Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions client/src/components/player-replays-list.tsx
Original file line number Diff line number Diff line change
@@ -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);
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.

const PlayerReplaysList = ({ playerId }: PlayerReplaysListProps) => {
const { t } = useTranslation();
const [, navigate] = useLocation();
const { data: replays, isLoading, error } = usePlayerReplays(playerId);

if (isLoading) {
return (
<div className="bg-muted/30 p-3 rounded-lg flex items-center justify-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm">{t('player.loadingReplays')}</span>
</div>
);
}

if (error) {
return (
<div className="bg-destructive/10 border border-destructive/20 p-3 rounded-lg flex items-center gap-2 text-destructive">
<TriangleAlert className="h-4 w-4" />
<span className="text-sm">{t('player.replaysLoadFailed')}</span>
</div>
);
}

if (!replays || replays.length === 0) {
return (
<div className="bg-muted/30 p-3 rounded-lg">
<p className="text-sm text-muted-foreground">{t('player.noReplays')}</p>
</div>
);
}

return (
<div className="space-y-2">
{replays.map((replay) => {
const replayId = getReplayId(replay);
const canOpenReplay = Boolean(replayId)
&& (replay.matchSource === 'TICKET_FALLBACK' || replay.status === 'COMPLETE');

return (
<div key={replay.replayId || replay.replayUrl} className="bg-muted/30 p-3 rounded-lg">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<Play className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-sm truncate">
{replay.targetName || replay.targetUuid || t('player.unknownPlayer')}
</span>
{replay.status && (
<Badge variant="outline" className="text-xs">
{replay.status}
</Badge>
)}
{replay.matchSource && (
<Badge variant="secondary" className="text-xs">
{replay.matchSource}
</Badge>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>{t('player.replayCreated')}: {replay.createdAt ? formatDateWithTime(replay.createdAt) : t('common.unknown')}</span>
<span>{t('player.replayMcVersion')}: {replay.mcVersion || t('common.unknown')}</span>
<span>{t('player.replaySize')}: {typeof replay.fileSize === 'number' ? formatFileSize(replay.fileSize) : t('common.unknown')}</span>
<span className="truncate">{t('player.replayId')}: {replayId || t('common.unknown')}</span>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/replay?id=${encodeURIComponent(replayId)}`)}
disabled={!canOpenReplay}
>
<Play className="h-3.5 w-3.5 mr-1.5" />
{t('player.openReplay')}
</Button>
</div>
</div>
);
})}
</div>
);
};

export default PlayerReplaysList;
113 changes: 113 additions & 0 deletions client/src/components/settings/UsageSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -114,13 +116,26 @@ const UsageSettings = () => {
const [newOverageLimit, setNewOverageLimit] = useState<number>(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(() => {
fetchStorageData();
fetchStorageSettings();
}, []);

useEffect(() => {
if (!replayRetentionSettings?.data) {
return;
}

setReplayRetentionEnabled(Boolean(replayRetentionSettings.data.enabled));
setReplayRetentionDays(Number(replayRetentionSettings.data.days || 7));
}, [replayRetentionSettings]);

const fetchStorageData = async () => {
try {
setLoading(true);
Expand Down Expand Up @@ -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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}, {
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';
Expand Down Expand Up @@ -784,6 +829,74 @@ const fetchStorageData = async () => {
</Card>
)}

<Card className="rounded-card shadow-card-inner bg-surface-2">
<CardHeader>
<CardTitle className="flex items-center">
<Play className="h-5 w-5 mr-2" />
{t('settings.usage.replayRetention')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{replayRetentionError && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive rounded-lg p-3 text-sm">
{t('settings.usage.replayRetentionLoadFailed')}
</div>
)}

<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="replay-retention-enabled" className="text-sm font-medium">
{t('settings.usage.replayRetentionEnabled')}
</Label>
<p className="text-xs text-muted-foreground mt-1">
{t('settings.usage.replayRetentionEnabledDesc')}
</p>
</div>
<Switch
id="replay-retention-enabled"
checked={replayRetentionEnabled}
onCheckedChange={setReplayRetentionEnabled}
disabled={isLoadingReplayRetention || updateReplayRetentionSettings.isPending}
/>
</div>

<div className="space-y-2">
<Label htmlFor="replay-retention-days">{t('settings.usage.replayRetentionDays')}</Label>
<Input
id="replay-retention-days"
type="number"
min="1"
max="365"
value={replayRetentionDays}
onChange={(event) => setReplayRetentionDays(Number(event.target.value))}
disabled={!replayRetentionEnabled || isLoadingReplayRetention || updateReplayRetentionSettings.isPending}
/>
<p className="text-xs text-muted-foreground">
{replayRetentionEnabled
? t('settings.usage.replayRetentionDaysDesc')
: t('settings.usage.replayRetentionDisabledDesc')}
</p>
</div>

<div className="flex items-center justify-between gap-3">
<Badge variant="outline">
{replayRetentionEnabled
? t('settings.usage.replayRetentionActive', { days: Math.max(1, Math.floor(Number(replayRetentionDays) || 1)) })
: t('settings.usage.replayRetentionOff')}
</Badge>
<Button
size="sm"
onClick={handleSaveReplayRetention}
disabled={isLoadingReplayRetention || updateReplayRetentionSettings.isPending || Boolean(replayRetentionError) || !replayRetentionSettings?.data}
>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
{updateReplayRetentionSettings.isPending ? t('common.saving') : t('settings.usage.saveSettings')}
</Button>
</div>
</div>
</CardContent>
</Card>

<Card className="rounded-card shadow-card-inner bg-surface-2">
<CardHeader>
<CardTitle>{t('settings.usage.systemStatus')}</CardTitle>
Expand Down
14 changes: 12 additions & 2 deletions client/src/components/windows/PlayerWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -1503,7 +1504,7 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind
</div>

<Tabs defaultValue="history" className="w-full" onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-6 gap-1 px-1">
<TabsList className="grid grid-cols-7 gap-1 px-1">
<TabsTrigger value="history" className="text-xs py-2">
<History className="h-3.5 w-3.5 mr-1.5" />
{t('player.tabs.history')}
Expand All @@ -1520,6 +1521,10 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind
<Ticket className="h-3.5 w-3.5 mr-1.5" />
{t('player.tabs.tickets')}
</TabsTrigger>
<TabsTrigger value="replays" className="text-xs py-2">
<Play className="h-3.5 w-3.5 mr-1.5" />
{t('player.tabs.replays')}
</TabsTrigger>
<TabsTrigger value="names" className="text-xs py-2">
<UserRound className="h-3.5 w-3.5 mr-1.5" />
{t('player.tabs.names')}
Expand Down Expand Up @@ -2975,6 +2980,11 @@ const PlayerWindow = ({ playerId, isOpen, onClose, initialPosition }: PlayerWind
)}
</TabsContent>

<TabsContent value="replays" className="space-y-2 mx-1 mt-3">
<h4 className="font-medium">{t('player.playerReplays')}</h4>
<PlayerReplaysList playerId={playerId} />
</TabsContent>

<TabsContent value="names" className="space-y-2 mx-1 mt-3">
<h4 className="font-medium">{t('player.previousNames')}</h4>
<div className="bg-muted/30 p-3 rounded-lg">
Expand Down
Loading
Loading