{
// Invalidate queries to refresh polls
qc.invalidateQueries({ queryKey: ['linked-polls'] });
diff --git a/frontend/src/pages/admin/CommentsAdminPage.tsx b/frontend/src/pages/admin/CommentsAdminPage.tsx
index 7b033d6..d7cdc60 100644
--- a/frontend/src/pages/admin/CommentsAdminPage.tsx
+++ b/frontend/src/pages/admin/CommentsAdminPage.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import AdminLayout from '../../layouts/AdminLayout';
import { Box, Heading, HStack, VStack, Button, Select, Input, Table, Thead, Tbody, Tr, Th, Td, Text, Badge, IconButton, useToast, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalFooter, ModalCloseButton, useDisclosure, FormControl, FormLabel, NumberInput, NumberInputField, Switch } from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban } from '../../services/admin/comments';
+import { adminListComments, adminUpdateCommentStatus, adminBanUser, adminListUnbanRequests, adminResolveUnban, adminListBans, adminLiftBan } from '../../services/admin/comments';
import { deleteComment } from '../../services/comments';
import { FiTrash2 } from 'react-icons/fi';
import { getArticles } from '../../services/articles';
@@ -37,6 +37,11 @@ const CommentsAdminPage: React.FC = () => {
queryFn: adminListUnbanRequests,
});
+ const bansQ = useQuery({
+ queryKey: ['admin-comment-bans'],
+ queryFn: adminListBans,
+ });
+
const updateStatusMut = useMutation({
mutationFn: (args: { id: number; s: 'visible'|'hidden' }) => adminUpdateCommentStatus(args.id, args.s),
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comments'] }); },
@@ -57,7 +62,16 @@ const CommentsAdminPage: React.FC = () => {
const resolveUnbanMut = useMutation({
mutationFn: (args: { id: number; action: 'approve'|'reject' }) => adminResolveUnban(args.id, args.action),
- onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] }); toast({ status: 'success', title: 'Vyřízeno' }); },
+ onSuccess: async () => {
+ await qc.invalidateQueries({ queryKey: ['admin-unban-requests'] });
+ await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] });
+ toast({ status: 'success', title: 'Vyřízeno' });
+ },
+ });
+
+ const liftBanMut = useMutation({
+ mutationFn: (id: number) => adminLiftBan(id),
+ onSuccess: async () => { await qc.invalidateQueries({ queryKey: ['admin-comment-bans'] }); toast({ status: 'success', title: 'Ban zrušen' }); },
});
React.useEffect(() => {
@@ -167,7 +181,10 @@ const CommentsAdminPage: React.FC = () => {
| #{c.id} |
#{c.user?.id} {c.user?.first_name} {c.user?.last_name} |
- {c.target_type} {c.target_id} |
+
+ {c.target_type}
+ {c.target_label || c.target_id}
+ |
{c.content} |
{(c as any).spam_score ? 0.5 ? 'orange' : 'green'}>{(c as any).spam_score.toFixed(2)} : '-'} |
{(c as any).reports ? 2 ? 'red' : 'yellow'}>{(c as any).reports} : '-'} |
@@ -213,7 +230,7 @@ const CommentsAdminPage: React.FC = () => {
{(unbanQ.data?.items || []).map((r) => (
| #{r.id} |
- #{r.user_id} |
+ #{r.user?.id} {r.user?.first_name} {r.user?.last_name} {r.user?.email} |
{r.message} |
{r.status} |
@@ -228,6 +245,39 @@ const CommentsAdminPage: React.FC = () => {
+ Zablokovaní uživatelé
+
+
+
+
+ | ID |
+ Uživatel |
+ Důvod |
+ Zabanován |
+ Platné do |
+ Akce |
+
+
+
+ {(bansQ.data?.items || []).map((b) => {
+ const untilText = !b.until ? 'Trvale' : new Date(b.until).toLocaleString();
+ return (
+
+ | #{b.id} |
+ #{b.user?.id} {b.user?.first_name} {b.user?.last_name} {b.user?.email} |
+ {b.reason || '-'} |
+ {new Date(b.created_at).toLocaleString()} |
+ {untilText} |
+
+
+ |
+
+ );
+ })}
+
+
+
+
{/* Ban modal */}
diff --git a/frontend/src/pages/admin/ContactsAdminPage.tsx b/frontend/src/pages/admin/ContactsAdminPage.tsx
index 12f64de..ae9e6ab 100644
--- a/frontend/src/pages/admin/ContactsAdminPage.tsx
+++ b/frontend/src/pages/admin/ContactsAdminPage.tsx
@@ -101,12 +101,32 @@ const ContactsAdminPage: React.FC = () => {
const [savingSettings, setSavingSettings] = useState(false);
const [facrCompetitions, setFacrCompetitions] = useState([]);
const fileInputRef = React.useRef(null);
+ // Map of competition code -> alias (public aliases)
+ const [compAliasMap, setCompAliasMap] = useState>({});
useEffect(() => {
loadData();
loadSettings();
}, []);
+ // Load competition aliases map for filtering categories (so alias-named categories are visible)
+ useEffect(() => {
+ (async () => {
+ try {
+ const aliases = await getCompetitionAliasesPublic().catch(() => [] as Array<{ code?: string; alias?: string }>);
+ const map: Record = {};
+ (aliases || []).forEach((a: any) => {
+ const code = String(a?.code || '').trim();
+ const alias = String(a?.alias || '').trim();
+ if (code && alias) map[code] = alias;
+ });
+ setCompAliasMap(map);
+ } catch {
+ // ignore
+ }
+ })();
+ }, []);
+
const loadData = async () => {
setLoading(true);
try {
@@ -170,12 +190,15 @@ const ContactsAdminPage: React.FC = () => {
for (const comp of facrCompetitions || []) {
const n = String(comp?.name || '').trim();
if (n) names.add(n);
+ const code = String(comp?.code || '').trim();
+ const alias = code && compAliasMap[code] ? String(compAliasMap[code]).trim() : '';
+ if (alias) names.add(alias);
}
return Array.from(names);
} catch {
return [] as string[];
}
- }, [facrCompetitions]);
+ }, [facrCompetitions, compAliasMap]);
const filteredContactCategories = useMemo(() => {
try {
diff --git a/frontend/src/pages/admin/EngagementAdminPage.tsx b/frontend/src/pages/admin/EngagementAdminPage.tsx
index 2d0f581..28e0bd6 100644
--- a/frontend/src/pages/admin/EngagementAdminPage.tsx
+++ b/frontend/src/pages/admin/EngagementAdminPage.tsx
@@ -88,6 +88,7 @@ const EngagementAdminPage: React.FC = () => {
const editModal = useDisclosure();
const [editForm, setEditForm] = React.useState>({});
// Remove raw JSON editing, keep structured metadata only
+ const batchEnabled = false;
const [batch, setBatch] = React.useState({
base_url: '',
@@ -330,7 +331,9 @@ const EngagementAdminPage: React.FC = () => {
-
+ {batchEnabled && (
+
+ )}
@@ -361,22 +364,38 @@ const EngagementAdminPage: React.FC = () => {
~ {Math.round(Number(form.cost_points || 0) * 0.1)} Kč
+ {(form.type !== 'avatar_upload_unlock' && form.type !== 'avatar_animated_upload_unlock') && (
+
+ Sklad
+ setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
+
+
+
+ )}
+
+ {(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
+ <>
+
+ Obrázek URL
+ setForm({ ...form, image_url: e.target.value })} />
+ Pro avatar uveďte URL obrázku.
+
+
+ handleUpload(e.target.files?.[0])} />
+
+
+ >
+ )}
+
- Sklad
- setForm({ ...form, stock: Number.isFinite(n) ? n : -1 })}>
-
-
+ Platnost od
+ setMetaField('valid_from', e.target.value)} />
-
-
- Obrázek URL
- setForm({ ...form, image_url: e.target.value })} />
- Pro avatar uveďte URL obrázku. Pro odemknutí uploadu není třeba.
-
-
- handleUpload(e.target.files?.[0])} />
-
-
+
+ Platnost do
+ setMetaField('valid_to', e.target.value)} />
+
+
{/* Metadata helpers */}
{form.type === 'merch_coupon' && (
@@ -384,10 +403,6 @@ const EngagementAdminPage: React.FC = () => {
Kód kuponu
setMetaField('coupon_code', e.target.value)} />
-
- Platnost do (ISO nebo datum)
- setMetaField('expires_at', e.target.value)} placeholder="2025-12-31" />
-
Poznámka
setMetaField('note', e.target.value)} />
@@ -432,16 +447,18 @@ const EngagementAdminPage: React.FC = () => {
-
- Náhled
-
- {form.image_url ? (
-
- ) : (
- Bez obrázku
- )}
+ {(form.type === 'avatar_static' || form.type === 'avatar_animated') && (
+
+ Náhled
+
+ {form.image_url ? (
+
+ ) : (
+ Bez obrázku
+ )}
+
-
+ )}
@@ -468,6 +485,7 @@ const EngagementAdminPage: React.FC = () => {
Body |
Sklad |
Obrázek |
+ Platnost |
Aktivní |
Akce |
|
@@ -496,6 +514,20 @@ const EngagementAdminPage: React.FC = () => {
{r.image_url ? : '-'} |
+
+ {(() => {
+ const m = (r.metadata || {}) as any;
+ const vf = m.valid_from ? new Date(m.valid_from) : null;
+ const vt = m.valid_to ? new Date(m.valid_to) : null;
+ if (!vf && !vt) return -;
+ return (
+
+ {vf && od {vf.toLocaleString()}}
+ {vt && do {vt.toLocaleString()}}
+
+ );
+ })()}
+ |
{
{editForm.type === 'merch_coupon' && (
<>
Kód kuponusetEditMetaField('coupon_code', e.target.value)} />
- Platnost dosetEditMetaField('expires_at', e.target.value)} />
PoznámkasetEditMetaField('note', e.target.value)} />
>
)}
@@ -665,6 +696,16 @@ const EngagementAdminPage: React.FC = () => {
)}
)}
+
+
+ Platnost od
+ setEditMetaField('valid_from', e.target.value)} />
+
+
+ Platnost do
+ setEditMetaField('valid_to', e.target.value)} />
+
+
{/* Odstraněno: ruční JSON metadata v editoru. */}
Aktivní
@@ -699,76 +740,78 @@ const EngagementAdminPage: React.FC = () => {
- {/* Batch create modal */}
-
-
-
- Dávkové vytvoření odměn
-
-
-
-
- Základní URL (použijte {`{i}`} pro index)
- setBatch({ ...batch, base_url: e.target.value })} />
- Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…
-
-
+ {/* Batch create modal (hidden) */}
+ {batchEnabled && (
+
+
+
+ Dávkové vytvoření odměn
+
+
+
- Počet
- setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
-
-
-
-
- Počáteční index
- setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
-
-
-
-
-
- Předpona názvu
- setBatch({ ...batch, name_prefix: e.target.value })} />
-
-
-
- Typ
-
-
-
- Body
- setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
-
-
-
-
-
-
- Sklad
- setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
-
-
+ Základní URL (použijte {`{i}`} pro index)
+ setBatch({ ...batch, base_url: e.target.value })} />
+ Příklad: avatar-{`{i}`}.png → avatar-1.png, avatar-2.png…
- Aktivní
- setBatch({ ...batch, active: e.target.checked })} />
+
+ Počet
+ setBatch({ ...batch, count: Number.isFinite(n)? n : 1 })}>
+
+
+
+
+ Počáteční index
+ setBatch({ ...batch, start_index: Number.isFinite(n)? n : 1 })}>
+
+
+
+
+ Předpona názvu
+ setBatch({ ...batch, name_prefix: e.target.value })} />
+
+
+
+ Typ
+
+
+
+ Body
+ setBatch({ ...batch, cost_points: Number.isFinite(n)? n : 0 })}>
+
+
+
+
+
+
+ Sklad
+ setBatch({ ...batch, stock: Number.isFinite(n)? n : -1 })}>
+
+
+
+
+ Aktivní
+ setBatch({ ...batch, active: e.target.checked })} />
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+ )}
);
};
diff --git a/frontend/src/pages/admin/FilesAdminPage.tsx b/frontend/src/pages/admin/FilesAdminPage.tsx
index 22a4cf1..9b62aa7 100644
--- a/frontend/src/pages/admin/FilesAdminPage.tsx
+++ b/frontend/src/pages/admin/FilesAdminPage.tsx
@@ -78,6 +78,8 @@ const FilesAdminPage: React.FC = () => {
const [forceDelete, setForceDelete] = useState(false);
const [scanResult, setScanResult] = useState(null);
const [refreshResult, setRefreshResult] = useState(null);
+ const [isBulkDeletingUnused, setIsBulkDeletingUnused] = useState(false);
+ const [isBulkDeletingDuplicates, setIsBulkDeletingDuplicates] = useState(false);
const { isOpen: isUsagesOpen, onOpen: onUsagesOpen, onClose: onUsagesClose } = useDisclosure();
const { isOpen: isDeleteOpen, onOpen: onDeleteOpen, onClose: onDeleteClose } = useDisclosure();
@@ -202,6 +204,71 @@ const FilesAdminPage: React.FC = () => {
return full || url;
};
+ const handleDeleteAllUnused = async () => {
+ if (unusedFiles.length === 0) return;
+ const confirmed = window.confirm(`Opravdu chcete smazat ${unusedFiles.length} nepoužívaných souborů? Tuto akci nelze vrátit.`);
+ if (!confirmed) return;
+ setIsBulkDeletingUnused(true);
+ let deleted = 0;
+ let failed = 0;
+ for (const f of unusedFiles) {
+ try {
+ await deleteFile(f.id, false);
+ deleted++;
+ } catch (e) {
+ failed++;
+ }
+ }
+ setIsBulkDeletingUnused(false);
+ qc.invalidateQueries({ queryKey: ['admin-files'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
+ toast({ title: 'Hromadné mazání dokončeno', description: `Smazáno ${deleted} / ${unusedFiles.length}. Chyby: ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
+ };
+
+ const handleDeleteAllDuplicates = async () => {
+ if (duplicateGroups.length === 0) return;
+ const confirmed = window.confirm('Smazat všechny duplicitní soubory bez použití? V každé skupině bude ponechán 1 soubor. Používané soubory budou přeskočeny.');
+ if (!confirmed) return;
+ setIsBulkDeletingDuplicates(true);
+ // Build list of files to delete: in each group keep one (oldest by created_at), delete the rest only if usage_count === 0
+ type FI = typeof duplicateFiles extends Record ? A extends Array ? B : never : never;
+ const toDelete: FI[] = [] as any;
+ duplicateGroups.forEach(([, files]) => {
+ if (files.length <= 1) return;
+ const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ const [, ...rest] = sorted;
+ rest.forEach(f => {
+ if ((f.usage_count ?? 0) === 0) toDelete.push(f as any);
+ });
+ });
+ let deleted = 0;
+ let skipped = 0;
+ let failed = 0;
+ for (const f of toDelete) {
+ try {
+ await deleteFile((f as any).id, false);
+ deleted++;
+ } catch (e) {
+ failed++;
+ }
+ }
+ // Count duplicates with usage to report as skipped
+ duplicateGroups.forEach(([, files]) => {
+ if (files.length <= 1) return;
+ const sorted = [...files].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ const [, ...rest] = sorted;
+ rest.forEach(f => { if ((f.usage_count ?? 0) > 0) skipped++; });
+ });
+ setIsBulkDeletingDuplicates(false);
+ qc.invalidateQueries({ queryKey: ['admin-files'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-unused'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-duplicates'] });
+ qc.invalidateQueries({ queryKey: ['admin-files-usage'] });
+ toast({ title: 'Mazání duplicit dokončeno', description: `Smazáno ${deleted}, přeskočeno (použité) ${skipped}, chyby ${failed}.`, status: failed > 0 ? 'warning' : 'success' });
+ };
+
// Mime type options
const mimeTypes = useMemo(() => {
const types = new Set();
@@ -443,7 +510,19 @@ const FilesAdminPage: React.FC = () => {
-
+
+
+ }
+ colorScheme="red"
+ size="sm"
+ onClick={handleDeleteAllUnused}
+ isLoading={isBulkDeletingUnused}
+ isDisabled={unusedFiles.length === 0}
+ >
+ Vymazat vše
+
+
@@ -483,7 +562,19 @@ const FilesAdminPage: React.FC = () => {
-
+
+
+ }
+ colorScheme="red"
+ size="sm"
+ onClick={handleDeleteAllDuplicates}
+ isLoading={isBulkDeletingDuplicates}
+ isDisabled={duplicateGroups.length === 0}
+ >
+ Vymazat vše
+
+
{duplicateGroups.length === 0 ? (
Žádné duplicity nenalezeny
diff --git a/frontend/src/pages/admin/GalleryAdminPage.tsx b/frontend/src/pages/admin/GalleryAdminPage.tsx
index 8335194..cb33308 100644
--- a/frontend/src/pages/admin/GalleryAdminPage.tsx
+++ b/frontend/src/pages/admin/GalleryAdminPage.tsx
@@ -131,7 +131,7 @@ const GalleryAdminPage: React.FC = () => {
try {
// Use the api service which automatically includes authentication
- await api.post('/admin/gallery/refresh');
+ await api.post('/admin/gallery/refresh', {});
toast({
title: 'Galerie obnovena',
diff --git a/frontend/src/pages/admin/MatchesAdminPage.tsx b/frontend/src/pages/admin/MatchesAdminPage.tsx
index 041c755..6d6d455 100644
--- a/frontend/src/pages/admin/MatchesAdminPage.tsx
+++ b/frontend/src/pages/admin/MatchesAdminPage.tsx
@@ -34,7 +34,7 @@ import {
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import AdminLayout from '../../layouts/AdminLayout';
-import { putMatchOverride } from '../../services/adminMatches';
+import { putMatchOverride, fetchAdminMatches } from '../../services/adminMatches';
import { getPublicSettings } from '../../services/settings';
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -85,51 +85,21 @@ const MatchesAdminPage = () => {
const { data: matches = [], isLoading, error } = useQuery({
queryKey: ['admin-matches-list-cache'],
queryFn: async () => {
- // Read cached FACR club info
- const origin = new URL(API_URL, typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000').origin;
- const url = `${origin}/cache/prefetch/facr_club_info.json`;
- const res = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
- if (!res.ok) throw new Error(`Failed to load cache: ${res.status}`);
- const json = await res.json();
-
- const comps = Array.isArray(json?.competitions) ? json.competitions : [];
- const items: any[] = comps.flatMap((c: any) =>
- (Array.isArray(c.matches) ? c.matches : []).map((m: any) => ({ ...m, competitionName: c.name, competition_id: c.id }))
- );
-
- // Optional: stable sort by date ascending
+ const items = await fetchAdminMatches();
const FACR_DATE_FMT = 'dd.MM.yyyy HH:mm';
- const formatDisplayDate = (s: string): string => {
- const str = String(s || '').trim();
- if (!str) return '';
- try {
- const dt = parse(str, FACR_DATE_FMT, new Date());
- if (!isNaN(dt.getTime())) return format(dt, FACR_DATE_FMT);
- } catch {}
- const d2 = new Date(str);
- if (!isNaN(d2.getTime())) return format(d2, FACR_DATE_FMT);
- return str;
- };
- items.sort((a, b) => {
- const da = parse(String(a.date_time || a.date), FACR_DATE_FMT, new Date()).getTime();
- const db = parse(String(b.date_time || b.date), FACR_DATE_FMT, new Date()).getTime();
- return da - db;
- });
-
- return items.map((m: any) => ({
- id: m.match_id,
- date_time: m.date_time || m.date,
- competitionName: m.competitionName,
- competition_id: m.competition_id,
- home: m.home || m.home_team,
- home_id: m.home_id || m.home_team_id || m.home_team_facr_id,
- away: m.away || m.away_team,
- away_id: m.away_id || m.away_team_id || m.away_team_facr_id,
- score: m.score,
- venue: m.venue,
- home_logo_url: m.home_logo_url,
- away_logo_url: m.away_logo_url,
- }));
+ const parseTs = (obj: any): number => {
+ const s = String(obj?.date_time || obj?.date || '').trim();
+ if (!s) return Number.MAX_SAFE_INTEGER;
+ try {
+ const dt = parse(s, FACR_DATE_FMT, new Date());
+ if (!isNaN(dt.getTime())) return dt.getTime();
+ } catch {}
+ const d2 = new Date(s);
+ if (!isNaN(d2.getTime())) return d2.getTime();
+ return Number.MAX_SAFE_INTEGER;
+ };
+ items.sort((a: any, b: any) => parseTs(a) - parseTs(b));
+ return items;
},
});
@@ -374,7 +344,7 @@ const MatchesAdminPage = () => {
const saveMutation = useMutation({
mutationFn: async () => {
- const externalMatchId: string = selected?.match_id || selected?.id;
+ const externalMatchId: string = String((selected?.match_id ?? selected?.id ?? '')).trim();
if (!externalMatchId) throw new Error('Chybí match_id');
const payload: any = {
venue_override: form.venue_override,
diff --git a/frontend/src/pages/admin/NavigationAdminPage.tsx b/frontend/src/pages/admin/NavigationAdminPage.tsx
index 20d1e66..860c52a 100644
--- a/frontend/src/pages/admin/NavigationAdminPage.tsx
+++ b/frontend/src/pages/admin/NavigationAdminPage.tsx
@@ -132,7 +132,6 @@ const ADMIN_PAGE_PRESETS = [
{ value: 'activities', label: 'Aktivity', url: '/admin/aktivity' },
{ value: 'players', label: 'Hráči', url: '/admin/hraci' },
{ value: 'articles', label: 'Články', url: '/admin/clanky' },
- { value: 'categories', label: 'Kategorie', url: '/admin/kategorie' },
{ value: 'comments', label: 'Komentáře', url: '/admin/komentare' },
{ value: 'about', label: 'O klubu', url: '/admin/o-klubu' },
{ value: 'videos', label: 'Videa', url: '/admin/videa' },
@@ -1149,6 +1148,8 @@ const NavigationAdminPage = () => {
onChildMoveUp={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'up')}
onChildMoveDown={(parentId, childIdx) => moveChildNavItem(parentId, childIdx, 'down')}
onToggleVisible={toggleVisible}
+ childrenDroppableId={`admin-children-${item.id}`}
+ draggableChildPrefix={'admin-child'}
onEditTarget={(it) => openNavModal(it, undefined, true)}
onDeleteTarget={(it) => deleteNav(it.id!)}
/>
diff --git a/frontend/src/pages/admin/NewsletterAdminPage.tsx b/frontend/src/pages/admin/NewsletterAdminPage.tsx
index 6da73ed..8a427e8 100644
--- a/frontend/src/pages/admin/NewsletterAdminPage.tsx
+++ b/frontend/src/pages/admin/NewsletterAdminPage.tsx
@@ -602,6 +602,25 @@ export default function NewsletterAdminPage() {
Automatické rozesílky
+ {/* Weekly schedule detail */}
+
+ {statusData?.weekly_day ? (
+ <>
+
+ Týdenní přehled: {statusData?.weekly_enabled ? 'Zapnuto' : 'Vypnuto'}
+ {statusData?.weekly_enabled ? (
+ <> — {({sun:'Neděle', mon:'Pondělí', tue:'Úterý', wed:'Středa', thu:'Čtvrtek', fri:'Pátek', sat:'Sobota'} as any)[statusData.weekly_day as any]}
+ {' '}{String(statusData?.weekly_hour ?? 9).padStart(2,'0')}:00>
+ ) : null}
+
+ {statusData?.weekly_next_scheduled ? (
+
+ Příští týdenní odeslání: {format(new Date(statusData.weekly_next_scheduled), 'd. M. yyyy HH:mm', { locale: cs })}
+
+ ) : null}
+ >
+ ) : null}
+
{statusData?.next_approximate ? (
Další automatický newsletter za {(() => {
diff --git a/frontend/src/pages/admin/PlayersAdminPage.tsx b/frontend/src/pages/admin/PlayersAdminPage.tsx
index f86cb4d..49d7e2b 100644
--- a/frontend/src/pages/admin/PlayersAdminPage.tsx
+++ b/frontend/src/pages/admin/PlayersAdminPage.tsx
@@ -639,11 +639,8 @@ const PlayersAdminPage: React.FC = () => {
// Czech pluralization for years: 1 rok, 2–4 roky, 5+ let (11–14 let)
function czYears(n: number): string {
- const mod100 = n % 100;
- if (mod100 >= 11 && mod100 <= 14) return 'let';
- const mod10 = n % 10;
- if (mod10 === 1) return 'rok';
- if (mod10 >= 2 && mod10 <= 4) return 'roky';
+ if (n === 1) return 'rok';
+ if (n >= 2 && n <= 4) return 'roky';
return 'let';
}
diff --git a/frontend/src/pages/admin/ShortlinksAdminPage.tsx b/frontend/src/pages/admin/ShortlinksAdminPage.tsx
index f630fa0..cfdc711 100644
--- a/frontend/src/pages/admin/ShortlinksAdminPage.tsx
+++ b/frontend/src/pages/admin/ShortlinksAdminPage.tsx
@@ -27,11 +27,14 @@ import {
Badge,
} from '@chakra-ui/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useAuth } from '../../contexts/AuthContext';
import { createShortLink, listShortLinks, getShortLinkStats } from '../../services/shortlinks';
import { FiClipboard, FiExternalLink, FiRefreshCcw, FiBarChart2 } from 'react-icons/fi';
const ShortlinksAdminPage: React.FC = () => {
const toast = useToast();
+ const { user } = useAuth();
+ const isAdmin = (user as any)?.role === 'admin';
const qc = useQueryClient();
const [targetUrl, setTargetUrl] = React.useState('');
const [title, setTitle] = React.useState('');
@@ -77,7 +80,7 @@ const ShortlinksAdminPage: React.FC = () => {
};
return (
-
+
Zkrácené odkazy
@@ -125,7 +128,9 @@ const ShortlinksAdminPage: React.FC = () => {
} as={ChakraLink as any} href={shortUrl} isExternal />
} onClick={async ()=>{ await navigator.clipboard.writeText(shortUrl); toast({ title: 'Zkopírováno', description: shortUrl, status: 'success', duration: 2000 }); }} />
- } onClick={()=> openStats(it)} />
+ {isAdmin && (
+ } onClick={()=> openStats(it)} />
+ )}
@@ -138,7 +143,8 @@ const ShortlinksAdminPage: React.FC = () => {
- {/* Stats modal */}
+ {/* Stats modal (admins only) */}
+ {isAdmin && (
@@ -190,6 +196,7 @@ const ShortlinksAdminPage: React.FC = () => {
+ )}
);
diff --git a/frontend/src/pages/admin/SweepstakesAdminPage.tsx b/frontend/src/pages/admin/SweepstakesAdminPage.tsx
index 70604a8..99a1a31 100644
--- a/frontend/src/pages/admin/SweepstakesAdminPage.tsx
+++ b/frontend/src/pages/admin/SweepstakesAdminPage.tsx
@@ -35,6 +35,11 @@ import {
Divider,
Image,
FormHelperText,
+ Tabs,
+ TabList,
+ Tab,
+ TabPanels,
+ TabPanel,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import AdminLayout from '../../layouts/AdminLayout';
@@ -88,15 +93,15 @@ const SweepstakesAdminPage: React.FC = () => {
const [form, setForm] = useState(defaultForm);
const [editing, setEditing] = useState(null);
- // Prizes modal state
- const prizesDisc = useDisclosure();
- const [prizeSweep, setPrizeSweep] = useState(null);
+ // Prizes state (integrated tab)
const [prizes, setPrizes] = useState([]);
const [prizeForm, setPrizeForm] = useState<{ name: string; quantity: number; value?: string; image_url?: string; kind?: 'physical'|'points'|'xp'|'points_xp'; points?: number; xp?: number }>(() => ({ name: '', quantity: 1, value: '', image_url: '', kind: 'physical', points: 0, xp: 0 }));
const [savingPrize, setSavingPrize] = useState(false);
const imageInputRef = useRef(null);
const rulesInputRef = useRef(null);
+ const [activeTab, setActiveTab] = useState(0);
+ const [coverPreview, setCoverPreview] = useState('');
const onUploadImage = async (file?: File | null) => {
if (!file) return;
@@ -143,24 +148,19 @@ const SweepstakesAdminPage: React.FC = () => {
};
const openPrizes = async (it: Sweepstake) => {
- try {
- setPrizeSweep(it);
- prizesDisc.onOpen();
- const list = await adminListPrizes(it.id);
- setPrizes(list);
- } catch {
- setPrizes([]);
- }
+ openEdit(it);
+ setActiveTab(2);
+ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); }
};
const addPrize = async () => {
- if (!prizeSweep) return;
+ if (!editing) { toast({ status: 'info', title: 'Uložte soutěž a poté přidejte výhry' }); return; }
if (!prizeForm.name.trim()) { toast({ status: 'error', title: 'Název výhry je povinný' }); return; }
try {
setSavingPrize(true);
- await adminCreatePrize(prizeSweep.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
+ await adminCreatePrize(editing.id, { name: prizeForm.name, quantity: prizeForm.quantity, value: prizeForm.value, image_url: prizeForm.image_url, display_order: prizes.length, kind: prizeForm.kind, points: prizeForm.points, xp: prizeForm.xp });
setPrizeForm({ name: '', quantity: 1, value: '', image_url: '' });
- setPrizes(await adminListPrizes(prizeSweep.id));
+ setPrizes(await adminListPrizes(editing.id));
} catch (e:any) {
toast({ status: 'error', title: 'Nelze uložit výhru' });
} finally {
@@ -169,14 +169,14 @@ const SweepstakesAdminPage: React.FC = () => {
};
const delPrize = async (p: SweepstakePrize) => {
- if (!prizeSweep) return;
+ if (!editing) return;
if (!window.confirm('Smazat výhru?')) return;
- await adminDeletePrize(prizeSweep.id, p.id as any);
- setPrizes(await adminListPrizes(prizeSweep.id));
+ await adminDeletePrize(editing.id, p.id as any);
+ setPrizes(await adminListPrizes(editing.id));
};
const movePrize = async (idx: number, dir: -1 | 1) => {
- if (!prizeSweep) return;
+ if (!editing) return;
const arr = [...prizes];
const ni = idx + dir;
if (ni < 0 || ni >= arr.length) return;
@@ -184,12 +184,12 @@ const SweepstakesAdminPage: React.FC = () => {
arr[idx] = arr[ni];
arr[ni] = tmp;
setPrizes(arr);
- await adminReorderPrizes(prizeSweep.id, arr.map(p => p.id as any));
+ await adminReorderPrizes(editing.id, arr.map(p => p.id as any));
};
useEffect(() => { load(); }, [status]);
- const openCreate = () => { setEditing(null); setForm(defaultForm); onOpen(); };
+ const openCreate = () => { setEditing(null); setForm(defaultForm); setPrizes([]); setActiveTab(0); onOpen(); };
const openEdit = (it: Sweepstake) => {
setEditing(it);
setForm({
@@ -205,7 +205,9 @@ const SweepstakesAdminPage: React.FC = () => {
entry_cost_points: (it as any).entry_cost_points ?? 0,
max_entries_per_user: (it as any).max_entries_per_user ?? 1,
});
+ setActiveTab(0);
onOpen();
+ (async ()=>{ try { setPrizes(await adminListPrizes(it.id)); } catch { setPrizes([]); } })();
};
const save = async () => {
@@ -229,12 +231,16 @@ const SweepstakesAdminPage: React.FC = () => {
if (editing) {
await adminUpdateSweepstake(editing.id, payload);
toast({ status: 'success', title: 'Uloženo' });
+ onClose();
+ await load();
} else {
- await adminCreateSweepstake(payload);
- toast({ status: 'success', title: 'Vytvořeno' });
+ const created = await adminCreateSweepstake(payload);
+ toast({ status: 'success', title: 'Vytvořeno', description: 'Nyní můžete přidat výhry' });
+ setEditing(created);
+ setActiveTab(2);
+ try { setPrizes(await adminListPrizes(created.id)); } catch { setPrizes([]); }
+ await load();
}
- onClose();
- await load();
} catch (e: any) {
toast({ status: 'error', title: 'Chyba', description: e?.response?.data?.error || 'Operace selhala' });
}
@@ -325,106 +331,206 @@ const SweepstakesAdminPage: React.FC = () => {
)}
- {/* Create/Edit Modal */}
-
+ {/* Create/Edit Modal with tabs */}
+
{editing ? 'Upravit soutěž' : 'Nová soutěž'}
-
-
- Název
- setForm({ ...form, title: e.target.value })} />
-
-
- Popis
-
-
-
- Začátek
- setForm({ ...form, start_at: e.target.value })} />
-
-
- Konec
- setForm({ ...form, end_at: e.target.value })} />
-
-
-
-
- Styl vizualizace
-
-
- 100}>
- Počet výherců
- setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
-
-
- Max. 100 výherců
-
-
-
-
-
-
-
-
-
-
-
- Vstupné (body)
- setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
-
-
-
-
- Max. účastí / uživatel
- setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
-
-
-
-
-
-
- Titulní obrázek
-
-
- } variant="outline">
- Nahrát
- onUploadImage(e.target.files?.[0])} />
-
-
- setForm({ ...form, image_url: e.target.value })} />
-
-
- Pravidla
-
- } variant="outline">
- Nahrát PDF/obrázek
- onUploadRules(e.target.files?.[0])} />
-
-
-
- setForm({ ...form, rules_url: e.target.value })} />
-
-
-
+
+
+ Základní
+ Termíny a limity
+ Výhry
+
+
+
+
+
+ Název
+ setForm({ ...form, title: e.target.value })} />
+
+
+ Popis
+
+
+
+ Titulní obrázek
+
+
+
+ } variant="outline">
+ Nahrát
+ { const f=e.target.files?.[0]; if(!f) return; try { setCoverPreview(URL.createObjectURL(f)); const r=await uploadFile(f); setForm((prev:any)=>({ ...prev, image_url: r.url })); setCoverPreview(''); toast({ status:'success', title:'Obrázek nahrán' }); } catch { toast({ status:'error', title:'Nahrávání selhalo' }); } }} />
+
+ {form.image_url && ()}
+
+ setForm({ ...form, image_url: e.target.value })} />
+
+
+
+ Pravidla
+
+
+ } variant="outline">
+ Nahrát PDF/obrázek
+ onUploadRules(e.target.files?.[0])} />
+
+
+ {form.rules_url && ()}
+
+ setForm({ ...form, rules_url: e.target.value })} />
+
+
+
+
+
+
+
+
+
+ Začátek
+ setForm({ ...form, start_at: e.target.value })} />
+
+
+ Konec
+ setForm({ ...form, end_at: e.target.value })} />
+
+
+
+
+ Styl vizualizace
+
+
+ 100}>
+ Počet výherců
+ setForm({ ...form, total_prizes: Number.isFinite(n) ? Math.floor(n) : 1 })}>
+
+
+ Max. 100 výherců
+
+
+
+
+ Vstupné (body)
+ setForm({ ...form, entry_cost_points: Number(v) || 0 })}>
+
+
+
+
+ Max. účastí / uživatel
+ setForm({ ...form, max_entries_per_user: Number(v) || 1 })}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {prizes.length === 0 && Zatím žádné výhry}
+ {prizes.map((p, i) => (
+
+ } onClick={()=>movePrize(i,-1)} />
+ } onClick={()=>movePrize(i,1)} />
+ {p.name}
+ ×{p.quantity}
+ {p.kind && (
+
+ {p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
+
+ )}
+ {p.value}
+ } onClick={()=>delPrize(p)} />
+
+ ))}
+
+ Přidat výhru
+
+
+ Název
+ setPrizeForm({ ...prizeForm, name: e.target.value })} />
+
+
+ Počet
+ setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
+
+
+
+
+ Hodnota
+ setPrizeForm({ ...prizeForm, value: e.target.value })} />
+
+
+ Obrázek URL
+
+ setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
+ } size="sm" variant="outline">
+ Upload
+ { const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
+
+
+
+
+
+
+ Typ výhry
+
+
+ {(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
+
+ Body
+ setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
+
+
+
+ )}
+ {(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
+
+ XP
+ setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
+
+
+
+ )}
+
+
+ } colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat
+
+
+
+
+
@@ -434,96 +540,6 @@ const SweepstakesAdminPage: React.FC = () => {
-
- {/* Prizes Modal */}
-
-
-
- Výhry – {prizeSweep?.title}
-
-
-
- {prizes.length === 0 && Zatím žádné výhry}
- {prizes.map((p, i) => (
-
- } onClick={()=>movePrize(i,-1)} />
- } onClick={()=>movePrize(i,1)} />
- {p.name}
- ×{p.quantity}
- {p.kind && (
-
- {p.kind === 'physical' ? 'fyzická' : p.kind === 'points' ? `body ${p.points||0}` : p.kind === 'xp' ? `XP ${p.xp||0}` : `body ${p.points||0} + XP ${p.xp||0}`}
-
- )}
- {p.value}
- } onClick={()=>delPrize(p)} />
-
- ))}
-
- Přidat výhru
-
-
- Název
- setPrizeForm({ ...prizeForm, name: e.target.value })} />
-
-
- Počet
- setPrizeForm({ ...prizeForm, quantity: Number(v) || 1 })}>
-
-
-
-
- Hodnota
- setPrizeForm({ ...prizeForm, value: e.target.value })} />
-
-
- Obrázek URL
-
- setPrizeForm({ ...prizeForm, image_url: e.target.value })} />
- } size="sm" variant="outline">
- Upload
- { const f=e.target.files?.[0]; if(f){ const r=await uploadFile(f); setPrizeForm(prev=>({...prev, image_url: r.url })); } }} />
-
-
-
-
-
-
- Typ výhry
-
-
- {(prizeForm.kind === 'points' || prizeForm.kind === 'points_xp') && (
-
- Body
- setPrizeForm({ ...prizeForm, points: Number(v)||0 })}>
-
-
-
- )}
- {(prizeForm.kind === 'xp' || prizeForm.kind === 'points_xp') && (
-
- XP
- setPrizeForm({ ...prizeForm, xp: Number(v)||0 })}>
-
-
-
- )}
-
-
- } colorScheme="blue" size="sm" onClick={addPrize} isLoading={savingPrize}>Přidat
-
-
-
-
-
-
-
-
);
diff --git a/frontend/src/pages/admin/TeamsAdminPage.tsx b/frontend/src/pages/admin/TeamsAdminPage.tsx
index 464c1d2..e5ec8e6 100644
--- a/frontend/src/pages/admin/TeamsAdminPage.tsx
+++ b/frontend/src/pages/admin/TeamsAdminPage.tsx
@@ -63,6 +63,8 @@ function normalize(s: string): string {
.toLowerCase();
// Unify various dash characters to a simple hyphen
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
+ out = out.replace(/\bn\.?\b/g, ' nad ');
+ out = out.replace(/\bp\.?\b/g, ' pod ');
// Remove legal suffixes like ", z.s." / ", z. s." / " z.s." / "o.s." at end
out = out.replace(/[,,\s]*(z\.?\s*s\.?|o\.?\s*s\.?)\s*$/g, '');
// Remove organization phrases/prefixes anywhere (keep core locality/name)
@@ -140,6 +142,16 @@ const TeamsAdminPage = () => {
staleTime: 5 * 60 * 1000,
});
const overridesById: Record = (overrides as any)?.by_id || {};
+ // Lowercase-key index for robust UUID lookups irrespective of source casing
+ const overridesByIdLC = useMemo(() => {
+ const m: Record = {};
+ try {
+ for (const [k, v] of Object.entries(overridesById)) {
+ m[String(k).toLowerCase()] = v as any;
+ }
+ } catch {}
+ return m;
+ }, [overridesById]);
// Build an index by normalized team name for overrides that carry an ID
const overridesNameIndex = useMemo(() => {
const idx: Record = {};
@@ -168,7 +180,7 @@ const TeamsAdminPage = () => {
for (const comp of competitions) {
const rows: TableRow[] = comp?.table?.overall || [];
for (const r of rows) {
- if (r.team_id) teamIds.add(r.team_id);
+ if (r.team_id) teamIds.add(String(r.team_id).toLowerCase());
else {
const derived = deriveTeamIdFromLogoUrl(r.team_logo_url);
if (derived) teamIds.add(derived);
@@ -200,8 +212,9 @@ const TeamsAdminPage = () => {
const getLogo = (teamName?: string, teamId?: string, original?: string) => {
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
// Priority 0: Admin override by team ID
- if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
- const u = String(overridesById[teamId].logo_url);
+ const tid = teamId ? String(teamId).toLowerCase() : '';
+ if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.logo_url) {
+ const u = String(overridesByIdLC[tid].logo_url);
if (u.startsWith('/')) return assetUrl(u) as string;
return u;
}
@@ -254,8 +267,8 @@ const TeamsAdminPage = () => {
}
// Priority 2: logoapi.sportcreative.eu if we have a team ID
- if (teamId && sportLogosMap[teamId]) {
- return sportLogosMap[teamId];
+ if (tid && sportLogosMap[tid]) {
+ return sportLogosMap[tid];
}
// Priority 3: FACR original
@@ -268,8 +281,9 @@ const TeamsAdminPage = () => {
};
const getName = (teamName?: string, teamId?: string) => {
- if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
- return String(overridesById[teamId].name || '').trim() || String(teamName || '');
+ const tid = teamId ? String(teamId).toLowerCase() : '';
+ if (tid && overridesByIdLC[tid] && overridesByIdLC[tid]?.name) {
+ return String(overridesByIdLC[tid].name || '').trim() || String(teamName || '');
}
// If no ID, but override exists for the normalized name, use canonical override name
try {
@@ -326,6 +340,7 @@ const TeamsAdminPage = () => {
for (const r of rows) {
const rawName = (r.team || '').trim();
let teamId = ((r as any).team_id as string | undefined) || deriveTeamIdFromLogoUrl(r.team_logo_url);
+ if (teamId) teamId = String(teamId).toLowerCase();
if (!teamId && mainClubId) {
const rn = normalize(rawName);
if (
@@ -431,7 +446,30 @@ const TeamsAdminPage = () => {
const onSave = useMutation({
mutationFn: async () => {
- if (!form.external_team_id) {
+ let extTeamId = (form.external_team_id || '').trim();
+ if (!extTeamId) {
+ let derived: string | undefined = undefined;
+ try { derived = deriveTeamIdFromLogoUrl(form.logo_url); } catch {}
+ if (!derived && selected?.teamLogoUrl) {
+ try { derived = deriveTeamIdFromLogoUrl(selected.teamLogoUrl); } catch {}
+ }
+ if (!derived) {
+ const primaryNameTry = (form.team_name || selected?.teamName || '').trim();
+ if (primaryNameTry) {
+ try {
+ const results = await searchClubs(primaryNameTry);
+ const norm = (s: string) => normalize(s);
+ const exact = results.find(r => norm(r.name) === norm(primaryNameTry));
+ const pick = exact || results[0];
+ if (pick?.id) derived = String(pick.id);
+ } catch {}
+ }
+ }
+ if (derived) {
+ extTeamId = derived;
+ }
+ }
+ if (!extTeamId) {
throw new Error('Vyberte tým ze seznamu vyhledávání (chybí ID).');
}
let logoUrl = (form.logo_url || '').trim();
@@ -443,8 +481,8 @@ const TeamsAdminPage = () => {
.filter(Boolean);
// Prefer highest-quality logo from logoapi if available (unless uploading a new file)
try {
- if (!uploadedFile && form.external_team_id) {
- const apiLogo = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
+ if (!uploadedFile && extTeamId) {
+ const apiLogo = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (apiLogo) {
logoUrl = apiLogo;
}
@@ -482,10 +520,10 @@ const TeamsAdminPage = () => {
}
if (logoFileToUpload) {
const logaResult = await uploadToLogaSportcreative(
- form.external_team_id,
+ extTeamId,
logoFileToUpload,
{
- filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
+ filename: `${extTeamId}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
@@ -497,7 +535,7 @@ const TeamsAdminPage = () => {
try {
let confirmedUrl: string | null = null;
for (let i = 0; i < 10; i++) {
- confirmedUrl = await fetchLogoFromLogoAPI(form.external_team_id, primaryName);
+ confirmedUrl = await fetchLogoFromLogoAPI(extTeamId, primaryName);
if (confirmedUrl) break;
await new Promise((r) => setTimeout(r, 700));
}
@@ -532,7 +570,7 @@ const TeamsAdminPage = () => {
}
}
- await putTeamLogoOverride(form.external_team_id, primaryName, logoUrl);
+ await putTeamLogoOverride(extTeamId, primaryName, logoUrl);
return true;
},
@@ -706,7 +744,8 @@ const TeamsAdminPage = () => {
{r.points} |
| |