From a2685fb602224ded2bb93a838817e319e5a27a5e Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 24 Jul 2026 16:18:54 +0300 Subject: [PATCH] Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support. --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 30 +- .../features/admin/channels/ChannelDetail.tsx | 660 ++++++++++++++++++ .../features/admin/channels/ChannelsPanel.tsx | 109 +++ frontend/src/features/admin/channels/api.ts | 92 +++ .../src/features/admin/media/MediaPanel.tsx | 157 +++++ frontend/src/features/admin/media/api.ts | 66 ++ .../src/features/admin/shows/ShowDetail.tsx | 136 ++++ .../src/features/admin/shows/ShowsPanel.tsx | 113 +++ frontend/src/features/admin/shows/api.ts | 29 + frontend/src/features/streaming/AirPage.tsx | 145 ++++ .../src/features/streaming/ChannelPlayer.tsx | 55 ++ frontend/src/features/streaming/api.ts | 19 + frontend/src/routeTree.gen.ts | 125 ++++ frontend/src/routes/admin.tsx | 23 +- .../src/routes/admin/channels.$channelId.tsx | 9 + frontend/src/routes/admin/channels.tsx | 4 + frontend/src/routes/admin/index.tsx | 2 +- frontend/src/routes/admin/media.tsx | 4 + frontend/src/routes/admin/shows.$showId.tsx | 9 + frontend/src/routes/admin/shows.tsx | 4 + frontend/src/routes/dashboard.tsx | 33 +- frontend/src/shared/api/types.ts | 117 ++++ frontend/src/shared/lib/i18n.ts | 150 ++++ 24 files changed, 2050 insertions(+), 42 deletions(-) create mode 100644 frontend/src/features/admin/channels/ChannelDetail.tsx create mode 100644 frontend/src/features/admin/channels/ChannelsPanel.tsx create mode 100644 frontend/src/features/admin/channels/api.ts create mode 100644 frontend/src/features/admin/media/MediaPanel.tsx create mode 100644 frontend/src/features/admin/media/api.ts create mode 100644 frontend/src/features/admin/shows/ShowDetail.tsx create mode 100644 frontend/src/features/admin/shows/ShowsPanel.tsx create mode 100644 frontend/src/features/admin/shows/api.ts create mode 100644 frontend/src/features/streaming/AirPage.tsx create mode 100644 frontend/src/features/streaming/ChannelPlayer.tsx create mode 100644 frontend/src/features/streaming/api.ts create mode 100644 frontend/src/routes/admin/channels.$channelId.tsx create mode 100644 frontend/src/routes/admin/channels.tsx create mode 100644 frontend/src/routes/admin/media.tsx create mode 100644 frontend/src/routes/admin/shows.$showId.tsx create mode 100644 frontend/src/routes/admin/shows.tsx diff --git a/frontend/package.json b/frontend/package.json index 8464ba3..c1da005 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@tanstack/react-router": "^1.170.16", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "hls.js": "^1.6.16", "i18next": "^26.3.4", "lucide-react": "^1.22.0", "react": "^19.2.7", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index adf7d1f..5bef8e8 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + hls.js: + specifier: ^1.6.16 + version: 1.6.16 i18next: specifier: ^26.3.4 version: 26.3.6(typescript@6.0.3) @@ -68,7 +71,7 @@ importers: version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) '@tanstack/router-plugin': specifier: ^1.168.18 - version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) '@types/node': specifier: ^24.13.2 version: 24.13.3 @@ -1051,6 +1054,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + html-parse-stringify@4.0.1: resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} @@ -1605,7 +1611,7 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -1614,7 +1620,7 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -1645,17 +1651,17 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -1682,7 +1688,7 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 @@ -2266,9 +2272,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))': dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@tanstack/router-core': 1.171.15 @@ -2343,9 +2349,9 @@ snapshots: babel-dead-code-elimination@1.0.12: dependencies: - '@babel/core': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -2424,6 +2430,8 @@ snapshots: graceful-fs@4.2.11: {} + hls.js@1.6.16: {} + html-parse-stringify@4.0.1: {} https-proxy-agent@7.0.6(supports-color@10.2.2): diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx new file mode 100644 index 0000000..ee054e9 --- /dev/null +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -0,0 +1,660 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronLeft, RefreshCw } from 'lucide-react' +import { HttpError } from '@/shared/api/client' +import type { + AdInsertion, + BlockMode, + ChannelShowDto, + OverrideMode, + ScheduleEntryDto, +} from '@/shared/api/types' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { listMedia } from '@/features/admin/media/api' +import { listShows } from '@/features/admin/shows/api' +import { + addChannelAd, + addChannelShow, + createOverride, + deleteOverride, + getChannel, + getSchedule, + regenerateSchedule, + removeChannelAd, + removeChannelShow, + updateChannelSettings, + updateChannelShow, +} from './api' + +function formatTime(iso: string) { + return new Date(iso).toLocaleString([], { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function ChannelDetail({ channelId }: { channelId: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + + const { data: channel, isLoading } = useQuery({ + queryKey: ['admin', 'channels', channelId], + queryFn: () => getChannel(channelId), + }) + const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) + const { data: ready } = useQuery({ + queryKey: ['admin', 'media', 'ready'], + queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }), + }) + const { data: schedule } = useQuery({ + queryKey: ['admin', 'channels', channelId, 'schedule'], + queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)), + }) + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] }) + } + const invalidateSchedule = () => + queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const regenerateMutation = useMutation({ + mutationFn: () => regenerateSchedule(channelId), + onSuccess: () => { + toast.success(t('admin.channels.regenerated')) + void invalidateSchedule() + }, + onError, + }) + + if (isLoading || !channel) return

{t('common.loading')}

+ + const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? [] + + return ( +
+
+
+ +

{channel.name}

+ {channel.slug} +
+ +
+ + + + {/* Шоу канала */} + + + {t('admin.channels.shows')} + + + ({ id: s.id, name: s.name }))} + onAdded={invalidate} + onError={onError} + /> + + + + + + + + + + + + {channel.shows.map((row) => ( + + ))} + {channel.shows.length === 0 && ( + + + + )} + +
{t('admin.channels.show')}{t('admin.channels.weight')}{t('admin.channels.block')}{t('admin.channels.on')}{t('common.actions')}
+ {t('admin.channels.noShows')} +
+
+
+ + {/* Реклама */} + + + {t('admin.channels.ads')} + + + !channel.ads.some((ad) => ad.mediaAssetId === a.id)) + .map((a) => ({ id: a.id, name: a.originalFileName }))} + onAdded={invalidate} + onError={onError} + /> +
    + {channel.ads.map((ad) => ( +
  • + {ad.assetName ?? '—'} + + removeChannelAd(channelId, ad.id).then(invalidate).catch(onError) + } + /> +
  • + ))} + {channel.ads.length === 0 && ( +
  • {t('admin.channels.noAds')}
  • + )} +
+
+
+ + {/* Override'ы / марафоны */} + + + {t('admin.channels.overrides')} + + + ({ id: cs.showId, name: cs.showName }))} + onCreated={invalidate} + onError={onError} + /> +
    + {channel.overrides.map((o) => ( +
  • + + {t(`admin.channels.modes.${o.mode}`)}{' '} + {formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '} + {o.shows.map((s) => s.showName).join(', ')} + + deleteOverride(channelId, o.id).then(invalidate).catch(onError)} + /> +
  • + ))} + {channel.overrides.length === 0 && ( +
  • {t('admin.channels.noOverrides')}
  • + )} +
+
+
+ + {/* Предпросмотр расписания */} + + + {t('admin.channels.schedule')} + + + + + +
+ ) +} + +function SettingsCard({ + channel, + readyAssets, + onSaved, + onError, +}: { + channel: import('@/shared/api/types').ChannelDto + readyAssets: { id: string; originalFileName: string }[] + onSaved: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [name, setName] = useState(channel.name) + const [isEnabled, setIsEnabled] = useState(channel.isEnabled) + const [adInsertion, setAdInsertion] = useState(channel.adInsertion) + const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak) + const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '') + + useEffect(() => { + setName(channel.name) + setIsEnabled(channel.isEnabled) + setAdInsertion(channel.adInsertion) + setAdsPerBreak(channel.adsPerBreak) + setFillerAssetId(channel.fillerAssetId ?? '') + }, [channel]) + + const save = useMutation({ + mutationFn: () => + updateChannelSettings(channel.id, { + name: name.trim(), + isEnabled, + adInsertion, + adsPerBreak, + fillerAssetId: fillerAssetId || null, + }), + onSuccess: () => { + toast.success(t('settings.saved')) + onSaved() + }, + onError, + }) + + return ( + + + {t('admin.channels.settings')} + + +
+ + setName(e.target.value)} /> +
+
+ + +
+
+ + setAdsPerBreak(Number(e.target.value))} + /> +
+
+ + +
+ +
+ +
+
+
+ ) +} + +function AddShowForm({ + channelId, + options, + onAdded, + onError, +}: { + channelId: string + options: { id: string; name: string }[] + onAdded: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [showId, setShowId] = useState('') + const [weight, setWeight] = useState(1) + const [blockMode, setBlockMode] = useState('Count') + const [blockValue, setBlockValue] = useState(1) + + const add = useMutation({ + mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }), + onSuccess: () => { + setShowId('') + onAdded() + }, + onError, + }) + + return ( +
+ + + + + +
+ ) +} + +function ChannelShowRow({ + channelId, + row, + onChanged, + onError, +}: { + channelId: string + row: ChannelShowDto + onChanged: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [weight, setWeight] = useState(row.weight) + const [blockMode, setBlockMode] = useState(row.blockMode) + const [blockValue, setBlockValue] = useState(row.blockValue) + const [isEnabled, setIsEnabled] = useState(row.isEnabled) + + const save = useMutation({ + mutationFn: () => + updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }), + onSuccess: onChanged, + onError, + }) + + return ( + + {row.showName} + + setWeight(Number(e.target.value))} + className="h-8 w-16" + /> + + +
+ + setBlockValue(Number(e.target.value))} + className="h-8 w-16" + /> +
+ + + setIsEnabled(e.target.checked)} + /> + + +
+ + removeChannelShow(channelId, row.id).then(onChanged).catch(onError)} + /> +
+ + + ) +} + +function AddAdForm({ + channelId, + options, + onAdded, + onError, +}: { + channelId: string + options: { id: string; name: string }[] + onAdded: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [assetId, setAssetId] = useState('') + + const add = useMutation({ + mutationFn: () => addChannelAd(channelId, assetId), + onSuccess: () => { + setAssetId('') + onAdded() + }, + onError, + }) + + return ( +
+ + +
+ ) +} + +function OverrideForm({ + channelId, + options, + onCreated, + onError, +}: { + channelId: string + options: { id: string; name: string }[] + onCreated: () => void + onError: (e: unknown) => void +}) { + const { t } = useTranslation() + const [mode, setMode] = useState('Exclusive') + const [showId, setShowId] = useState('') + const [weight, setWeight] = useState(1) + const [start, setStart] = useState('') + const [end, setEnd] = useState('') + + const create = useMutation({ + mutationFn: () => + createOverride(channelId, { + mode, + startsAtUtc: new Date(start).toISOString(), + endsAtUtc: new Date(end).toISOString(), + shows: [{ showId, weight }], + }), + onSuccess: () => { + setShowId('') + setStart('') + setEnd('') + onCreated() + }, + onError, + }) + + const valid = showId && start && end && new Date(end) > new Date(start) + + return ( +
+ + + {mode === 'Boost' && ( + + )} +
+ + setStart(e.target.value)} className="w-52" /> +
+
+ + setEnd(e.target.value)} className="w-52" /> +
+ +
+ ) +} + +function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) { + const { t } = useTranslation() + if (entries.length === 0) + return

{t('admin.channels.noSchedule')}

+ + return ( +
    + {entries.slice(0, 40).map((e) => ( +
  • + {formatTime(e.startsAtUtc)} + {e.kind === 'Ad' ? ( + {t('air.ad')} + ) : ( + + {e.showName ?? '—'} + {e.episodeIndex != null && ( + · {t('air.episode')} {e.episodeIndex + 1} + )} + + )} +
  • + ))} +
+ ) +} + +function NumberField({ + label, + value, + onChange, + min, +}: { + label: string + value: number + onChange: (v: number) => void + min?: number +}) { + return ( +
+ + onChange(Number(e.target.value))} + className="w-24" + /> +
+ ) +} + +function RemoveButton({ onClick }: { onClick: () => void }) { + const { t } = useTranslation() + return ( + + ) +} diff --git a/frontend/src/features/admin/channels/ChannelsPanel.tsx b/frontend/src/features/admin/channels/ChannelsPanel.tsx new file mode 100644 index 0000000..4f005df --- /dev/null +++ b/frontend/src/features/admin/channels/ChannelsPanel.tsx @@ -0,0 +1,109 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { HttpError } from '@/shared/api/client' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { toast } from '@/shared/ui/toast-store' +import { createChannel, listChannels } from './api' + +function slugify(value: string) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') +} + +export function ChannelsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [slug, setSlug] = useState('') + + const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels }) + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const createMutation = useMutation({ + mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }), + onSuccess: () => { + setName('') + setSlug('') + invalidate() + }, + onError, + }) + + return ( +
+

{t('admin.channels.title')}

+ +
+ setName(e.target.value)} + /> + setSlug(slugify(e.target.value))} + /> + +
+ +
+ + + + + + + + + + {isLoading && ( + + + + )} + {data?.map((channel) => ( + + + + + + ))} + +
{t('admin.channels.name')}{t('admin.channels.slug')}{t('admin.channels.state')}
+ {t('common.loading')} +
+ + {channel.name} + + {channel.slug} + {channel.isEnabled ? ( + {t('admin.channels.enabled')} + ) : ( + {t('admin.channels.disabled')} + )} +
+
+
+ ) +} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts new file mode 100644 index 0000000..89584ea --- /dev/null +++ b/frontend/src/features/admin/channels/api.ts @@ -0,0 +1,92 @@ +import { apiRequest } from '@/shared/api/client' +import type { + AdInsertion, + BlockMode, + ChannelDto, + ChannelSummaryDto, + CreatedIdResponse, + OverrideMode, + ScheduleEntryDto, +} from '@/shared/api/types' + +export function listChannels() { + return apiRequest('/admin/channels') +} + +export function getChannel(id: string) { + return apiRequest(`/admin/channels/${id}`) +} + +export function createChannel(body: { name: string; slug: string }) { + return apiRequest('/admin/channels', { method: 'POST', body }) +} + +export type ChannelSettingsBody = { + name: string + isEnabled: boolean + adInsertion: AdInsertion + adsPerBreak: number + fillerAssetId: string | null +} + +export function updateChannelSettings(id: string, body: ChannelSettingsBody) { + return apiRequest(`/admin/channels/${id}/settings`, { method: 'PUT', body }) +} + +export type ChannelShowBody = { + showId: string + weight: number + blockMode: BlockMode + blockValue: number +} + +export function addChannelShow(id: string, body: ChannelShowBody) { + return apiRequest(`/admin/channels/${id}/shows`, { method: 'POST', body }) +} + +export function updateChannelShow( + id: string, + channelShowId: string, + body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean }, +) { + return apiRequest(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body }) +} + +export function removeChannelShow(id: string, channelShowId: string) { + return apiRequest(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' }) +} + +export function addChannelAd(id: string, mediaAssetId: string) { + return apiRequest(`/admin/channels/${id}/ads`, { + method: 'POST', + body: { mediaAssetId }, + }) +} + +export function removeChannelAd(id: string, channelAdId: string) { + return apiRequest(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' }) +} + +export type OverrideBody = { + mode: OverrideMode + startsAtUtc: string + endsAtUtc: string + shows: { showId: string; weight: number }[] +} + +export function createOverride(id: string, body: OverrideBody) { + return apiRequest(`/admin/channels/${id}/overrides`, { method: 'POST', body }) +} + +export function deleteOverride(id: string, overrideId: string) { + return apiRequest(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' }) +} + +export function regenerateSchedule(id: string) { + return apiRequest(`/admin/channels/${id}/regenerate`, { method: 'POST' }) +} + +export function getSchedule(id: string, from: Date, to: Date) { + const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() }) + return apiRequest(`/admin/channels/${id}/schedule?${query.toString()}`) +} diff --git a/frontend/src/features/admin/media/MediaPanel.tsx b/frontend/src/features/admin/media/MediaPanel.tsx new file mode 100644 index 0000000..8aa30bc --- /dev/null +++ b/frontend/src/features/admin/media/MediaPanel.tsx @@ -0,0 +1,157 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Upload } from 'lucide-react' +import { HttpError } from '@/shared/api/client' +import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' +import { Badge, type BadgeProps } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { toast } from '@/shared/ui/toast-store' +import { deleteMedia, listMedia, uploadMedia } from './api' + +const PAGE_SIZE = 20 + +export function formatDuration(seconds: number | null): string { + if (seconds == null) return '—' + const total = Math.round(seconds) + const h = Math.floor(total / 3600) + const m = Math.floor((total % 3600) / 60) + const s = total % 60 + const pad = (n: number) => String(n).padStart(2, '0') + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}` +} + +const statusVariant: Record = { + Ready: 'default', + Processing: 'muted', + Pending: 'muted', + Failed: 'destructive', +} + +export function MediaPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const fileInput = useRef(null) + const [progress, setProgress] = useState(null) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'media'], + queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }), + // Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI. + refetchInterval: (query) => + query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending') + ? 4000 + : false, + }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError }) + + const handleFile = async (file: File) => { + setProgress(0) + try { + await uploadMedia(file, setProgress) + toast.success(t('admin.media.uploaded')) + invalidate() + } catch (error) { + onError(error) + } finally { + setProgress(null) + if (fileInput.current) fileInput.current.value = '' + } + } + + return ( +
+
+

{t('admin.media.title')}

+
+ {progress != null && ( + {progress}% + )} + { + const file = e.target.files?.[0] + if (file) void handleFile(file) + }} + /> + +
+
+ +
+ + + + + + + + + + + + {isLoading && ( + + + + )} + {data?.items.map((asset) => ( + deleteMutation.mutate(asset.id)} + /> + ))} + {data && data.items.length === 0 && !isLoading && ( + + + + )} + +
{t('admin.media.name')}{t('admin.media.status')}{t('admin.media.duration')}{t('admin.media.resolution')}{t('common.actions')}
+ {t('common.loading')} +
+ {t('admin.media.empty')} +
+
+
+ ) +} + +function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) { + const { t } = useTranslation() + return ( + + {asset.originalFileName} + + + {t(`admin.media.statuses.${asset.status}`)} + + + {formatDuration(asset.durationSeconds)} + + {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} + + + + + + ) +} diff --git a/frontend/src/features/admin/media/api.ts b/frontend/src/features/admin/media/api.ts new file mode 100644 index 0000000..4e7979a --- /dev/null +++ b/frontend/src/features/admin/media/api.ts @@ -0,0 +1,66 @@ +import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client' +import type { + CreatedIdResponse, + MediaAssetDto, + MediaAssetStatus, + PagedList, +} from '@/shared/api/types' + +export type ListMediaParams = { + page: number + pageSize: number + status?: MediaAssetStatus + search?: string +} + +export function listMedia(params: ListMediaParams) { + const query = new URLSearchParams({ + page: String(params.page), + pageSize: String(params.pageSize), + }) + if (params.status) query.set('status', params.status) + if (params.search) query.set('search', params.search) + return apiRequest>(`/admin/media?${query.toString()}`) +} + +export function deleteMedia(id: string) { + return apiRequest(`/admin/media/${id}`, { method: 'DELETE' }) +} + +/** + * Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса. + */ +export function uploadMedia( + file: File, + onProgress?: (percent: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + const query = new URLSearchParams({ fileName: file.name }) + xhr.open('POST', `/api/admin/media?${query.toString()}`) + + const token = getAccessToken() + if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`) + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress) + onProgress(Math.round((event.loaded / event.total) * 100)) + } + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(JSON.parse(xhr.responseText) as CreatedIdResponse) + } else { + let detail = `HTTP ${xhr.status}` + try { + const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string } + detail = problem.detail ?? problem.title ?? detail + } catch { + /* пусто */ + } + reject(new HttpError({ detail }, xhr.status)) + } + } + xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0)) + xhr.send(file) + }) +} diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx new file mode 100644 index 0000000..b7fd383 --- /dev/null +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -0,0 +1,136 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { ChevronLeft } from 'lucide-react' +import { HttpError } from '@/shared/api/client' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { listMedia } from '@/features/admin/media/api' +import { formatDuration } from '@/features/admin/media/MediaPanel' +import { addEpisode, getShow, removeEpisode } from './api' + +export function ShowDetail({ showId }: { showId: string }) { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [assetId, setAssetId] = useState('') + + const { data: show, isLoading } = useQuery({ + queryKey: ['admin', 'shows', showId], + queryFn: () => getShow(showId), + }) + const { data: ready } = useQuery({ + queryKey: ['admin', 'media', 'ready'], + queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }), + }) + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const addMutation = useMutation({ + mutationFn: () => addEpisode(showId, assetId), + onSuccess: () => { + setAssetId('') + invalidate() + }, + onError, + }) + const removeMutation = useMutation({ + mutationFn: (episodeId: string) => removeEpisode(showId, episodeId), + onSuccess: invalidate, + onError, + }) + + if (isLoading || !show) return

{t('common.loading')}

+ + const canAdd = show.kind !== 'Single' || show.episodes.length === 0 + + return ( +
+
+ +
+ +
+

{show.name}

+ {t(`admin.shows.kinds.${show.kind}`)} +
+ + {canAdd && ( +
+ + +
+ )} + +
+ + + + + + + + + + + + {show.episodes.map((episode, index) => ( + + + + + + + + ))} + {show.episodes.length === 0 && ( + + + + )} + +
#{t('admin.shows.episode')}{t('admin.media.duration')}{t('admin.media.status')}{t('common.actions')}
{index + 1}{episode.assetName ?? '—'} + {formatDuration(episode.durationSeconds)} + + {episode.assetStatus && ( + + {t(`admin.media.statuses.${episode.assetStatus}`)} + + )} + + +
+ {t('admin.shows.noEpisodes')} +
+
+
+ ) +} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx new file mode 100644 index 0000000..1bffd9c --- /dev/null +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -0,0 +1,113 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { HttpError } from '@/shared/api/client' +import type { ShowKind } from '@/shared/api/types' +import { Badge } from '@/shared/ui/badge' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' +import { toast } from '@/shared/ui/toast-store' +import { createShow, deleteShow, listShows } from './api' + +export function ShowsPanel() { + const { t } = useTranslation() + const queryClient = useQueryClient() + const [name, setName] = useState('') + const [kind, setKind] = useState('Series') + + const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] }) + const onError = (error: unknown) => + toast.error(error instanceof HttpError ? error.detail : t('common.error')) + + const createMutation = useMutation({ + mutationFn: () => createShow({ name: name.trim(), kind }), + onSuccess: () => { + setName('') + invalidate() + }, + onError, + }) + const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError }) + + return ( +
+

{t('admin.shows.title')}

+ +
+ setName(e.target.value)} + /> + + +
+ +
+ + + + + + + + + + + {isLoading && ( + + + + )} + {data?.map((show) => ( + + + + + + + ))} + +
{t('admin.shows.name')}{t('admin.shows.kind')}{t('admin.shows.episodes')}{t('common.actions')}
+ {t('common.loading')} +
+ + {show.name} + + + {t(`admin.shows.kinds.${show.kind}`)} + {show.episodeCount} + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/shows/api.ts b/frontend/src/features/admin/shows/api.ts new file mode 100644 index 0000000..040a5f7 --- /dev/null +++ b/frontend/src/features/admin/shows/api.ts @@ -0,0 +1,29 @@ +import { apiRequest } from '@/shared/api/client' +import type { CreatedIdResponse, ShowDto, ShowKind, ShowSummaryDto } from '@/shared/api/types' + +export function listShows() { + return apiRequest('/admin/shows') +} + +export function getShow(id: string) { + return apiRequest(`/admin/shows/${id}`) +} + +export function createShow(body: { name: string; kind: ShowKind; description?: string }) { + return apiRequest('/admin/shows', { method: 'POST', body }) +} + +export function deleteShow(id: string) { + return apiRequest(`/admin/shows/${id}`, { method: 'DELETE' }) +} + +export function addEpisode(showId: string, mediaAssetId: string) { + return apiRequest(`/admin/shows/${showId}/episodes`, { + method: 'POST', + body: { mediaAssetId }, + }) +} + +export function removeEpisode(showId: string, episodeId: string) { + return apiRequest(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/features/streaming/AirPage.tsx b/frontend/src/features/streaming/AirPage.tsx new file mode 100644 index 0000000..bc52542 --- /dev/null +++ b/frontend/src/features/streaming/AirPage.tsx @@ -0,0 +1,145 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Radio } from 'lucide-react' +import type { ScheduleEntryDto } from '@/shared/api/types' +import { cn } from '@/shared/lib/cn' +import { Badge } from '@/shared/ui/badge' +import { ChannelPlayer } from './ChannelPlayer' +import { getEpg, listChannels, watchChannel } from './api' + +function formatTime(iso: string) { + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +export function AirPage() { + const { t } = useTranslation() + const [selected, setSelected] = useState(null) + const [watchReady, setWatchReady] = useState(false) + + const { data: channels, isLoading } = useQuery({ + queryKey: ['air', 'channels'], + queryFn: listChannels, + }) + + useEffect(() => { + if (!selected && channels && channels.length > 0) setSelected(channels[0].slug) + }, [channels, selected]) + + useEffect(() => { + if (!selected) return + setWatchReady(false) + let cancelled = false + void watchChannel(selected) + .then(() => { + if (!cancelled) setWatchReady(true) + }) + .catch(() => undefined) + return () => { + cancelled = true + } + }, [selected]) + + const { data: epg } = useQuery({ + queryKey: ['air', 'epg', selected], + queryFn: () => + getEpg( + selected!, + new Date(Date.now() - 30 * 60_000), + new Date(Date.now() + 3 * 60 * 60_000), + ), + enabled: !!selected, + refetchInterval: 60_000, + }) + + const { current, upcoming } = useMemo(() => splitEpg(epg ?? []), [epg]) + + if (isLoading) return

{t('common.loading')}

+ + if (!channels || channels.length === 0) + return ( +
+

{t('nav.dashboard')}

+

{t('air.noChannels')}

+
+ ) + + return ( +
+

{t('nav.dashboard')}

+ +
+ + +
+ {selected && watchReady ? ( + + ) : ( +
+ )} + +
+ {current && ( +
+
+ {t('air.now')} + {programLabel(current, t)} +
+ + {formatTime(current.startsAtUtc)} – {formatTime(current.endsAtUtc)} + +
+ )} + + {upcoming.length > 0 && ( +
+
+ {t('air.next')} +
+
    + {upcoming.slice(0, 6).map((entry) => ( +
  • + + {formatTime(entry.startsAtUtc)} + + {programLabel(entry, t)} +
  • + ))} +
+
+ )} +
+
+
+
+ ) +} + +function splitEpg(entries: ScheduleEntryDto[]) { + const now = Date.now() + const current = entries.find( + (e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now, + ) + const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now) + return { current, upcoming } +} + +function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) { + if (entry.kind === 'Ad') return t('air.ad') + const name = entry.showName ?? '—' + return entry.episodeIndex != null ? `${name} · ${t('air.episode')} ${entry.episodeIndex + 1}` : name +} diff --git a/frontend/src/features/streaming/ChannelPlayer.tsx b/frontend/src/features/streaming/ChannelPlayer.tsx new file mode 100644 index 0000000..8863da5 --- /dev/null +++ b/frontend/src/features/streaming/ChannelPlayer.tsx @@ -0,0 +1,55 @@ +import Hls from 'hls.js' +import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +/** + * HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому + * запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS — + * фолбэк для Safari/iOS. + */ +export function ChannelPlayer({ slug }: { slug: string }) { + const videoRef = useRef(null) + const { t } = useTranslation() + const [error, setError] = useState(null) + + useEffect(() => { + const video = videoRef.current + if (!video) return + setError(null) + const src = `/api/channels/${slug}/live.m3u8` + let hls: Hls | null = null + + if (Hls.isSupported()) { + hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false }) + hls.loadSource(src) + hls.attachMedia(video) + hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined)) + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) setError(t('air.playbackError')) + }) + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = src + video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined)) + video.addEventListener('error', () => setError(t('air.playbackError'))) + } else { + setError(t('air.unsupported')) + } + + return () => { + hls?.destroy() + } + }, [slug, t]) + + return ( +
+
+ ) +} diff --git a/frontend/src/features/streaming/api.ts b/frontend/src/features/streaming/api.ts new file mode 100644 index 0000000..55293d5 --- /dev/null +++ b/frontend/src/features/streaming/api.ts @@ -0,0 +1,19 @@ +import { apiRequest } from '@/shared/api/client' +import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types' + +export function listChannels() { + return apiRequest('/channels') +} + +/** Выдаёт httpOnly-cookie tw_stream — после этого