From 444fa9cae4db5503186329c1a9fa3a5f64841951 Mon Sep 17 00:00:00 2001 From: byteful Date: Mon, 18 May 2026 04:49:41 -0500 Subject: [PATCH 1/2] fix #92 and #114 --- client/src/components/player-replays-list.tsx | 129 ++++++++++++++++++ .../src/components/settings/UsageSettings.tsx | 125 +++++++++++++---- .../src/components/windows/PlayerWindow.tsx | 14 +- client/src/hooks/use-data.tsx | 86 ++++++++++++ client/src/locales/de.json | 23 ++++ client/src/locales/en.json | 23 ++++ client/src/locales/es.json | 23 ++++ client/src/locales/nl.json | 23 ++++ client/src/pages/player-detail-page.tsx | 15 +- 9 files changed, 434 insertions(+), 27 deletions(-) create mode 100644 client/src/components/player-replays-list.tsx diff --git a/client/src/components/player-replays-list.tsx b/client/src/components/player-replays-list.tsx new file mode 100644 index 0000000..9d247b7 --- /dev/null +++ b/client/src/components/player-replays-list.tsx @@ -0,0 +1,129 @@ +import { Loader2, Play, TriangleAlert } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useLocation } from 'wouter'; +import { Badge } from '@modl-gg/shared-web/components/ui/badge'; +import { Button } from '@modl-gg/shared-web/components/ui/button'; +import { usePlayerReplays } from '@/hooks/use-data'; +import { formatDateWithTime } from '@/utils/date-utils'; +import { formatFileSize } from '@/utils/file-utils'; + +interface PlayerReplaysListProps { + playerId: string; +} + +const isRawReplayId = (replayReference: string) => ( + !replayReference.includes('://') + && !replayReference.includes('/') + && !replayReference.includes('?') + && !replayReference.includes('#') +); + +const getReplayIdFromReference = (replayUrl?: string) => { + const replayReference = replayUrl?.trim(); + if (!replayReference) { + return ''; + } + + if (isRawReplayId(replayReference)) { + return replayReference; + } + + try { + const parsedReplayUrl = new URL(replayReference, window.location.origin); + return parsedReplayUrl.searchParams.get('id') || ''; + } catch { + return ''; + } +}; + +const getReplayId = (replay: { replayId?: string; replayUrl?: string; matchSource?: string }) => { + if (replay.matchSource === 'TICKET_FALLBACK') { + return getReplayIdFromReference(replay.replayUrl); + } + + return replay.replayId || getReplayIdFromReference(replay.replayUrl); +}; + +const PlayerReplaysList = ({ playerId }: PlayerReplaysListProps) => { + const { t } = useTranslation(); + const [, navigate] = useLocation(); + const { data: replays, isLoading, error } = usePlayerReplays(playerId); + + if (isLoading) { + return ( +
+ + {t('player.loadingReplays')} +
+ ); + } + + if (error) { + return ( +
+ + {t('player.replaysLoadFailed')} +
+ ); + } + + if (!replays || replays.length === 0) { + return ( +
+

{t('player.noReplays')}

+
+ ); + } + + return ( +
+ {replays.map((replay) => { + const replayId = getReplayId(replay); + const canOpenReplay = Boolean(replayId) + && (replay.matchSource === 'TICKET_FALLBACK' || replay.status === 'COMPLETE'); + + return ( +
+
+
+
+ + + {replay.targetName || replay.targetUuid || t('player.unknownPlayer')} + + {replay.status && ( + + {replay.status} + + )} + {replay.matchSource && ( + + {replay.matchSource} + + )} +
+
+ {t('player.replayCreated')}: {replay.createdAt ? formatDateWithTime(replay.createdAt) : t('common.unknown')} + {t('player.replayMcVersion')}: {replay.mcVersion || t('common.unknown')} + {t('player.replaySize')}: {typeof replay.fileSize === 'number' ? formatFileSize(replay.fileSize) : t('common.unknown')} + {t('player.replayId')}: {replayId || t('common.unknown')} +
+
+ +
+
+ ); + })} +
+ ); +}; + +export default PlayerReplaysList; diff --git a/client/src/components/settings/UsageSettings.tsx b/client/src/components/settings/UsageSettings.tsx index b5bde95..cf33e51 100644 --- a/client/src/components/settings/UsageSettings.tsx +++ b/client/src/components/settings/UsageSettings.tsx @@ -9,11 +9,13 @@ import { Progress } from '@modl-gg/shared-web/components/ui/progress'; import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card'; import { Badge } from '@modl-gg/shared-web/components/ui/badge'; import { Checkbox } from '@modl-gg/shared-web/components/ui/checkbox'; +import { Switch } from '@modl-gg/shared-web/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@modl-gg/shared-web/components/ui/table'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@modl-gg/shared-web/components/ui/alert-dialog'; import { useToast } from '@modl-gg/shared-web/hooks/use-toast'; import { formatFileSize } from '@/utils/file-utils'; +import { useReplayRetentionSettings, useUpdateReplayRetentionSettings } from '@/hooks/use-data'; interface StorageFile { id: string; @@ -114,6 +116,10 @@ const UsageSettings = () => { const [newOverageLimit, setNewOverageLimit] = useState(0); const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(25); + const [replayRetentionEnabled, setReplayRetentionEnabled] = useState(true); + const [replayRetentionDays, setReplayRetentionDays] = useState(7); + const { data: replayRetentionSettings, isLoading: isLoadingReplayRetention, error: replayRetentionError } = useReplayRetentionSettings(); + const updateReplayRetentionSettings = useUpdateReplayRetentionSettings(); const DEFAULT_AI_LIMIT = 1000; useEffect(() => { @@ -121,6 +127,15 @@ const UsageSettings = () => { fetchStorageSettings(); }, []); + useEffect(() => { + if (!replayRetentionSettings?.data) { + return; + } + + setReplayRetentionEnabled(Boolean(replayRetentionSettings.data.enabled)); + setReplayRetentionDays(Number(replayRetentionSettings.data.days || 7)); + }, [replayRetentionSettings]); + const fetchStorageData = async () => { try { setLoading(true); @@ -365,6 +380,36 @@ const fetchStorageData = async () => { } }; + const handleSaveReplayRetention = () => { + const days = Math.max(1, Math.floor(Number(replayRetentionDays) || 1)); + const expectedVersion = Number(replayRetentionSettings?._meta?.version ?? 0); + + updateReplayRetentionSettings.mutate({ + expectedVersion, + enabled: replayRetentionEnabled, + days, + }, { + onSuccess: (envelope) => { + if (envelope?.data) { + setReplayRetentionEnabled(Boolean(envelope.data.enabled)); + setReplayRetentionDays(Number(envelope.data.days || days)); + } + + toast({ + title: t('toast.success'), + description: t('settings.usage.replayRetentionSaved'), + }); + }, + onError: (error: any) => { + toast({ + title: t('toast.error'), + description: error?.message || t('settings.usage.replayRetentionSaveFailed'), + variant: "destructive", + }); + }, + }); + }; + const getTypeColor = (type: string): string => { switch (type) { case 'ticket': return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; @@ -786,33 +831,67 @@ const fetchStorageData = async () => { - {t('settings.usage.systemStatus')} + + + {t('settings.usage.replayRetention')} + -
-
- {t('settings.usage.totalFiles')}: - {files.length} -
-
- {t('settings.usage.selected')}: - {selectedFiles.size} -
-
- {t('settings.usage.filtered')}: - {filteredAndSortedFiles.length} +
+ {replayRetentionError && ( +
+ {t('settings.usage.replayRetentionLoadFailed')} +
+ )} + +
+
+ +

+ {t('settings.usage.replayRetentionEnabledDesc')} +

+
+
-
- {t('settings.usage.canUpload')}: - - {storageUsage.quota?.canUpload ? t('common.yes') : t('common.no')} - + +
+ + setReplayRetentionDays(Number(event.target.value))} + disabled={!replayRetentionEnabled || isLoadingReplayRetention || updateReplayRetentionSettings.isPending} + /> +

+ {replayRetentionEnabled + ? t('settings.usage.replayRetentionDaysDesc') + : t('settings.usage.replayRetentionDisabledDesc')} +

-
- {t('settings.usage.aiAvailable')}: - - {storageUsage.isPremium && storageUsage.aiQuota ? t('common.yes') : t('common.no')} - + +
+ + {replayRetentionEnabled + ? t('settings.usage.replayRetentionActive', { days: Math.max(1, Math.floor(Number(replayRetentionDays) || 1)) }) + : t('settings.usage.replayRetentionOff')} + +
diff --git a/client/src/components/windows/PlayerWindow.tsx b/client/src/components/windows/PlayerWindow.tsx index 895eab1..e5e7852 100644 --- a/client/src/components/windows/PlayerWindow.tsx +++ b/client/src/components/windows/PlayerWindow.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Eye, TriangleAlert, History, Link2, StickyNote, Ticket, UserRound, Shield, FileText, Loader2, - ChevronDown, ChevronRight, Settings, Plus, X + ChevronDown, ChevronRight, Settings, Plus, X, Play } from 'lucide-react'; import { useLocation } from 'wouter'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -17,6 +17,7 @@ import { usePermissions } from '@/hooks/use-permissions'; import { toast } from '@modl-gg/shared-web/hooks/use-toast'; import PlayerPunishment, { PlayerPunishmentData } from '@/components/ui/player-punishment'; import MediaUpload from '@/components/MediaUpload'; +import PlayerReplaysList from '@/components/player-replays-list'; import { formatDateWithTime } from '@/utils/date-utils'; import { getAvatarUrl, apiFetch } from '@/lib/api'; import { formatTicketStatusLabel, normalizeTicketStatus } from '@/lib/ticket-enums'; @@ -1503,7 +1504,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind
- + {t('player.tabs.history')} @@ -1520,6 +1521,10 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind {t('player.tabs.tickets')} + + + {t('player.tabs.replays')} + {t('player.tabs.names')} @@ -2975,6 +2980,11 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind )} + +

{t('player.playerReplays')}

+ +
+

{t('player.previousNames')}

diff --git a/client/src/hooks/use-data.tsx b/client/src/hooks/use-data.tsx index 10d5487..b0493fa 100644 --- a/client/src/hooks/use-data.tsx +++ b/client/src/hooks/use-data.tsx @@ -18,6 +18,12 @@ function toSettingsEnvelope(payload: any): SettingsEnvelope { if (payload && typeof payload === 'object' && 'data' in payload) { return payload as SettingsEnvelope; } + if (payload && typeof payload === 'object' && '_data' in payload) { + return { + data: payload._data as T, + _meta: payload._meta || null, + }; + } return { data: payload as T, _meta: { @@ -27,6 +33,23 @@ function toSettingsEnvelope(payload: any): SettingsEnvelope { }; } +export interface ReplayRetentionSettings { + enabled: boolean; + days: number; +} + +export interface PlayerReplaySummary { + replayId: string; + targetUuid: string; + targetName: string; + mcVersion: string; + fileSize: number; + createdAt: string; + status: string; + replayUrl: string; + matchSource: string; +} + export function usePlayer(uuid: string) { return useQuery({ queryKey: ['/v1/panel/players', uuid], @@ -611,6 +634,50 @@ export function useTicketLabelSettings() { }); } +export function useReplayRetentionSettings() { + return useQuery | null>({ + queryKey: ['/v1/panel/settings/replay-retention'], + queryFn: async () => { + const res = await apiFetch('/v1/panel/settings/replay-retention'); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + return null; + } + throw new Error('Failed to fetch replay retention settings'); + } + return toSettingsEnvelope(await res.json()); + }, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false + }); +} + +export function useUpdateReplayRetentionSettings() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (settings: ReplayRetentionSettings & { expectedVersion: number }) => { + const res = await apiFetch('/v1/panel/settings/replay-retention', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText || 'Failed to update replay retention settings'); + } + + return toSettingsEnvelope(await res.json()); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/v1/panel/settings/replay-retention'] }); + }, + }); +} + export function useStats() { return useQuery({ queryKey: ['/v1/panel/stats'], @@ -822,6 +889,25 @@ export function usePlayerAllTickets(uuid: string) { }); } +export function usePlayerReplays(uuid: string) { + return useQuery({ + queryKey: ['/v1/panel/players', uuid, 'replays'], + queryFn: async () => { + const res = await apiFetch(`/v1/panel/players/${uuid}/replays`); + if (!res.ok) { + if (res.status === 404) { + return []; + } + throw new Error('Failed to fetch player replays'); + } + return res.json(); + }, + enabled: !!uuid, + staleTime: 30000, + refetchOnWindowFocus: true + }); +} + export function useModifyPunishment() { const { user } = useAuth(); diff --git a/client/src/locales/de.json b/client/src/locales/de.json index 4376509..0764879 100644 --- a/client/src/locales/de.json +++ b/client/src/locales/de.json @@ -751,6 +751,17 @@ "storageOverage": "Speicherüberschreitung", "costThisMonth": "Kosten diesen Monat", "storageRate": "$0,08 pro GB pro Monat", + "replayRetention": "Replay-Aufbewahrung", + "replayRetentionEnabled": "Automatische Replay-Bereinigung aktivieren", + "replayRetentionEnabledDesc": "Gespeicherte Replays nach der konfigurierten Aufbewahrungszeit automatisch löschen.", + "replayRetentionDays": "Aufbewahrungszeitraum (Tage)", + "replayRetentionDaysDesc": "Gespeicherte Replays, die älter als diese Anzahl von Tagen sind, können bereinigt werden.", + "replayRetentionDisabledDesc": "Automatische Replay-Bereinigung ist deaktiviert. Gespeicherte Replays bleiben erhalten, bis sie manuell gelöscht werden.", + "replayRetentionActive": "Replays werden nach {{days}} Tagen gelöscht", + "replayRetentionOff": "Automatische Bereinigung deaktiviert", + "replayRetentionSaved": "Replay-Aufbewahrungseinstellungen gespeichert.", + "replayRetentionSaveFailed": "Replay-Aufbewahrungseinstellungen konnten nicht gespeichert werden.", + "replayRetentionLoadFailed": "Replay-Aufbewahrungseinstellungen konnten nicht geladen werden.", "systemStatus": "Systemstatus", "totalFiles": "Dateien gesamt", "selected": "Ausgewählt", @@ -1386,6 +1397,7 @@ "tabConnectedShort": "Verknüpft", "tabNotes": "Notizen", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Namen", "tabPunish": "Bestrafen", "tabs": { @@ -1393,6 +1405,7 @@ "connected": "Verbunden", "notes": "Notizen", "tickets": "Tickets", + "replays": "Replays", "names": "Namen", "punish": "Bestrafen" }, @@ -1456,6 +1469,16 @@ "playerTickets": "Spieler-Tickets", "loadingTickets": "Tickets werden geladen...", "noTickets": "Keine Tickets für diesen Spieler gefunden.", + "playerReplays": "Spieler-Replays", + "loadingReplays": "Replays werden geladen...", + "noReplays": "Keine Replays für diesen Spieler gefunden.", + "replaysLoadFailed": "Replays für diesen Spieler konnten nicht geladen werden.", + "openReplay": "Replay öffnen", + "replayCreated": "Erstellt", + "replayMcVersion": "Minecraft", + "replaySize": "Größe", + "replayId": "Replay-ID", + "unknownPlayer": "Unbekannter Spieler", "previousNames": "Frühere Namen", "missingInfo": "Fehlende Informationen", "selectPunishmentType": "Bitte wähle einen Straftyp aus, bevor du ihn anwendest.", diff --git a/client/src/locales/en.json b/client/src/locales/en.json index ed6675f..453a41c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -758,6 +758,17 @@ "storageOverage": "Storage Overage", "costThisMonth": "Cost this month", "storageRate": "$0.08 per GB per month", + "replayRetention": "Replay Retention", + "replayRetentionEnabled": "Enable automatic replay cleanup", + "replayRetentionEnabledDesc": "Automatically delete stored replays after the configured retention period.", + "replayRetentionDays": "Retention period (days)", + "replayRetentionDaysDesc": "Stored replays older than this many days are eligible for cleanup.", + "replayRetentionDisabledDesc": "Automatic replay cleanup is disabled. Stored replays will be retained until manually deleted.", + "replayRetentionActive": "Deleting replays after {{days}} days", + "replayRetentionOff": "Automatic cleanup disabled", + "replayRetentionSaved": "Replay retention settings saved.", + "replayRetentionSaveFailed": "Failed to save replay retention settings.", + "replayRetentionLoadFailed": "Failed to load replay retention settings.", "systemStatus": "System Status", "totalFiles": "Total files", "selected": "Selected", @@ -1393,6 +1404,7 @@ "tabConnectedShort": "Linked", "tabNotes": "Notes", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Names", "tabPunish": "Punish", "tabs": { @@ -1400,6 +1412,7 @@ "connected": "Connected", "notes": "Notes", "tickets": "Tickets", + "replays": "Replays", "names": "Names", "punish": "Punish" }, @@ -1463,6 +1476,16 @@ "playerTickets": "Player Tickets", "loadingTickets": "Loading tickets...", "noTickets": "No tickets found for this player.", + "playerReplays": "Player Replays", + "loadingReplays": "Loading replays...", + "noReplays": "No replays found for this player.", + "replaysLoadFailed": "Failed to load replays for this player.", + "openReplay": "Open Replay", + "replayCreated": "Created", + "replayMcVersion": "Minecraft", + "replaySize": "Size", + "replayId": "Replay ID", + "unknownPlayer": "Unknown player", "previousNames": "Previous Names", "missingInfo": "Missing information", "selectPunishmentType": "Please select a punishment type before applying.", diff --git a/client/src/locales/es.json b/client/src/locales/es.json index c6f2222..6d587e8 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -751,6 +751,17 @@ "storageOverage": "Exceso de almacenamiento", "costThisMonth": "Costo este mes", "storageRate": "$0,08 por GB por mes", + "replayRetention": "Retención de repeticiones", + "replayRetentionEnabled": "Habilitar limpieza automática de repeticiones", + "replayRetentionEnabledDesc": "Eliminar automáticamente las repeticiones almacenadas después del periodo de retención configurado.", + "replayRetentionDays": "Periodo de retención (días)", + "replayRetentionDaysDesc": "Las repeticiones almacenadas con más de esta cantidad de días podrán limpiarse.", + "replayRetentionDisabledDesc": "La limpieza automática de repeticiones está deshabilitada. Las repeticiones almacenadas se conservarán hasta que se eliminen manualmente.", + "replayRetentionActive": "Eliminando repeticiones después de {{days}} días", + "replayRetentionOff": "Limpieza automática deshabilitada", + "replayRetentionSaved": "Configuración de retención de repeticiones guardada.", + "replayRetentionSaveFailed": "No se pudo guardar la configuración de retención de repeticiones.", + "replayRetentionLoadFailed": "No se pudo cargar la configuración de retención de repeticiones.", "systemStatus": "Estado del sistema", "totalFiles": "Total de archivos", "selected": "Seleccionados", @@ -1386,6 +1397,7 @@ "tabConnectedShort": "Vinculado", "tabNotes": "Notas", "tabTickets": "Tickets", + "tabReplays": "Repeticiones", "tabNames": "Nombres", "tabPunish": "Sancionar", "tabs": { @@ -1393,6 +1405,7 @@ "connected": "Conectado", "notes": "Notas", "tickets": "Tickets", + "replays": "Repeticiones", "names": "Nombres", "punish": "Sancionar" }, @@ -1456,6 +1469,16 @@ "playerTickets": "Tickets del jugador", "loadingTickets": "Cargando tickets...", "noTickets": "No se encontraron tickets para este jugador.", + "playerReplays": "Repeticiones del jugador", + "loadingReplays": "Cargando repeticiones...", + "noReplays": "No se encontraron repeticiones para este jugador.", + "replaysLoadFailed": "No se pudieron cargar las repeticiones de este jugador.", + "openReplay": "Abrir repetición", + "replayCreated": "Creada", + "replayMcVersion": "Minecraft", + "replaySize": "Tamaño", + "replayId": "ID de repetición", + "unknownPlayer": "Jugador desconocido", "previousNames": "Nombres anteriores", "missingInfo": "Información faltante", "selectPunishmentType": "Por favor selecciona un tipo de sanción antes de aplicar.", diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json index 0649791..cf32f49 100644 --- a/client/src/locales/nl.json +++ b/client/src/locales/nl.json @@ -751,6 +751,17 @@ "storageOverage": "Opslagoverschrijding", "costThisMonth": "Kosten deze maand", "storageRate": "$0,08 per GB per maand", + "replayRetention": "Replaybewaring", + "replayRetentionEnabled": "Automatische replay-opruiming inschakelen", + "replayRetentionEnabledDesc": "Verwijder opgeslagen replays automatisch na de ingestelde bewaartermijn.", + "replayRetentionDays": "Bewaartermijn (dagen)", + "replayRetentionDaysDesc": "Opgeslagen replays ouder dan dit aantal dagen komen in aanmerking voor opruiming.", + "replayRetentionDisabledDesc": "Automatische replay-opruiming is uitgeschakeld. Opgeslagen replays blijven bewaard totdat ze handmatig worden verwijderd.", + "replayRetentionActive": "Replays worden na {{days}} dagen verwijderd", + "replayRetentionOff": "Automatische opruiming uitgeschakeld", + "replayRetentionSaved": "Replaybewaarinstellingen opgeslagen.", + "replayRetentionSaveFailed": "Replaybewaarinstellingen opslaan mislukt.", + "replayRetentionLoadFailed": "Replaybewaarinstellingen laden mislukt.", "systemStatus": "Systeemstatus", "totalFiles": "Totaal bestanden", "selected": "Geselecteerd", @@ -1386,6 +1397,7 @@ "tabConnectedShort": "Gekoppeld", "tabNotes": "Notities", "tabTickets": "Tickets", + "tabReplays": "Replays", "tabNames": "Namen", "tabPunish": "Bestraffen", "tabs": { @@ -1393,6 +1405,7 @@ "connected": "Verbonden", "notes": "Notities", "tickets": "Tickets", + "replays": "Replays", "names": "Namen", "punish": "Bestraffen" }, @@ -1456,6 +1469,16 @@ "playerTickets": "Spelertickets", "loadingTickets": "Tickets laden...", "noTickets": "Geen tickets gevonden voor deze speler.", + "playerReplays": "Spelerreplays", + "loadingReplays": "Replays laden...", + "noReplays": "Geen replays gevonden voor deze speler.", + "replaysLoadFailed": "Replays voor deze speler laden mislukt.", + "openReplay": "Replay openen", + "replayCreated": "Gemaakt", + "replayMcVersion": "Minecraft", + "replaySize": "Grootte", + "replayId": "Replay-ID", + "unknownPlayer": "Onbekende speler", "previousNames": "Vorige namen", "missingInfo": "Ontbrekende informatie", "selectPunishmentType": "Selecteer een straftype voordat je het toepast.", diff --git a/client/src/pages/player-detail-page.tsx b/client/src/pages/player-detail-page.tsx index b908d03..d6871ab 100644 --- a/client/src/pages/player-detail-page.tsx +++ b/client/src/pages/player-detail-page.tsx @@ -4,7 +4,7 @@ import { useRoute, useLocation } from 'wouter'; import { ArrowLeft, Ban, RefreshCcw, Search, History, Link2, StickyNote, Ticket, UserRound, Shield, FileText, Upload, Loader2, - ChevronDown, ChevronRight, Settings, Plus, X + ChevronDown, ChevronRight, Settings, Plus, X, Play } from 'lucide-react'; import { getAvatarUrl } from '@/lib/api'; import { Button } from '@modl-gg/shared-web/components/ui/button'; @@ -19,6 +19,7 @@ import { toast } from '@modl-gg/shared-web/hooks/use-toast'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@modl-gg/shared-web/components/ui/select'; import PlayerPunishment, { PlayerPunishmentData } from '@/components/ui/player-punishment'; import MediaUpload from '@/components/MediaUpload'; +import PlayerReplaysList from '@/components/player-replays-list'; import { formatDateWithTime } from '@/utils/date-utils'; import { apiFetch } from '@/lib/api'; import { formatTicketStatusLabel, normalizeTicketStatus } from '@/lib/ticket-enums'; @@ -1232,12 +1233,13 @@ const PlayerDetailPage = () => { {t('player.tabConnected')} {t('player.tabNotes')} {t('player.tabTickets')} + {t('player.tabReplays')} {t('player.tabNames')} {t('player.tabPunish')} ) : ( - + {t('player.tabHistory')} @@ -1254,6 +1256,10 @@ const PlayerDetailPage = () => { {t('player.tabTickets')} + + + {t('player.tabReplays')} + {t('player.tabNames')} @@ -2565,6 +2571,11 @@ const PlayerDetailPage = () => {
+ +

{t('player.playerReplays')}

+ +
+

Previous Names

From e48cdb8738f5c7f192414a192645d8e3663aff2e Mon Sep 17 00:00:00 2001 From: byteful Date: Thu, 21 May 2026 05:02:02 -0500 Subject: [PATCH 2/2] fix --- client/src/components/player-replays-list.tsx | 2 +- .../src/components/settings/UsageSettings.tsx | 38 ++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/client/src/components/player-replays-list.tsx b/client/src/components/player-replays-list.tsx index 9d247b7..3fecf2e 100644 --- a/client/src/components/player-replays-list.tsx +++ b/client/src/components/player-replays-list.tsx @@ -38,7 +38,7 @@ const getReplayIdFromReference = (replayUrl?: string) => { const getReplayId = (replay: { replayId?: string; replayUrl?: string; matchSource?: string }) => { if (replay.matchSource === 'TICKET_FALLBACK') { - return getReplayIdFromReference(replay.replayUrl); + return getReplayIdFromReference(replay.replayUrl) || replay.replayId || ''; } return replay.replayId || getReplayIdFromReference(replay.replayUrl); diff --git a/client/src/components/settings/UsageSettings.tsx b/client/src/components/settings/UsageSettings.tsx index cf33e51..c298975 100644 --- a/client/src/components/settings/UsageSettings.tsx +++ b/client/src/components/settings/UsageSettings.tsx @@ -381,7 +381,7 @@ const fetchStorageData = async () => { }; const handleSaveReplayRetention = () => { - const days = Math.max(1, Math.floor(Number(replayRetentionDays) || 1)); + const days = Math.min(365, Math.max(1, Math.floor(Number(replayRetentionDays) || 1))); const expectedVersion = Number(replayRetentionSettings?._meta?.version ?? 0); updateReplayRetentionSettings.mutate({ @@ -888,7 +888,7 @@ const fetchStorageData = async () => { @@ -896,6 +896,40 @@ const fetchStorageData = async () => {
+ + + + {t('settings.usage.systemStatus')} + + +
+
+ {t('settings.usage.totalFiles')}: + {files.length} +
+
+ {t('settings.usage.selected')}: + {selectedFiles.size} +
+
+ {t('settings.usage.filtered')}: + {filteredAndSortedFiles.length} +
+
+ {t('settings.usage.canUpload')}: + + {storageUsage.quota?.canUpload ? t('common.yes') : t('common.no')} + +
+
+ {t('settings.usage.aiAvailable')}: + + {storageUsage.isPremium && storageUsage.aiQuota ? t('common.yes') : t('common.no')} + +
+
+
+
)}