mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #77
This commit is contained in:
@@ -85,7 +85,7 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('informative');
|
||||
const [aiOverwrite, setAiOverwrite] = useState<boolean>(true);
|
||||
// Location coordinates for map preview
|
||||
const [locationLat, setLocationLat] = useState<number | undefined>(undefined);
|
||||
@@ -93,6 +93,8 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
// YouTube videos from club channel
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
const [savedLocations, setSavedLocations] = useState<Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>>([]);
|
||||
const [selectedSavedId, setSelectedSavedId] = useState<string>('');
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
@@ -153,6 +155,66 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('admin_saved_locations');
|
||||
const base: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }> = raw ? JSON.parse(raw) : [];
|
||||
const s: any = settingsQ.data || {};
|
||||
const clubAddrParts = [s.contact_address, s.contact_city, s.contact_zip].filter((x: any) => String(x || '').trim());
|
||||
const clubAddr = clubAddrParts.join(', ');
|
||||
const hasCoords = typeof s.location_latitude === 'number' && typeof s.location_longitude === 'number' && !isNaN(s.location_latitude) && !isNaN(s.location_longitude);
|
||||
const label = s.club_name ? `Klub – ${s.club_name}` : 'Klub – Hlavní místo';
|
||||
if (clubAddr || hasCoords) {
|
||||
const exists = base.some((it) => (clubAddr && it.address === clubAddr) || (hasCoords && it.lat === s.location_latitude && it.lng === s.location_longitude));
|
||||
if (!exists) {
|
||||
base.unshift({ id: 'club-main', label, address: clubAddr || (s.contact_city || 'Klub'), lat: hasCoords ? s.location_latitude : undefined, lng: hasCoords ? s.location_longitude : undefined });
|
||||
}
|
||||
}
|
||||
setSavedLocations(base);
|
||||
} catch {
|
||||
setSavedLocations([]);
|
||||
}
|
||||
}, [settingsQ.data]);
|
||||
|
||||
const persistSavedLocations = (list: Array<{ id: string; label: string; address: string; lat?: number; lng?: number }>) => {
|
||||
try { localStorage.setItem('admin_saved_locations', JSON.stringify(list)); } catch {}
|
||||
setSavedLocations(list);
|
||||
};
|
||||
|
||||
const addCurrentLocationToSaved = () => {
|
||||
const address = String(editing?.location || '').trim();
|
||||
const hasCoords = typeof locationLat === 'number' || typeof locationLng === 'number';
|
||||
if (!address && !hasCoords) {
|
||||
toast({ title: 'Nelze uložit místo', description: 'Zadejte název/adresu nebo vyberte souřadnice.', status: 'warning' });
|
||||
return;
|
||||
}
|
||||
const id = String(Date.now());
|
||||
const label = address || 'Uložené místo';
|
||||
const next = [...savedLocations, { id, label, address, lat: locationLat, lng: locationLng }];
|
||||
persistSavedLocations(next);
|
||||
setSelectedSavedId(id);
|
||||
toast({ title: 'Místo uloženo', status: 'success', duration: 2000 });
|
||||
};
|
||||
|
||||
const applySavedLocation = (id: string) => {
|
||||
setSelectedSavedId(id);
|
||||
const item = savedLocations.find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
setLocationLat(item.lat);
|
||||
setLocationLng(item.lng);
|
||||
setEditing(prev => ({ ...(prev || {}), location: item.address, latitude: item.lat as any, longitude: item.lng as any } as any));
|
||||
toast({ title: 'Místo vybráno', description: item.label, status: 'success', duration: 1500 });
|
||||
};
|
||||
|
||||
const deleteSelectedSaved = () => {
|
||||
if (!selectedSavedId) return;
|
||||
if (selectedSavedId === 'club-main') { toast({ title: 'Nelze smazat klubové místo', status: 'info' }); return; }
|
||||
const next = savedLocations.filter((x) => x.id !== selectedSavedId);
|
||||
persistSavedLocations(next);
|
||||
setSelectedSavedId('');
|
||||
toast({ title: 'Uložené místo smazáno', status: 'success', duration: 1500 });
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-activity-new';
|
||||
@@ -279,14 +341,18 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
if (e.type) lines.push(`Typ: ${e.type}`);
|
||||
if (e.description) lines.push(`Poznámky: ${e.description}`);
|
||||
const base = lines.join('\n');
|
||||
const toneText = aiTone === 'informative' ? 'informativním a věcným stylem' : aiTone === 'formal' ? 'formálním a profesionálním stylem' : 'přátelským, pozitivním a lákavým stylem';
|
||||
const safeUserPrompt = (aiPrompt || 'Vytvoř krátké oznámení pro fanoušky o klubové aktivitě.').trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu.';
|
||||
const toneText = aiTone === 'informative'
|
||||
? 'neutrálním, věcným a stručným stylem (bez nadsázky)'
|
||||
: aiTone === 'formal'
|
||||
? 'formálním a profesionálním stylem (bez příkras)'
|
||||
: 'přátelským, ale věcným a stručným stylem (bez nadsázky)';
|
||||
const safeUserPrompt = (aiPrompt || 'Napiš krátkou neutrální pozvánku na klubovou aktivitu.').trim();
|
||||
const constraints = 'Nevkládej datum ani místo (lokalitu) do textu. Neuváděj konkrétní čas nebo adresu. Vyhýbej se superlativům, hyperbolám a marketingovým frázím. Nepoužívej slova jako „neopakovatelný“, „epický“, „úchvatný“ apod. Preferuj 1–2 krátké odstavce nebo stručné odrážky. Dbej na věcný a střízlivý tón.';
|
||||
const prompt = `${safeUserPrompt}\n\nPiš ${toneText}, česky, s důrazem na jasnost a pozvánku k účasti. ${constraints}\nDetaily:\n${base}`.trim();
|
||||
const { data } = await api.post('/ai/blog/generate', {
|
||||
prompt,
|
||||
audience: clubName ? `Fanoušci klubu ${clubName}, oznámení/pozvánka` : 'Fanoušci klubu, oznámení/pozvánka',
|
||||
min_words: 120,
|
||||
min_words: 60,
|
||||
});
|
||||
|
||||
// Handle potential JSON string response from AI (defensive parsing)
|
||||
@@ -831,6 +897,30 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<Box mt={4}>
|
||||
<Heading size="sm" mb={3}>Místo konání</Heading>
|
||||
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
|
||||
<FormControl>
|
||||
<FormLabel fontSize="sm">Uložená místa (rychlý výběr)</FormLabel>
|
||||
<HStack spacing={2} align="center">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Vyberte uložené místo..."
|
||||
value={selectedSavedId}
|
||||
onChange={(e) => applySavedLocation(e.target.value)}
|
||||
flex={1}
|
||||
>
|
||||
{savedLocations.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
{loc.label}{loc.address ? ` — ${loc.address}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={addCurrentLocationToSaved}>Uložit aktuální</Button>
|
||||
<Button size="sm" variant="ghost" colorScheme="red" onClick={deleteSelectedSaved} isDisabled={!selectedSavedId || selectedSavedId === 'club-main'}>Smazat</Button>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color={textSecondary} mt={1}>Vyberte klubové nebo dříve uložené místo. „Uložit aktuální“ přidá současný název/adresu a souřadnice.</Text>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* MapLinkImporter */}
|
||||
<Box bg={useColorModeValue('gray.50', 'gray.900')} p={4} borderRadius="md" borderWidth="1px" mb={3}>
|
||||
<Text fontSize="sm" fontWeight="semibold" mb={2}>Importovat z odkazu na mapu</Text>
|
||||
|
||||
@@ -39,12 +39,12 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import AdminLayout from '../../layouts/AdminLayout';
|
||||
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||
import { putMatchOverride, fetchTeamLogoOverrides } from '../../services/adminMatches';
|
||||
import { getPublicSettings } from '../../services/settings';
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { parse } from 'date-fns';
|
||||
import { parse, format } from 'date-fns';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
|
||||
import { API_URL } from '../../services/api';
|
||||
@@ -53,15 +53,10 @@ const MatchesAdminPage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [focusSide, setFocusSide] = useState<'home' | 'away' | null>(null);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
home_name_override: '',
|
||||
away_name_override: '',
|
||||
venue_override: '',
|
||||
date_time_override: '',
|
||||
home_logo_url: '',
|
||||
away_logo_url: '',
|
||||
date_time_edit: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
@@ -70,6 +65,7 @@ const MatchesAdminPage = () => {
|
||||
queryFn: fetchTeamLogoOverrides,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
|
||||
const normalizeName = (s: string) => {
|
||||
let out = String(s || '');
|
||||
@@ -102,56 +98,58 @@ const MatchesAdminPage = () => {
|
||||
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
|
||||
return idx;
|
||||
}, [byName]);
|
||||
// Build name index from overrides by_id for cases where team_id is missing in cached data
|
||||
const overridesNameIndex = useMemo(() => {
|
||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||
try {
|
||||
for (const [id, v] of Object.entries(overridesById)) {
|
||||
const name = String((v as any)?.name || '').trim();
|
||||
const logo = String((v as any)?.logo_url || '').trim();
|
||||
if (!name) continue;
|
||||
const norm = normalizeName(name);
|
||||
if (!norm) continue;
|
||||
idx[norm] = { id, name, logo_url: logo };
|
||||
}
|
||||
} catch {}
|
||||
return idx;
|
||||
}, [overridesById]);
|
||||
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
|
||||
|
||||
const getLogo = (teamName?: string, teamId?: string, facrOriginal?: string) => {
|
||||
if (!teamName) return assetUrl('/dist/img/logo-club-empty.svg') as string;
|
||||
// 0) Admin override by team ID takes precedence
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.logo_url) {
|
||||
const u = String(overridesById[teamId].logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
// 0.5) If no ID, but override exists for normalized name, use it
|
||||
try {
|
||||
const hit = overridesNameIndex[normalizeName(teamName)];
|
||||
if (hit && hit.logo_url) {
|
||||
const u = String(hit.logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
} catch {}
|
||||
// 1) LogoAPI map by team ID
|
||||
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
|
||||
// 2) Local/legacy overrides by name
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
|
||||
if (overrideUrl) {
|
||||
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
|
||||
return overrideUrl;
|
||||
}
|
||||
// 3) FACR original if provided
|
||||
if (facrOriginal) return facrOriginal;
|
||||
// Fallback placeholder
|
||||
return '/dist/img/logo-club-empty.svg';
|
||||
};
|
||||
|
||||
// External logo upload helpers/state
|
||||
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
|
||||
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
|
||||
const [homeUploadedFile, setHomeUploadedFile] = useState<File | null>(null);
|
||||
const [awayUploadedFile, setAwayUploadedFile] = useState<File | null>(null);
|
||||
|
||||
// Team search state
|
||||
const [homeQuery, setHomeQuery] = useState('');
|
||||
const [awayQuery, setAwayQuery] = useState('');
|
||||
const [debouncedHome, setDebouncedHome] = useState('');
|
||||
const [debouncedAway, setDebouncedAway] = useState('');
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedHome(homeQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [homeQuery]);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedAway(awayQuery), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [awayQuery]);
|
||||
const { data: homeResults = [] } = useQuery({
|
||||
queryKey: ['club-search-home', debouncedHome],
|
||||
queryFn: () => searchClubs(debouncedHome),
|
||||
enabled: debouncedHome.trim().length >= 2,
|
||||
});
|
||||
const { data: awayResults = [] } = useQuery({
|
||||
queryKey: ['club-search-away', debouncedAway],
|
||||
queryFn: () => searchClubs(debouncedAway),
|
||||
enabled: debouncedAway.trim().length >= 2,
|
||||
});
|
||||
|
||||
// Upload refs
|
||||
const homeFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayFileRef = useRef<HTMLInputElement | null>(null);
|
||||
// Team name/logo editing removed
|
||||
|
||||
const { data: matches = [], isLoading, error } = useQuery<any[], Error>({
|
||||
queryKey: ['admin-matches-list-cache'],
|
||||
@@ -170,6 +168,17 @@ const MatchesAdminPage = () => {
|
||||
|
||||
// Optional: stable sort by date ascending
|
||||
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();
|
||||
@@ -218,6 +227,17 @@ const MatchesAdminPage = () => {
|
||||
const [sideFilter, setSideFilter] = useState<'home' | 'away' | ''>('');
|
||||
const normalizedTeam = teamFilter.trim().toLowerCase();
|
||||
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;
|
||||
};
|
||||
// Club name (for side filter)
|
||||
const { data: publicSettings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
@@ -410,78 +430,28 @@ const MatchesAdminPage = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Datetime validation (RFC3339-ish)
|
||||
const isDateInvalid = form.date_time_override.trim() !== '' && isNaN(Date.parse(form.date_time_override));
|
||||
// Datetime validation for datetime-local
|
||||
const isDateInvalid = form.date_time_edit.trim() !== '' && isNaN(new Date(form.date_time_edit).getTime());
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const externalMatchId: string = selected?.match_id || selected?.id;
|
||||
if (!externalMatchId) throw new Error('Chybí match_id');
|
||||
const payload: any = { ...form };
|
||||
// normalize empty strings to null so backend can clear values
|
||||
const payload: any = {
|
||||
venue_override: form.venue_override,
|
||||
date_time_override: form.date_time_edit,
|
||||
notes: form.notes,
|
||||
};
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k as keyof typeof payload] === '') payload[k as keyof typeof payload] = null;
|
||||
});
|
||||
// First store current overrides
|
||||
await putMatchOverride(externalMatchId, payload);
|
||||
|
||||
// Best-effort upload to logoapi.sportcreative.eu for home/away
|
||||
const results: { home?: { success: boolean; error?: string }; away?: { success: boolean; error?: string } } = {};
|
||||
|
||||
const processSide = async (
|
||||
side: 'home' | 'away',
|
||||
externalTeamId: string,
|
||||
uploadedFile: File | null,
|
||||
nameOverride: string,
|
||||
logoUrl: string | null
|
||||
) => {
|
||||
try {
|
||||
if (!externalTeamId) return { success: false, error: 'Chybí ID týmu' };
|
||||
let file: File | Blob | null = uploadedFile;
|
||||
if (!file && logoUrl) {
|
||||
file = await fetchLogoAsBlob(logoUrl);
|
||||
}
|
||||
if (!file) return { success: false, error: 'Nelze získat soubor loga' };
|
||||
const up = await uploadToLogaSportcreative(externalTeamId, file, {
|
||||
filename: file instanceof File ? file.name : `${externalTeamId}.png`,
|
||||
clubName: nameOverride || 'Neznámý klub',
|
||||
clubType: 'football',
|
||||
});
|
||||
if (!up.success) return { success: false, error: up.error || 'Upload selhal' };
|
||||
if (up.url) {
|
||||
// Patch override to immediately use external URL
|
||||
await patchMatchOverride(
|
||||
externalMatchId,
|
||||
side === 'home' ? { home_logo_url: up.url } : { away_logo_url: up.url }
|
||||
);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.message || 'Chyba při uploadu' };
|
||||
}
|
||||
};
|
||||
|
||||
if (homeExternalTeamId && (form.home_logo_url || homeUploadedFile)) {
|
||||
results.home = await processSide('home', homeExternalTeamId, homeUploadedFile, form.home_name_override, form.home_logo_url);
|
||||
}
|
||||
if (awayExternalTeamId && (form.away_logo_url || awayUploadedFile)) {
|
||||
results.away = await processSide('away', awayExternalTeamId, awayUploadedFile, form.away_name_override, form.away_logo_url);
|
||||
}
|
||||
|
||||
return { ok: true, results };
|
||||
return { ok: true };
|
||||
},
|
||||
onSuccess: (res: any) => {
|
||||
const r = res?.results || {};
|
||||
const parts: string[] = [];
|
||||
if (r.home) parts.push(r.home.success ? 'Logo domácích nahráno' : `Domácí: ${r.home.error || 'chyba'}`);
|
||||
if (r.away) parts.push(r.away.success ? 'Logo hostů nahráno' : `Hosté: ${r.away.error || 'chyba'}`);
|
||||
const description = parts.length ? parts.join(' • ') : undefined;
|
||||
toast({ title: 'Uloženo', description, status: 'success' });
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Uloženo', status: 'success' });
|
||||
setIsOpen(false);
|
||||
setSelected(null);
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
// Invalidate the cache-backed list to refresh any merged overrides
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-matches-list-cache'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
@@ -489,57 +459,34 @@ const MatchesAdminPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const openEdit = (m: any, side?: 'home' | 'away') => {
|
||||
const openEdit = (m: any) => {
|
||||
setSelected(m);
|
||||
// Convert FACR-style date (e.g., 25.08.2025 18:30) to RFC3339 for backend
|
||||
const facrStr: string = m.date_time || m.date || '';
|
||||
let iso = '';
|
||||
let localStr = '';
|
||||
if (facrStr) {
|
||||
try {
|
||||
const dt = parse(String(facrStr), 'dd.MM.yyyy HH:mm', new Date());
|
||||
if (!isNaN(dt.getTime())) iso = dt.toISOString();
|
||||
if (!isNaN(dt.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
localStr = `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
||||
}
|
||||
} catch (_) {
|
||||
// If it's already ISO or another parseable format, keep as-is if valid
|
||||
const d2 = new Date(facrStr);
|
||||
if (!isNaN(d2.getTime())) iso = d2.toISOString();
|
||||
if (!isNaN(d2.getTime())) {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
localStr = `${d2.getFullYear()}-${pad(d2.getMonth() + 1)}-${pad(d2.getDate())}T${pad(d2.getHours())}:${pad(d2.getMinutes())}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
setForm({
|
||||
home_name_override: m.home || m.home_team || '',
|
||||
away_name_override: m.away || m.away_team || '',
|
||||
venue_override: m.venue || '',
|
||||
date_time_override: iso,
|
||||
home_logo_url: m.home_logo_url || '',
|
||||
away_logo_url: m.away_logo_url || '',
|
||||
date_time_edit: localStr,
|
||||
notes: '',
|
||||
});
|
||||
setIsOpen(true);
|
||||
setFocusSide(side ?? null);
|
||||
// Reset external selections and uploaded files to avoid stale state
|
||||
setHomeExternalTeamId('');
|
||||
setAwayExternalTeamId('');
|
||||
setHomeUploadedFile(null);
|
||||
setAwayUploadedFile(null);
|
||||
};
|
||||
|
||||
// Autofocus on the selected team input when drawer opens
|
||||
const homeInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const awayInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const handleHomeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setHomeQuery(e.target.value);
|
||||
};
|
||||
const handleAwayInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAwayQuery(e.target.value);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isOpen && focusSide) {
|
||||
const t = setTimeout(() => {
|
||||
if (focusSide === 'home') homeInputRef.current?.focus();
|
||||
if (focusSide === 'away') awayInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen, focusSide]);
|
||||
// Removed autofocus logic for team inputs
|
||||
|
||||
const drawerSize = useBreakpointValue({ base: 'full', md: 'md' });
|
||||
// Horizontal scroll affordance
|
||||
@@ -943,7 +890,7 @@ const MatchesAdminPage = () => {
|
||||
>
|
||||
<Td>
|
||||
<HStack spacing={2}>
|
||||
<Text>{m.date_time || m.date || ''}</Text>
|
||||
<Text>{formatDisplayDate(String(m.date_time || m.date || ''))}</Text>
|
||||
{isPast && <Badge colorScheme="gray" fontSize="xs">Odehráno</Badge>}
|
||||
{!isPast && <Badge colorScheme="green" fontSize="xs">Nadcházející</Badge>}
|
||||
</HStack>
|
||||
@@ -965,7 +912,7 @@ const MatchesAdminPage = () => {
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.home || m.home_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'home')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td textAlign="center">
|
||||
@@ -985,7 +932,7 @@ const MatchesAdminPage = () => {
|
||||
draggable={false}
|
||||
/>
|
||||
<Text fontWeight={isPast ? 'normal' : 'medium'}>{m.away || m.away_team || ''}</Text>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m, 'away')} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
<Button size="xs" variant="outline" onClick={() => openEdit(m)} borderRadius="md" _hover={{ borderColor: 'brand.primary', color: 'brand.primary' }}>Tým</Button>
|
||||
</HStack>
|
||||
</Td>
|
||||
<Td>{m.venue || ''}</Td>
|
||||
@@ -1023,11 +970,11 @@ const MatchesAdminPage = () => {
|
||||
) : (
|
||||
<Stack spacing={4}>
|
||||
<FormControl>
|
||||
<FormLabel>Datum a čas (ISO)</FormLabel>
|
||||
<FormLabel>Datum a čas</FormLabel>
|
||||
<Input
|
||||
placeholder="YYYY-MM-DDTHH:mm:ss.sssZ"
|
||||
value={form.date_time_override}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date_time_override: e.target.value }))}
|
||||
type="datetime-local"
|
||||
value={form.date_time_edit}
|
||||
onChange={(e) => setForm((f) => ({ ...f, date_time_edit: e.target.value }))}
|
||||
/>
|
||||
{isDateInvalid && (
|
||||
<FormErrorMessage>Neplatný formát data/času</FormErrorMessage>
|
||||
@@ -1043,129 +990,7 @@ const MatchesAdminPage = () => {
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{/* Home team */}
|
||||
<FormControl>
|
||||
<FormLabel>Domácí tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={homeInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.home_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, home_name_override: e.target.value }));
|
||||
handleHomeInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => homeFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={homeFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, home_logo_url: up.url }));
|
||||
setHomeUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (domácí)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (homeFileRef.current) homeFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{homeResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{homeResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, home_name_override: r.name, home_logo_url: r.logo_url || f.home_logo_url }));
|
||||
setHomeQuery(r.name);
|
||||
setHomeExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.home_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.home_logo_url} alt="home logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, home_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{/* Away team */}
|
||||
<FormControl>
|
||||
<FormLabel>Hostující tým (název)</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={awayInputRef}
|
||||
placeholder="Zadejte název týmu"
|
||||
value={form.away_name_override}
|
||||
onChange={(e) => {
|
||||
setForm((f) => ({ ...f, away_name_override: e.target.value }));
|
||||
handleAwayInput(e);
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="4.5rem">
|
||||
<Button h="1.75rem" size="sm" onClick={() => awayFileRef.current?.click()}>Logo</Button>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
ref={awayFileRef}
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const up = await uploadImage(file);
|
||||
setForm((f) => ({ ...f, away_logo_url: up.url }));
|
||||
setAwayUploadedFile(file);
|
||||
toast({ title: 'Logo nahráno (hosté)', status: 'success' });
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Nahrání se nezdařilo', description: err?.message || '', status: 'error' });
|
||||
} finally {
|
||||
if (awayFileRef.current) awayFileRef.current.value = '' as any;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{awayResults.length > 0 && (
|
||||
<Box mt={2} borderWidth="1px" borderRadius="md" p={2} maxH="180px" overflowY="auto">
|
||||
<List spacing={1}>
|
||||
{awayResults.map((r: any) => (
|
||||
<ListItem key={r.id}>
|
||||
<Button size="xs" variant="ghost" onClick={() => {
|
||||
setForm((f) => ({ ...f, away_name_override: r.name, away_logo_url: r.logo_url || f.away_logo_url }));
|
||||
setAwayQuery(r.name);
|
||||
setAwayExternalTeamId(String(r.id || ''));
|
||||
}}>
|
||||
{r.name}
|
||||
</Button>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
{form.away_logo_url && (
|
||||
<HStack mt={2} spacing={3}>
|
||||
<Image src={form.away_logo_url} alt="away logo" boxSize="28px" objectFit="contain" />
|
||||
<Button size="xs" variant="outline" onClick={() => setForm((f) => ({ ...f, away_logo_url: '' }))}>Odebrat logo</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</FormControl>
|
||||
{/* Team name/logo editing removed */}
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Poznámka</FormLabel>
|
||||
|
||||
@@ -438,7 +438,7 @@ const PlayersAdminPage: React.FC = () => {
|
||||
</Select>
|
||||
</HStack>
|
||||
<Box mt={2} fontSize="sm" color="gray.500">
|
||||
{formatDobPreview(dobParts)}{calculateAgeFromParts(dobParts) != null ? ` — ${calculateAgeFromParts(dobParts)} let` : ''}
|
||||
{formatDobPreview(dobParts)}{(() => { const a = calculateAgeFromParts(dobParts); return a != null ? ` — ${a} ${czYears(a)}` : ''; })()}
|
||||
</Box>
|
||||
</FormControl>
|
||||
|
||||
@@ -529,6 +529,9 @@ const PlayersAdminPage: React.FC = () => {
|
||||
<FormLabel>Telefon (nepovinné)</FormLabel>
|
||||
<Input type="tel" value={(editing as any)?.phone || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), phone: e.target.value }))} />
|
||||
</FormControl>
|
||||
<Box gridColumn="1 / -1" fontSize="sm" color="gray.600">
|
||||
Upozornění: telefonní číslo a e‑mail budou viditelné na hlavní stránce. Údaje nejsou povinné — pokud je nechcete zadávat, ponechte je prázdné.
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
<FormControl>
|
||||
@@ -615,6 +618,16 @@ const PlayersAdminPage: React.FC = () => {
|
||||
return age;
|
||||
}
|
||||
|
||||
// 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';
|
||||
return 'let';
|
||||
}
|
||||
|
||||
// Update DOB parts and, when complete, compose YYYY-MM-DD. Clamp day to month length.
|
||||
function updateDobPart(part: 'day'|'month'|'year', value: string) {
|
||||
setDobParts((prev) => {
|
||||
|
||||
@@ -197,6 +197,8 @@ const SettingsAdminPage: React.FC = () => {
|
||||
(typeof (settings as any).location_latitude === 'number') &&
|
||||
(typeof (settings as any).location_longitude === 'number'),
|
||||
map_style: (settings as any).map_style,
|
||||
frontend_base_url: (settings as any).frontend_base_url,
|
||||
api_base_url: (settings as any).api_base_url,
|
||||
// homepage matches display
|
||||
finished_match_display_days: (settings as any).finished_match_display_days as any,
|
||||
};
|
||||
@@ -205,6 +207,22 @@ const SettingsAdminPage: React.FC = () => {
|
||||
toast({ title: 'Uloženo', description: 'Nastavení bylo úspěšně aktualizováno', status: 'success' });
|
||||
// Try to refresh prefetch caches
|
||||
try { await triggerPrefetch(); } catch {}
|
||||
try {
|
||||
const fb = String(((saved as any).frontend_base_url || (settings as any).frontend_base_url || '')).replace(/\/$/, '');
|
||||
let ab = String(((saved as any).api_base_url || (settings as any).api_base_url || '')).trim();
|
||||
if (fb || ab) {
|
||||
try {
|
||||
const u = new URL(ab || '', fb || (typeof window !== 'undefined' ? window.location.origin : ''));
|
||||
if (!/\/api\//.test(u.pathname)) { u.pathname = u.pathname.replace(/\/$/, '') + '/api/v1'; }
|
||||
ab = u.toString();
|
||||
} catch {}
|
||||
try { localStorage.setItem('fc_frontend_base_url', fb); } catch {}
|
||||
try { localStorage.setItem('fc_api_base_url', ab); } catch {}
|
||||
try { localStorage.setItem('api_base_url', ab); } catch {}
|
||||
try { (api as any).defaults.baseURL = ab; } catch {}
|
||||
setTimeout(() => { try { window.location.reload(); } catch {} }, 600);
|
||||
}
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
toast({ title: 'Chyba', description: e?.message || 'Uložení nastavení se nezdařilo', status: 'error' });
|
||||
} finally {
|
||||
@@ -271,6 +289,17 @@ const SettingsAdminPage: React.FC = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading size="sm">Nastavení URL</Heading>
|
||||
<FormControl>
|
||||
<FormLabel>URL webu</FormLabel>
|
||||
<Input value={(settings as any).frontend_base_url || ''} onChange={handleChange('frontend_base_url' as any)} placeholder="https://www.vasklub.cz" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<FormLabel>API URL</FormLabel>
|
||||
<Input value={(settings as any).api_base_url || ''} onChange={handleChange('api_base_url' as any)} placeholder="https://api.vasklub.cz/api/v1" />
|
||||
<FormHelperText>Ujistěte se, že adresa končí na /api/v1</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Heading size="sm">Zobrazení zápasů</Heading>
|
||||
<FormControl>
|
||||
<FormLabel>Počet dní zobrazení dokončených zápasů</FormLabel>
|
||||
|
||||
@@ -71,6 +71,7 @@ function normalize(s: string): string {
|
||||
'sportovni klub',
|
||||
'telovychovna jednota',
|
||||
'skolni sportovni klub',
|
||||
'spolek',
|
||||
'fotbal',
|
||||
'futsal',
|
||||
];
|
||||
@@ -139,6 +140,21 @@ const TeamsAdminPage = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const overridesById: Record<string, { name?: string; logo_url?: string }> = (overrides as any)?.by_id || {};
|
||||
// Build an index by normalized team name for overrides that carry an ID
|
||||
const overridesNameIndex = useMemo(() => {
|
||||
const idx: Record<string, { id: string; name: string; logo_url: string }> = {};
|
||||
try {
|
||||
for (const [id, v] of Object.entries(overridesById)) {
|
||||
const name = String((v as any)?.name || '').trim();
|
||||
const logo = String((v as any)?.logo_url || '').trim();
|
||||
if (!name) continue;
|
||||
const norm = normalize(name);
|
||||
if (!norm) continue;
|
||||
idx[norm] = { id, name, logo_url: logo };
|
||||
}
|
||||
} catch {}
|
||||
return idx;
|
||||
}, [overridesById]);
|
||||
|
||||
// Fetch logos from logoapi.sportcreative.eu for all teams
|
||||
const [sportLogosMap, setSportLogosMap] = useState<Record<string, string>>({});
|
||||
@@ -186,6 +202,15 @@ const TeamsAdminPage = () => {
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
// Priority 0.5: Try match by override name when team_id is missing
|
||||
try {
|
||||
const hit = overridesNameIndex[normalize(teamName)];
|
||||
if (hit && hit.logo_url) {
|
||||
const u = String(hit.logo_url);
|
||||
if (u.startsWith('/')) return assetUrl(u) as string;
|
||||
return u;
|
||||
}
|
||||
} catch {}
|
||||
// Priority 1: Local admin override (exact + normalized)
|
||||
let overrideUrl = byName[teamName];
|
||||
if (!overrideUrl) {
|
||||
@@ -217,6 +242,15 @@ const TeamsAdminPage = () => {
|
||||
if (teamId && overridesById[teamId] && overridesById[teamId]?.name) {
|
||||
return String(overridesById[teamId].name || '').trim() || String(teamName || '');
|
||||
}
|
||||
// If no ID, but override exists for the normalized name, use canonical override name
|
||||
try {
|
||||
if (teamName) {
|
||||
const hit = overridesNameIndex[normalize(teamName)];
|
||||
if (hit && hit.name) {
|
||||
return hit.name;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return String(teamName || '');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user