This commit is contained in:
Tomas Dvorak
2025-10-23 22:26:50 +02:00
parent 63700eedb2
commit 70ea0c3c91
75 changed files with 3337 additions and 1160 deletions
+18 -11
View File
@@ -40,13 +40,7 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
retry: false,
});
// Show loading state while fetching
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
const mid = (linkQ.data as any)?.external_match_id;
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
const facrQ = useQuery({
queryKey: ['facr-cached-match', mid],
@@ -77,6 +71,13 @@ const MatchLinkBadge: React.FC<{ articleId: number }> = ({ articleId }) => {
}
});
// Show loading state while fetching (after hooks are declared to keep order consistent)
if (linkQ.isLoading) {
return <Badge colorScheme="gray">Načítání...</Badge>;
}
if (!mid) return <Badge colorScheme="gray">Nepropojeno</Badge>;
// Guard against errors
if (facrQ.isError || linkQ.isError) {
return <Badge colorScheme="red">Chyba načítání</Badge>;
@@ -1164,6 +1165,12 @@ const ArticlesAdminPage = () => {
}
};
const matchBgSelected = useColorModeValue('blue.50', 'blue.900');
const matchBgDefault = useColorModeValue('white', 'gray.700');
const matchHoverBg = useColorModeValue('blue.50', 'gray.600');
const albumLinkHasPhotosBg = useColorModeValue('green.50', 'green.900');
const albumCardBg = useColorModeValue('white', 'gray.700');
return (
<AdminLayout requireAdmin={false}>
<Box>
@@ -1500,9 +1507,9 @@ const ArticlesAdminPage = () => {
borderWidth="2px"
borderRadius="md"
borderColor={isSelected ? 'blue.500' : 'gray.200'}
bg={isSelected ? useColorModeValue('blue.50', 'blue.900') : useColorModeValue('white', 'gray.700')}
bg={isSelected ? matchBgSelected : matchBgDefault}
cursor="pointer"
_hover={{ borderColor: 'blue.300', bg: useColorModeValue('blue.50', 'gray.600') }}
_hover={{ borderColor: 'blue.300', bg: matchHoverBg }}
transition="all 0.2s"
onClick={async () => {
const val = matchId;
@@ -1605,7 +1612,7 @@ const ArticlesAdminPage = () => {
placeholder="https://eu.zonerama.com/…"
value={zAlbumLink}
onChange={(e) => setZAlbumLink(e.target.value)}
bg={zAlbumPhotos.length > 0 ? useColorModeValue('green.50', 'green.900') : undefined}
bg={zAlbumPhotos.length > 0 ? albumLinkHasPhotosBg : undefined}
/>
</InputGroup>
<FormHelperText fontSize="xs">
@@ -2111,7 +2118,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
@@ -2200,7 +2207,7 @@ const ArticlesAdminPage = () => {
{!galleryLoading && cachedAlbums.length > 0 && (
<VStack align="stretch" spacing={6}>
{cachedAlbums.map((album) => (
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={useColorModeValue('white', 'gray.700')}>
<Box key={album.id} borderWidth="1px" borderRadius="md" p={4} bg={albumCardBg}>
<HStack justify="space-between" mb={3}>
<VStack align="start" spacing={0}>
<Text fontWeight="bold" fontSize="lg">{album.title || 'Album bez názvu'}</Text>
+82 -13
View File
@@ -38,14 +38,14 @@ import {
Select
} from '@chakra-ui/react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { TeamLogo } from '../../components/common/TeamLogo';
import AdminLayout from '../../layouts/AdminLayout';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative } from '../../services/adminMatches';
import { putMatchOverride, patchMatchOverride, searchClubs, uploadImage, fetchLogoAsBlob, uploadToLogaSportcreative, 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 { assetUrl } from '../../utils/url';
import { batchFetchLogosFromSportLogosAPI } from '../../utils/sportLogosAPI';
const MatchesAdminPage = () => {
const queryClient = useQueryClient();
@@ -63,6 +63,60 @@ const MatchesAdminPage = () => {
notes: '',
});
const { data: overrides = {} } = useQuery({
queryKey: ['teamLogoOverrides'],
queryFn: fetchTeamLogoOverrides,
staleTime: 5 * 60 * 1000,
});
const normalizeName = (s: string) => {
let out = String(s || '');
out = out
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
out = out.replace(/[\u2012\u2013\u2014\u2015\u2212]/g, '-');
const orgPhrases = [
'fotbalovy klub',
'sportovni klub',
'telovychovna jednota',
'skolni sportovni klub',
'fotbal',
'futsal',
];
for (const phrase of orgPhrases) {
const re = new RegExp(`(^|\\b)${phrase}(\\b|$)`, 'g');
out = out.replace(re, ' ');
}
out = out.replace(/\b(1\.)?\s*(sfc|afc|fc|fk|mfk|tj|sk|afk)\b\.?/g, ' ');
out = out.replace(/[\.,!;:()\[\]{}]/g, ' ');
out = out.replace(/\s+/g, ' ').trim();
return out;
};
const byName: Record<string, string> = (overrides as any)?.by_name || {};
const byNameNormalized = useMemo(() => {
const idx: Record<string, string> = {};
for (const k of Object.keys(byName)) idx[normalizeName(k)] = byName[k];
return idx;
}, [byName]);
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;
if (teamId && sportLogosMap[String(teamId)]) return sportLogosMap[String(teamId)];
let overrideUrl = byName[teamName];
if (!overrideUrl) overrideUrl = byNameNormalized[normalizeName(teamName)];
if (overrideUrl) {
if (overrideUrl.startsWith('/')) return assetUrl(overrideUrl) as string;
return overrideUrl;
}
if (facrOriginal) return facrOriginal;
return '/dist/img/logo-club-empty.svg';
};
// External logo upload helpers/state
const [homeExternalTeamId, setHomeExternalTeamId] = useState<string>('');
const [awayExternalTeamId, setAwayExternalTeamId] = useState<string>('');
@@ -137,7 +191,24 @@ const MatchesAdminPage = () => {
}));
},
});
useEffect(() => {
if (!Array.isArray(matches) || matches.length === 0) return;
const ids = new Set<string>();
for (const m of matches as any[]) {
if (m.home_id) ids.add(String(m.home_id));
if (m.away_id) ids.add(String(m.away_id));
}
if (ids.size === 0) return;
(async () => {
try {
const map = await batchFetchLogosFromSportLogosAPI(Array.from(ids));
setSportLogosMap(map);
} catch (e) {
console.warn('Failed to batch fetch logos:', e);
}
})();
}, [matches]);
// Filters
const [teamFilter, setTeamFilter] = useState('');
const [dateFrom, setDateFrom] = useState<string>(''); // YYYY-MM-DD
@@ -870,12 +941,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.home_id}
teamName={m.home || m.home_team || ''}
facrLogo={m.home_logo_url}
size="custom"
<Image
src={getLogo(m.home || m.home_team || '', m.home_id, m.home_logo_url)}
alt={m.home || m.home_team || ''}
boxSize="24px"
objectFit="contain"
/>
<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>
@@ -888,12 +958,11 @@ const MatchesAdminPage = () => {
</Td>
<Td>
<HStack spacing={2}>
<TeamLogo
teamId={m.away_id}
teamName={m.away || m.away_team || ''}
facrLogo={m.away_logo_url}
size="custom"
<Image
src={getLogo(m.away || m.away_team || '', m.away_id, m.away_logo_url)}
alt={m.away || m.away_team || ''}
boxSize="24px"
objectFit="contain"
/>
<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>
+65 -15
View File
@@ -4,6 +4,7 @@ import {
Button,
FormControl,
FormLabel,
FormErrorMessage,
Heading,
HStack,
IconButton,
@@ -229,6 +230,13 @@ const PlayersAdminPage: React.FC = () => {
const [editing, setEditing] = useState<Editing | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const JERSEY_MIN = 0;
const JERSEY_MAX = 99;
const HEIGHT_MIN = 0;
const HEIGHT_MAX = 250;
const WEIGHT_MIN = 0;
const WEIGHT_MAX = 200;
// Local state to persist partial DOB selections so the user sees what they picked
const [dobParts, setDobParts] = useState<{ day: string; month: string; year: string }>({ day: '', month: '', year: '' });
@@ -276,14 +284,47 @@ const PlayersAdminPage: React.FC = () => {
},
});
const maybeSplitName = () => {
setEditing((p) => {
if (!p) return p;
const fn = (p.first_name || '').trim();
const ln = (p.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return { ...(p as any), first_name: parts[0], last_name: parts[parts.length - 1] } as any;
}
}
return p as any;
});
};
const onSubmit = async () => {
if (!editing) return;
const fn = (editing.first_name || '').trim();
const ln = (editing.last_name || '').trim();
let fn = (editing.first_name || '').trim();
let ln = (editing.last_name || '').trim();
if (!ln && fn.includes(' ')) {
const parts = fn.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
fn = parts[0];
ln = parts[parts.length - 1];
}
}
if (!fn || !ln) {
toast({ title: 'Jméno a příjmení jsou povinné', status: 'warning' });
return;
}
const tooBig = (
typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > JERSEY_MAX
) || (
typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > HEIGHT_MAX
) || (
typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > WEIGHT_MAX
);
if (tooBig) {
toast({ title: 'Neplatná čísla', description: `Maxima: číslo dresu ${JERSEY_MAX}, výška ${HEIGHT_MAX} cm, váha ${WEIGHT_MAX} kg`, status: 'warning' });
return;
}
// Build payload by including only present values to satisfy backend validation
const payload: any = {
first_name: fn,
@@ -291,10 +332,16 @@ const PlayersAdminPage: React.FC = () => {
};
if (editing.date_of_birth) payload.date_of_birth = editing.date_of_birth;
if (editing.position) payload.position = editing.position;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) payload.jersey_number = editing.jersey_number;
if (typeof editing.jersey_number === 'number' && Number.isFinite(editing.jersey_number) && editing.jersey_number > 0) {
payload.jersey_number = editing.jersey_number;
}
if (editing.nationality) payload.nationality = editing.nationality;
if (typeof editing.height === 'number' && editing.height > 0) payload.height = editing.height;
if (typeof editing.weight === 'number' && editing.weight > 0) payload.weight = editing.weight;
if (typeof editing.height === 'number' && Number.isFinite(editing.height) && editing.height > 0) {
payload.height = editing.height;
}
if (typeof editing.weight === 'number' && Number.isFinite(editing.weight) && editing.weight > 0) {
payload.weight = editing.weight;
}
if (editing.image_url) payload.image_url = editing.image_url;
if (typeof editing.is_active === 'boolean') payload.is_active = editing.is_active;
const email = ((editing as any).email || '').trim();
@@ -373,7 +420,7 @@ const PlayersAdminPage: React.FC = () => {
<SimpleGrid columns={[1, 2]} spacing={4}>
<FormControl isRequired>
<FormLabel>Jméno</FormLabel>
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} />
<Input value={editing?.first_name || ''} onChange={(e) => setEditing((p) => ({ ...(p as any), first_name: e.target.value }))} onBlur={maybeSplitName} />
</FormControl>
<FormControl isRequired>
<FormLabel>Příjmení</FormLabel>
@@ -414,11 +461,12 @@ const PlayersAdminPage: React.FC = () => {
</Select>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.jersey_number === 'number' && (editing?.jersey_number as number) > JERSEY_MAX}>
<FormLabel>Číslo dresu</FormLabel>
<NumberInput value={editing?.jersey_number ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={JERSEY_MIN} max={JERSEY_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.jersey_number === 'number' ? editing?.jersey_number : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), jersey_number: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální číslo dresu je {JERSEY_MAX}.</FormErrorMessage>
</FormControl>
<FormControl>
@@ -466,17 +514,19 @@ const PlayersAdminPage: React.FC = () => {
</VStack>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.height === 'number' && (editing?.height as number) > HEIGHT_MAX}>
<FormLabel>Výška (cm)</FormLabel>
<NumberInput value={editing?.height ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={HEIGHT_MIN} max={HEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.height === 'number' ? editing?.height : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), height: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální výška je {HEIGHT_MAX} cm.</FormErrorMessage>
</FormControl>
<FormControl>
<FormControl isInvalid={typeof editing?.weight === 'number' && (editing?.weight as number) > WEIGHT_MAX}>
<FormLabel>Váha (kg)</FormLabel>
<NumberInput value={editing?.weight ?? 0} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : 0 }))}>
<NumberInputField />
<NumberInput min={WEIGHT_MIN} max={WEIGHT_MAX} keepWithinRange={false} clampValueOnBlur={false} value={typeof editing?.weight === 'number' ? editing?.weight : ''} onChange={(_, v) => setEditing((p) => ({ ...(p as any), weight: Number.isFinite(v) ? v : undefined }))}>
<NumberInputField inputMode="numeric" />
</NumberInput>
<FormErrorMessage>Maximální váha je {WEIGHT_MAX} kg.</FormErrorMessage>
</FormControl>
{/* Optional contact info (not shown publicly) */}
<FormControl>
+77 -6
View File
@@ -211,6 +211,65 @@ const PollsAdminPage: React.FC = () => {
onOpen();
};
const applyPreset = (preset: 'rating5' | 'rating10' | 'attendance') => {
if (preset === 'rating5') {
const options = Array.from({ length: 5 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu',
description: 'Ohodnoťte zápas (1 = nejhorší, 5 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'rating10') {
const options = Array.from({ length: 10 }).map((_, i) => ({
text: String(i + 1),
display_order: i + 1,
}));
setFormData({
title: 'Hodnocení zápasu (110)',
description: 'Ohodnoťte zápas (1 = nejhorší, 10 = nejlepší)',
type: 'rating',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options,
});
} else if (preset === 'attendance') {
setFormData({
title: 'Dorazíš na schůzku?',
description: 'Dej nám vědět, zda dorazíš.',
type: 'single',
status: 'active',
allow_multiple: false,
max_choices: 1,
show_results: 'after_vote',
require_auth: false,
allow_guest_vote: true,
featured: false,
options: [
{ text: 'Ano', display_order: 0 },
{ text: 'Ne', display_order: 1 },
{ text: 'Možná', display_order: 2 },
],
});
}
onOpen();
};
const handleOpenEdit = (poll: Poll) => {
setEditingPoll(poll);
setFormData({
@@ -362,9 +421,21 @@ const PollsAdminPage: React.FC = () => {
<VStack spacing={6} align="stretch">
<HStack justify="space-between">
<Heading size="lg">Správa anket</Heading>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
<HStack>
<Menu>
<MenuButton as={Button} rightIcon={<ChevronDownIcon />} variant="outline">
Předvolby
</MenuButton>
<MenuList>
<MenuItem onClick={() => applyPreset('rating5')}>Hodnocení zápasu (5 hvězd)</MenuItem>
<MenuItem onClick={() => applyPreset('rating10')}>Hodnocení zápasu (110)</MenuItem>
<MenuItem onClick={() => applyPreset('attendance')}>Dorazíš na schůzku?</MenuItem>
</MenuList>
</Menu>
<Button leftIcon={<AddIcon />} colorScheme="blue" onClick={handleOpenCreate}>
Nová anketa
</Button>
</HStack>
</HStack>
<Alert status="info">
@@ -818,7 +889,7 @@ const PollsAdminPage: React.FC = () => {
Výsledky
</Heading>
<VStack spacing={2} align="stretch">
{statsData.poll.options.map((option) => {
{(statsData.poll.options || []).map((option) => {
const percentage =
statsData.poll.total_votes > 0
? (option.vote_count / statsData.poll.total_votes) * 100
@@ -851,13 +922,13 @@ const PollsAdminPage: React.FC = () => {
</VStack>
</Box>
{statsData.votes_by_day.length > 0 && (
{(statsData.votes_by_day?.length ?? 0) > 0 && (
<Box>
<Heading size="sm" mb={4}>
Hlasy podle dnů
</Heading>
<VStack spacing={2} align="stretch">
{statsData.votes_by_day.map((day) => (
{(statsData.votes_by_day || []).map((day) => (
<HStack key={day.date} justify="space-between">
<Text>{new Date(day.date).toLocaleDateString('cs-CZ')}</Text>
<Badge>{day.count} hlasů</Badge>
+12 -21
View File
@@ -51,7 +51,7 @@ import { searchClubs, uploadImage, putTeamLogoOverride, fetchTeamLogoOverrides,
import { getFacrTablesCache } from '../../services/facr/cache';
import { assetUrl } from '../../utils/url';
import { useEffect, useMemo, useRef, useState } from 'react';
import { TeamLogo } from '../../components/common/TeamLogo';
type TableRow = {
rank?: string;
@@ -291,38 +291,26 @@ const TeamsAdminPage = () => {
.map((s) => s.trim())
.filter(Boolean);
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
// Also upload to logoapi.sportcreative.eu (non-blocking, best-effort)
// Upload to logoapi.sportcreative.eu first (best-effort). If successful, prefer that URL for overrides.
if (logoUrl) {
setExternalUploadStatus('uploading');
setExternalUploadError(null);
try {
let logoFileToUpload: File | Blob | null = uploadedFile;
// If no file was uploaded but we have a logo URL, fetch it as blob
if (!logoFileToUpload && logoUrl) {
logoFileToUpload = await fetchLogoAsBlob(logoUrl);
}
if (logoFileToUpload) {
// Upload to the logo service (loga.sportcreative.eu)
const logaResult = await uploadToLogaSportcreative(
form.external_team_id,
logoFileToUpload,
{
{
filename: `${form.external_team_id}.${logoFileToUpload instanceof File ? logoFileToUpload.name.split('.').pop() : 'png'}`,
clubName: form.team_name || selected?.teamName || 'Neznámý klub'
}
);
if (logaResult.success) {
setExternalUploadStatus('success');
// Use the URL from loga.sportcreative.eu
if (logaResult.url) {
logoUrl = logaResult.url;
}
@@ -339,7 +327,12 @@ const TeamsAdminPage = () => {
setExternalUploadError(error?.message || 'Upload failed');
}
}
// Save override for each variant name so editing one updates all duplicates
await Promise.all(
names.map((n) => putTeamLogoOverride(form.external_team_id, n, logoUrl))
);
return true;
},
onSuccess: () => {
@@ -495,12 +488,10 @@ const TeamsAdminPage = () => {
<Td py={1.5} fontSize="xs">{r.rank}</Td>
<Td py={1.5}>
<HStack spacing={2} align="center">
<TeamLogo
teamId={(r as any).team_id}
teamName={r.team}
facrLogo={r.team_logo_url}
size="small"
<Image
src={getLogo(r.team, (r as any).team_id, r.team_logo_url)}
alt={r.team}
boxSize="24px"
objectFit="contain"
/>
<Text fontSize="xs" noOfLines={1}>{r.team}</Text>
+5 -4
View File
@@ -49,7 +49,7 @@ interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'editor';
role: 'admin' | 'editor' | 'fan';
isActive: boolean;
createdAt: string;
}
@@ -65,7 +65,7 @@ const UsersAdminPage = () => {
email: '',
password: '',
currentPassword: '',
role: 'editor' as 'admin' | 'editor',
role: 'editor' as 'admin' | 'editor' | 'fan',
isActive: true,
});
const toast = useToast();
@@ -254,8 +254,8 @@ const UsersAdminPage = () => {
<Td>{user.name}</Td>
<Td>{user.email}</Td>
<Td>
<Badge colorScheme={user.role === 'admin' ? 'purple' : 'blue'}>
{user.role === 'admin' ? 'Admin' : 'Editor'}
<Badge colorScheme={user.role === 'admin' ? 'purple' : (user.role === 'editor' ? 'blue' : 'gray')}>
{user.role === 'admin' ? 'Admin' : user.role === 'editor' ? 'Editor' : 'Fan'}
</Badge>
</Td>
<Td>
@@ -385,6 +385,7 @@ const UsersAdminPage = () => {
value={formData.role}
onChange={handleInputChange}
>
<option value="fan">Fan</option>
<option value="editor" disabled={!!selectedUser && selectedUser.role === 'admin'}>Editor</option>
<option value="admin">Admin</option>
</Select>