This commit is contained in:
Tomáš Dvořák
2025-10-16 13:32:05 +02:00
commit 12cba639b9
663 changed files with 168914 additions and 0 deletions
@@ -0,0 +1,523 @@
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 = () => {
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;
});
toast({ status: 'success', title: 'Videa přidána', description: `${selected.length} videí bylo přidáno do seznamu.` });
};
const addItem = () => setItems((prev) => [...prev, { ...emptyItem }]);
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}>
<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">Načíst videa</Button>
<Button colorScheme="green" onClick={importSelected} isDisabled={ytVideos.length === 0}>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}>
<Input size="sm" placeholder="Filtrovat podle názvu" value={filter} onChange={(e) => setFilter(e.target.value)} />
<Button size="sm" onClick={refreshAuto} isLoading={autoLoading} variant="outline">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;