diff --git a/client/src/components/player-replays-list.tsx b/client/src/components/player-replays-list.tsx
new file mode 100644
index 0000000..3fecf2e
--- /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) || replay.replayId || '';
+ }
+
+ 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..c298975 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.min(365, 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';
@@ -784,6 +829,74 @@ const fetchStorageData = async () => {
)}
+
+
+
+
+ {t('settings.usage.replayRetention')}
+
+
+
+
+ {replayRetentionError && (
+
+ {t('settings.usage.replayRetentionLoadFailed')}
+
+ )}
+
+
+
+
+
+ {t('settings.usage.replayRetentionEnabledDesc')}
+
+
+
+
+
+
+
+
setReplayRetentionDays(Number(event.target.value))}
+ disabled={!replayRetentionEnabled || isLoadingReplayRetention || updateReplayRetentionSettings.isPending}
+ />
+
+ {replayRetentionEnabled
+ ? t('settings.usage.replayRetentionDaysDesc')
+ : t('settings.usage.replayRetentionDisabledDesc')}
+
+
+
+
+
+ {replayRetentionEnabled
+ ? t('settings.usage.replayRetentionActive', { days: Math.max(1, Math.floor(Number(replayRetentionDays) || 1)) })
+ : t('settings.usage.replayRetentionOff')}
+
+
+
+
+
+
+
{t('settings.usage.systemStatus')}
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