Files
MyClub/frontend/src/pages/admin/AdminVideosPage.tsx
T
Tomas Dvorak 16e4533202 dev day #75
2025-10-29 21:20:16 +01:00

544 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, Button, SimpleGrid, FormControl, FormLabel, Input, HStack, VStack, Text, IconButton, useToast, Divider, Alert, AlertIcon, Badge, Tooltip, Checkbox, Image, Spinner, Link, Switch, ButtonGroup } from '@chakra-ui/react';
import { getAdminSettings, updateAdminSettings, AdminSettings } from '../../services/settings';
import { FiPlus, FiTrash2, FiSave } from 'react-icons/fi';
import { triggerPrefetch } from '../../services/admin/prefetch';
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
export type AdminVideoItem = {
url: string;
title?: string;
length?: string;
uploaded_at?: string;
thumbnail_url?: string;
};
const emptyItem: AdminVideoItem = { url: '' };
const AdminVideosPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [items, setItems] = useState<AdminVideoItem[]>([]);
const [videosSource, setVideosSource] = useState<'auto'|'manual'>('manual');
const [videosEnabled, setVideosEnabled] = useState<boolean>(true);
const toast = useToast();
// YouTube Scraper API integration state
const [channelInput, setChannelInput] = useState<string>('');
const [ytLoading, setYtLoading] = useState<boolean>(false);
const [ytError, setYtError] = useState<string>('');
const [ytVideos, setYtVideos] = useState<Array<{
video_id: string;
title: string;
length?: string;
thumbnail_url?: string;
views?: number;
views_text?: string;
published_text?: string;
published_date?: string;
}>>([]);
const [selectedIds, setSelectedIds] = useState<Record<string, boolean>>({});
// Auto source preview state (cached YouTube)
const [autoVideos, setAutoVideos] = useState<YouTubeVideo[]>([]);
const [autoLoading, setAutoLoading] = useState<boolean>(false);
const [autoError, setAutoError] = useState<string>('');
const [filter, setFilter] = useState<string>('');
// Derived flags
const hasChannel = useMemo(() => (channelInput || '').trim().length > 0, [channelInput]);
useEffect(() => {
let mounted = true;
(async () => {
try {
const s: AdminSettings = await getAdminSettings();
if (!mounted) return;
const vids = Array.isArray((s as any).videos_items) ? (s as any).videos_items as AdminVideoItem[] : [];
const legacy = Array.isArray((s as any).videos) ? ((s as any).videos as string[]).map((url) => ({ url })) : [];
setItems(vids.length ? vids : legacy);
const src = (s as any).videos_source;
if (src === 'auto' || src === 'manual') setVideosSource(src);
// Default enable if not explicitly set and there are any videos configured
const explicit = (s as any).videos_module_enabled;
const hasAny = (vids.length + legacy.length) > 0;
setVideosEnabled(typeof explicit === 'boolean' ? Boolean(explicit) : hasAny);
// Prefill channel handle from settings if available (social/youtube_url)
const ytUrl = (s as any).youtube_url || (s as any).social_youtube || '';
if (ytUrl) setChannelInput(ytUrl);
} catch (e) {
// ignore
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Load cached YouTube videos for preview when auto source is active
useEffect(() => {
let mounted = true;
const run = async () => {
if (loading) return;
if (videosSource !== 'auto') return;
setAutoError('');
setAutoLoading(true);
try {
const payload = await getCachedYouTube();
if (!mounted) return;
setAutoVideos(payload?.videos || []);
} catch (err) {
if (!mounted) return;
setAutoError('Nepodařilo se načíst cache videí. Zkuste Aktualizovat.');
} finally {
if (mounted) setAutoLoading(false);
}
};
run();
return () => { mounted = false; };
}, [loading, videosSource]);
// Auto-disable videos module if there is neither channel nor manual items configured
useEffect(() => {
if (loading) return;
if (!hasChannel && items.length === 0 && videosEnabled) {
setVideosEnabled(false);
}
}, [loading, hasChannel, items.length, videosEnabled]);
// Auto-trigger backend prefetch of YouTube cache at most once per ~24h
useEffect(() => {
if (loading) return;
if (videosSource !== 'auto') return;
const channel = (channelInput || '').trim();
if (!channel) return;
const KEY = 'youtube_autoload_last';
let last = 0;
try { last = Number(localStorage.getItem(KEY) || '0'); } catch {}
const DAY_MS = 24 * 60 * 60 * 1000;
const due = !last || (Date.now() - last) > (23 * 60 * 60 * 1000); // ~23h to allow slight drift
if (!due) return;
(async () => {
try {
// Ask backend to refresh cached files; it will update youtube_channel.json opportunistically
await triggerPrefetch();
try { localStorage.setItem(KEY, String(Date.now())); } catch {}
toast({ status: 'info', title: 'Aktualizace videí', description: 'Na pozadí se aktualizuje cache videí z YouTube.', duration: 3000 });
} catch {
// silent
}
})();
}, [loading, videosSource, channelInput, toast]);
const refreshAuto = async () => {
setAutoError('');
setAutoLoading(true);
try {
await triggerPrefetch();
const payload = await getCachedYouTube();
setAutoVideos(payload?.videos || []);
toast({ status: 'success', title: 'Aktualizováno', description: 'Cache videí byla obnovena.', duration: 3000 });
} catch (e) {
setAutoError('Aktualizace cache selhala.');
} finally {
setAutoLoading(false);
}
};
const fetchChannelVideos = async () => {
const channel = channelInput?.trim();
if (!channel) {
toast({ status: 'warning', title: 'Zadejte kanál', description: 'Zadejte YouTube handle nebo URL kanálu.' });
return;
}
setYtError('');
setYtLoading(true);
setYtVideos([]);
setSelectedIds({});
try {
const url = `https://youtube.tdvorak.dev/channel_videos?channel=${encodeURIComponent(channel)}`;
const res = await fetch(url, { method: 'GET' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const vids = Array.isArray(data?.videos) ? data.videos : [];
setYtVideos(vids);
if (vids.length === 0) {
setYtError('Na kanálu nebyla nalezena žádná videa.');
}
} catch (err: any) {
console.error(err);
setYtError('Nepodařilo se načíst videa z API. Zkontrolujte dostupnost služby na https://youtube.tdvorak.dev/ a CORS.');
} finally {
setYtLoading(false);
}
};
const toggleSelect = (id: string) => {
setSelectedIds((prev) => ({ ...prev, [id]: !prev[id] }));
};
const importSelected = async () => {
const selected = ytVideos.filter((v) => selectedIds[v.video_id]);
if (selected.length === 0) {
toast({ status: 'info', title: 'Nic k importu', description: 'Vyberte alespoň jedno video.' });
return;
}
const newItems: AdminVideoItem[] = selected.map((v) => ({
url: `https://www.youtube.com/watch?v=${v.video_id}`,
title: v.title,
length: v.length,
uploaded_at: (v.published_date || '').slice(0,10),
thumbnail_url: v.thumbnail_url,
}));
// Avoid duplicates by URL
setItems((prev) => {
const urls = new Set(prev.map((p) => p.url));
const merged = [...prev];
for (const it of newItems) {
if (!urls.has(it.url)) {
merged.push(it);
urls.add(it.url);
}
}
return merged;
});
// If currently in auto mode, switch to manual so the preview reflects newly added items
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'info', title: 'Přepnuto na ruční správu', description: 'Nově přidaná videa se budou používat. Nezapomeňte uložit seznam.', duration: 3500 });
} catch {
// ignore
}
}
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
};
const addItem = async () => {
setItems((prev) => [...prev, { ...emptyItem }]);
if (videosSource !== 'manual') {
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
} catch {
// ignore
}
}
};
const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx));
const updateField = (idx: number, key: keyof AdminVideoItem, val: string) => {
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, [key]: val } : it));
};
const save = async () => {
setSaving(true);
try {
const clean = items.filter((it) => it.url && it.url.trim().length > 0);
await updateAdminSettings({ videos_items: clean, videos_module_enabled: videosEnabled });
toast({ status: 'success', title: 'Uloženo', description: 'Seznam videí byl uložen.' });
} catch (e) {
toast({ status: 'error', title: 'Chyba', description: 'Nepodařilo se uložit nastavení.' });
} finally {
setSaving(false);
}
};
const setDateQuick = (idx: number, daysAgo: number) => {
const d = new Date();
d.setDate(d.getDate() - daysAgo);
const iso = d.toISOString().slice(0,10);
setItems((prev) => prev.map((it, i) => i === idx ? { ...it, uploaded_at: iso } : it));
};
// Helper: derive YouTube thumbnail safely from a URL (supports youtube.com and youtu.be)
const getThumbFromUrl = (raw: string): string | undefined => {
try {
const u = raw.trim();
if (!u) return undefined;
if (u.includes('youtu.be/')) {
const id = u.split('youtu.be/')[1]?.split(/[?&#]/)[0];
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
}
if (u.includes('youtube.com')) {
// Try URL API first
try {
const url = new URL(u);
const id = url.searchParams.get('v') || '';
if (id) return `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
} catch {}
// Fallback regex
const m = u.match(/[?&]v=([^&#]+)/);
const id = m?.[1];
return id ? `https://i.ytimg.com/vi/${id}/hqdefault.jpg` : undefined;
}
} catch {}
return undefined;
};
return (
<AdminLayout>
<Box>
<Heading size="md" mb={2}>Videa (pro titulní stránku)</Heading>
<Text fontSize="sm" color="gray.600" mb={2}>Přidejte 5 videí (doporučeno). První se zobrazí jako hlavní, další 4 v mřížce. Podporováno YouTube/Vimeo URL.</Text>
{/* Source toggle */}
<HStack justify="space-between" mb={3} flexWrap="wrap">
<HStack>
<Text fontWeight="semibold">Zdroj videí:</Text>
<ButtonGroup size="sm" isAttached>
<Button
variant={videosSource === 'auto' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'auto') return;
setVideosSource('auto');
try {
await updateAdminSettings({ videos_source: 'auto' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa se načítají automaticky z YouTube.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Automaticky</Button>
<Button
variant={videosSource === 'manual' ? 'solid' : 'outline'}
onClick={async () => {
if (videosSource === 'manual') return;
setVideosSource('manual');
try {
await updateAdminSettings({ videos_source: 'manual' });
toast({ status: 'success', title: 'Zdroj nastaven', description: 'Videa spravujete ručně.', duration: 2500 });
} catch {
toast({ status: 'error', title: 'Chyba', description: 'Nelze uložit zdroj videí.', duration: 3000 });
}
}}
>Ručně</Button>
</ButtonGroup>
</HStack>
<FormControl display="flex" alignItems="center" w="auto">
<FormLabel mb={0}>Zobrazit sekci Videa na titulní stránce</FormLabel>
<Switch
isChecked={videosEnabled}
isDisabled={loading || saving}
onChange={async (e) => {
const next = e.target.checked;
// Require either channel or at least one manual video to enable
if (next && !hasChannel && items.length === 0) {
setVideosEnabled(false);
toast({ status: 'warning', title: 'Doplňte kanál nebo videa', description: 'Pro zobrazení sekce vyplňte YouTube kanál nebo přidejte alespoň jedno video.', duration: 4000 });
// Try to focus channel input
setTimeout(() => {
const el = document.getElementById('admin-videos-channel-input') as HTMLInputElement | null;
if (el) el.focus();
}, 0);
return;
}
setVideosEnabled(next);
try {
await updateAdminSettings({ videos_module_enabled: next });
} catch {
toast({ status: 'error', title: 'Uložení selhalo', description: 'Nepodařilo se uložit změnu zobrazení sekce.', duration: 3000 });
}
}}
/>
</FormControl>
</HStack>
{!hasChannel && items.length === 0 && (
<Text fontSize="sm" color="orange.600" mb={2}>Pro aktivaci sekce vyplňte YouTube kanál nebo přidejte video.</Text>
)}
{videosSource === 'auto' && (
<Alert status="info" mb={3} borderRadius="md">
<AlertIcon />
Automatický režim je zapnutý. Videa se načítají z YouTube kanálu z Nastavení Sociální sítě (YouTube URL) a správy Videa (YouTube modul). Manuální seznam je v tomto režimu skryt.
</Alert>
)}
{videosSource !== 'auto' && (
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<Heading size="sm" mb={2}>Import z YouTube kanálu</Heading>
<Text fontSize="sm" color="gray.600" mb={3}>
Použijte Scraper API. Zadejte handle (např. <code>@FotbalKunovice</code>) nebo URL kanálu a načtěte videa z karty Videa.
Služba: <Link href="https://youtube.tdvorak.dev/" isExternal color="blue.500">https://youtube.tdvorak.dev/</Link>
</Text>
<HStack align="start" spacing={3} flexWrap="wrap">
<FormControl maxW={{ base: '100%', md: '400px' }}>
<FormLabel>Kanál (handle nebo URL)</FormLabel>
<Input id="admin-videos-channel-input" placeholder="@FCBizoniUH nebo https://www.youtube.com/@FCBizoniUH/videos" value={channelInput} onChange={(e) => setChannelInput(e.target.value)} />
</FormControl>
<Button onClick={fetchChannelVideos} isLoading={ytLoading} variant="outline" flexShrink={0} minW="max-content">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0} flexShrink={0} minW="max-content">Přidat vybraná</Button>
</HStack>
{ytError && (
<Alert status="error" mt={3} borderRadius="md">
<AlertIcon />
{ytError}
</Alert>
)}
{ytLoading && (
<HStack mt={3} color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
)}
{!ytLoading && ytVideos.length > 0 && (
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3} mt={3}>
{ytVideos.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Checkbox isChecked={!!selectedIds[v.video_id]} onChange={() => toggleSelect(v.video_id)}>
Vybrat
</Checkbox>
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.length && <Badge>{v.length}</Badge>}
{v.published_text && <Text>{v.published_text}</Text>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
)}
</Box>
)}
{/* Always-visible preview of effective videos */}
<Box borderWidth="1px" borderRadius="md" p={3} mb={4}>
<HStack justify="space-between" align="center" mb={2} flexWrap="wrap">
<Heading size="sm">Náhled: všechna videa (aktivní zdroj)</Heading>
{videosSource === 'auto' && (
<HStack spacing={2} flexWrap="wrap">
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} width={{ base: '100%', md: '260px' }} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline" flexShrink={0} minW="max-content">Aktualizovat cache</Button>
</HStack>
)}
</HStack>
{videosSource === 'auto' ? (
<>
{autoError && (
<Alert status="error" mb={2} borderRadius="md"><AlertIcon />{autoError}</Alert>
)}
{autoLoading ? (
<HStack color="gray.600"><Spinner size="sm" /><Text>Načítám videa</Text></HStack>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {autoVideos.filter(v => v.title.toLowerCase().includes(filter.toLowerCase())).length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{autoVideos
.filter(v => v.title.toLowerCase().includes(filter.toLowerCase()))
.map((v) => (
<Box key={v.video_id} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={v.thumbnail_url} alt={v.title} borderRadius="md" />
<Box>
<Text fontWeight="semibold" noOfLines={2}>{v.title}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{v.published_date && <Badge>{new Date(v.published_date).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{autoVideos.length === 0 && (
<Text color="gray.600">Žádná videa v cache. Zkontrolujte YouTube URL v nastavení a použijte Aktualizovat cache.</Text>
)}
</>
)}
</>
) : (
<>
<Text fontSize="sm" color="gray.600" mb={2}>Počet videí: {items.length}</Text>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} spacing={3}>
{items.map((it, idx) => (
<Box key={`${idx}-${it.url}`} borderWidth="1px" borderRadius="md" p={2}>
<VStack align="stretch" spacing={2}>
<Image src={it.thumbnail_url || getThumbFromUrl(it.url)} alt={it.title || `Video ${idx+1}`} borderRadius="md" />
<Box>
<Text fontWeight="semibold" noOfLines={2}>{it.title || `Video ${idx+1}`}</Text>
<HStack spacing={2} color="gray.600" fontSize="sm">
{it.uploaded_at && <Badge>{(new Date(it.uploaded_at)).toLocaleDateString('cs-CZ')}</Badge>}
</HStack>
</Box>
</VStack>
</Box>
))}
</SimpleGrid>
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa.</Text>
)}
</>
)}
</Box>
<HStack justify="space-between" mb={3}>
<Button leftIcon={<FiPlus />} onClick={addItem}>Přidat video</Button>
<Button colorScheme="blue" leftIcon={<FiSave />} onClick={save} isLoading={saving}>Uložit</Button>
</HStack>
<Divider my={3} />
{loading ? (
<Text>Načítání</Text>
) : videosSource === 'auto' ? (
<Text color="gray.600">Automatický zdroj videí je aktivní. Pro ruční správu přepněte zdroj na Ručně.</Text>
) : (
<VStack align="stretch" spacing={4}>
{items.map((it, idx) => (
<Box key={idx} borderWidth="1px" borderRadius="md" p={3}>
<HStack justify="space-between">
<Heading size="sm">Video #{idx + 1}</Heading>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3} mt={3}>
<FormControl>
<FormLabel>URL videa</FormLabel>
<Input value={it.url} onChange={(e) => updateField(idx, 'url', e.target.value)} placeholder="https://www.youtube.com/watch?v=..." />
</FormControl>
<FormControl>
<FormLabel>Thumbnail (volitelné)</FormLabel>
<Input value={it.thumbnail_url || ''} onChange={(e) => updateField(idx, 'thumbnail_url', e.target.value)} placeholder="https://example.com/thumb.jpg" />
</FormControl>
<FormControl>
<FormLabel>Název (volitelné)</FormLabel>
<Input value={it.title || ''} onChange={(e) => updateField(idx, 'title', e.target.value)} placeholder="Titulek videa" />
</FormControl>
<FormControl>
<FormLabel>Délka (volitelné)</FormLabel>
<Input value={it.length || ''} onChange={(e) => updateField(idx, 'length', e.target.value)} placeholder="3:45" />
</FormControl>
<FormControl>
<FormLabel>Datum nahrání (volitelné)</FormLabel>
<HStack>
<Input type="date" value={(it.uploaded_at || '').slice(0,10)} onChange={(e) => updateField(idx, 'uploaded_at', e.target.value)} />
<Tooltip label="Dnes">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 0)}>Dnes</Button>
</Tooltip>
<Tooltip label="Včera">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 1)}>Včera</Button>
</Tooltip>
<Tooltip label="Před týdnem">
<Button size="sm" variant="outline" onClick={() => setDateQuick(idx, 7)}>7 dní</Button>
</Tooltip>
<Tooltip label="Vymazat datum">
<Button size="sm" variant="ghost" onClick={() => updateField(idx, 'uploaded_at', '')}>Vymazat</Button>
</Tooltip>
</HStack>
</FormControl>
</SimpleGrid>
<HStack justify="flex-end" mt={2}>
<IconButton aria-label="Smazat" icon={<FiTrash2 />} onClick={() => removeItem(idx)} variant="outline" colorScheme="red" />
</HStack>
</Box>
))}
{items.length === 0 && (
<Text color="gray.600">Zatím žádná videa. Použijte tlačítko Přidat video.</Text>
)}
</VStack>
)}
</Box>
</AdminLayout>
);
};
export default AdminVideosPage;