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 — после этого