diff --git a/src/app/dashboard/server/[id]/page.tsx b/src/app/dashboard/server/[id]/page.tsx index b27480b..f93150d 100644 --- a/src/app/dashboard/server/[id]/page.tsx +++ b/src/app/dashboard/server/[id]/page.tsx @@ -4,6 +4,7 @@ import { getServerConfigurations } from '@/lib/api/server/configuration'; import { ServerConfigurationTabs } from '@/components/server/ServerConfigurationTabs'; import { ServerHeader } from '@/components/server/ServerHeader'; import { getServerStatistics } from '@/lib/api/server/statistics'; +import { getLeaderboard } from '@/lib/api/server/leaderboard'; import { subDays, formatISO } from 'date-fns'; import { UTCDate } from '@date-fns/utc'; @@ -19,10 +20,11 @@ export default async function ServerPage({ params }: ServerPageProps) { const endDate = formatISO(today); const startDate = formatISO(subDays(today, 30)); - const [server, configurations, statistics] = await Promise.all([ + const [server, configurations, statistics, leaderboard] = await Promise.all([ getServer(session.token!, id), getServerConfigurations(session.token!, id), - getServerStatistics(session.token!, id, { startDate, endDate }) + getServerStatistics(session.token!, id, { startDate, endDate }), + getLeaderboard(session.token!, id) ]); return ( @@ -35,6 +37,7 @@ export default async function ServerPage({ params }: ServerPageProps) { serverId={id} configurations={configurations} statistics={statistics} + leaderboard={leaderboard} /> diff --git a/src/components/configuration/EventConfigEditor.tsx b/src/components/configuration/EventConfigEditor.tsx index dbbd29c..8d2d8ae 100644 --- a/src/components/configuration/EventConfigEditor.tsx +++ b/src/components/configuration/EventConfigEditor.tsx @@ -114,6 +114,7 @@ export function EventConfigEditor({ serverId, config }: EventConfigEditorProps) + diff --git a/src/components/leaderboard/LeaderboardManager.tsx b/src/components/leaderboard/LeaderboardManager.tsx new file mode 100644 index 0000000..e35fdac --- /dev/null +++ b/src/components/leaderboard/LeaderboardManager.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { Leaderboard, LeaderboardDriver } from '@/lib/schemas/leaderboard'; +import { saveLeaderboardAction } from '@/lib/actions/leaderboard'; +import { LeaderboardStandings } from './LeaderboardStandings'; +import { DriversEditor } from './editors/DriversEditor'; +import { PointsTableEditor } from './editors/PointsTableEditor'; +import { FlPointsEditor } from './editors/FlPointsEditor'; +import { TracksEditor } from './editors/TracksEditor'; + +type Section = 'standings' | 'drivers' | 'points' | 'fl' | 'tracks'; + +const sections: { id: Section; label: string }[] = [ + { id: 'standings', label: 'Standings' }, + { id: 'drivers', label: 'Drivers' }, + { id: 'points', label: 'Points Table' }, + { id: 'fl', label: 'FL Points' }, + { id: 'tracks', label: 'Tracks' } +]; + +interface LeaderboardManagerProps { + serverId: string; + initialData: Leaderboard; +} + +function isDirty(a: Leaderboard, b: Leaderboard) { + return JSON.stringify(a) !== JSON.stringify(b); +} + +export function LeaderboardManager({ serverId, initialData }: LeaderboardManagerProps) { + const [draft, setDraft] = useState(initialData); + const [saved, setSaved] = useState(initialData); + const [activeSection, setActiveSection] = useState
('standings'); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const dirty = isDirty(draft, saved); + + const handleDriversChange = (newDrivers: LeaderboardDriver[]) => { + const oldDrivers = draft.drivers; + + // Build index map: for each position in newDrivers, what was the old index? + const indexMap = newDrivers.map((d) => oldDrivers.indexOf(d)); + + // If a driver was added (indexOf returns -1), it's new + const newTracks = draft.tracks.map((t) => ({ + ...t, + results: indexMap.map((i) => (i === -1 ? 0 : (t.results[i] ?? 0))) + })); + + setDraft({ ...draft, drivers: newDrivers, tracks: newTracks }); + }; + + const handleSave = async () => { + setIsSubmitting(true); + setError(null); + try { + const result = await saveLeaderboardAction(serverId, draft); + if (result.success) { + setSaved(draft); + } else { + setError(result.message ?? 'Unknown error'); + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to save'); + } finally { + setIsSubmitting(false); + } + }; + + const renderSection = () => { + switch (activeSection) { + case 'standings': + return ; + case 'drivers': + return ; + case 'points': + return ( + setDraft({ ...draft, pointsTable })} + /> + ); + case 'fl': + return ( + setDraft({ ...draft, flPoints })} + /> + ); + case 'tracks': + return ( + setDraft({ ...draft, tracks })} + /> + ); + } + }; + + return ( +
+ {dirty && ( +
+ Unsaved changes +
+ + +
+
+ )} + + {error && ( +
{error}
+ )} + +
+ {sections.map((s) => ( + + ))} +
+ +
{renderSection()}
+ + {!dirty && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/leaderboard/LeaderboardStandings.tsx b/src/components/leaderboard/LeaderboardStandings.tsx new file mode 100644 index 0000000..738fca2 --- /dev/null +++ b/src/components/leaderboard/LeaderboardStandings.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { useMemo } from 'react'; +import { Leaderboard, StandingsRow } from '@/lib/schemas/leaderboard'; + +interface LeaderboardStandingsProps { + leaderboard: Leaderboard; +} + +export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps) { + const standings = useMemo(() => { + return leaderboard.drivers + .map((driver, idx) => { + const trackResults = leaderboard.tracks.map((t) => t.results[idx] ?? 0); + const fastestLapCount = leaderboard.tracks.filter( + (t) => t.fastestLapInitials === driver.initials + ).length; + const totalPoints = + trackResults.reduce((sum, r) => sum + (typeof r === 'number' ? r : 0), 0) + + fastestLapCount * leaderboard.flPoints.points; + return { driver, driverIndex: idx, totalPoints, trackResults, fastestLapCount }; + }) + .sort((a, b) => b.totalPoints - a.totalPoints); + }, [leaderboard]); + + if (leaderboard.drivers.length === 0) { + return ( +
+
πŸ†
+

No drivers yet. Add drivers to see standings.

+
+ ); + } + + return ( +
+ + + + + + {leaderboard.tracks.map((track, i) => ( + + ))} + + + + + + {standings.map((row, pos) => ( + + + + {row.trackResults.map((result, ti) => { + const track = leaderboard.tracks[ti]; + const isFl = track.fastestLapInitials === row.driver.initials; + return ( + + ); + })} + + + + ))} + +
PosDriver + {track.name} + FLPts
{pos + 1} +
+
+ {row.driver.name} + {row.driver.initials} +
+
+ + {result} + {isFl && ( + + FL + + )} + + + {row.fastestLapCount > 0 ? `Γ—${row.fastestLapCount}` : 'β€”'} + + {row.totalPoints} +
+
+ ); +} diff --git a/src/components/leaderboard/editors/DriversEditor.tsx b/src/components/leaderboard/editors/DriversEditor.tsx new file mode 100644 index 0000000..0a86a83 --- /dev/null +++ b/src/components/leaderboard/editors/DriversEditor.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { LeaderboardDriver } from '@/lib/schemas/leaderboard'; + +function randomColor(): string { + return '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'); +} + +interface DriversEditorProps { + drivers: LeaderboardDriver[]; + onChange: (drivers: LeaderboardDriver[]) => void; +} + +export function DriversEditor({ drivers, onChange }: DriversEditorProps) { + const update = (index: number, patch: Partial) => { + const next = drivers.map((d, i) => (i === index ? { ...d, ...patch } : d)); + onChange(next); + }; + + const moveUp = (index: number) => { + if (index === 0) return; + const next = [...drivers]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + onChange(next); + }; + + const moveDown = (index: number) => { + if (index === drivers.length - 1) return; + const next = [...drivers]; + [next[index], next[index + 1]] = [next[index + 1], next[index]]; + onChange(next); + }; + + const remove = (index: number) => { + onChange(drivers.filter((_, i) => i !== index)); + }; + + const add = () => { + onChange([...drivers, { name: 'New Driver', initials: 'ND', color: randomColor() }]); + }; + + return ( +
+ {drivers.map((driver, i) => ( +
+
+ + +
+ +
+
+ + update(i, { name: e.target.value })} + className="rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ + update(i, { initials: e.target.value })} + className="w-24 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ +
+ update(i, { color: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent" + /> + { + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) { + update(i, { color: e.target.value }); + } + }} + className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white" + /> +
+
+
+ + +
+ ))} + + +
+ ); +} diff --git a/src/components/leaderboard/editors/FlPointsEditor.tsx b/src/components/leaderboard/editors/FlPointsEditor.tsx new file mode 100644 index 0000000..cafa05d --- /dev/null +++ b/src/components/leaderboard/editors/FlPointsEditor.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { FlPoints } from '@/lib/schemas/leaderboard'; + +interface FlPointsEditorProps { + flPoints: FlPoints; + onChange: (flPoints: FlPoints) => void; +} + +export function FlPointsEditor({ flPoints, onChange }: FlPointsEditorProps) { + const update = (patch: Partial) => onChange({ ...flPoints, ...patch }); + + return ( +
+

+ Points awarded to the driver who sets the fastest lap in each race. +

+
+
+ + update({ label: e.target.value })} + className="w-24 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ + update({ points: parseInt(e.target.value) || 0 })} + className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ + update({ priority: parseInt(e.target.value) || 0 })} + className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ +
+ update({ color: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent" + /> + { + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update({ color: e.target.value }); + }} + className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white" + /> +
+
+
+ +
+ update({ textColor: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent" + /> + { + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update({ textColor: e.target.value }); + }} + className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white" + /> +
+
+
+ + + {flPoints.label} + +
+
+
+ ); +} diff --git a/src/components/leaderboard/editors/PointsTableEditor.tsx b/src/components/leaderboard/editors/PointsTableEditor.tsx new file mode 100644 index 0000000..f298462 --- /dev/null +++ b/src/components/leaderboard/editors/PointsTableEditor.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { PointsEntry } from '@/lib/schemas/leaderboard'; + +interface PointsTableEditorProps { + pointsTable: PointsEntry[]; + onChange: (pointsTable: PointsEntry[]) => void; +} + +export function PointsTableEditor({ pointsTable, onChange }: PointsTableEditorProps) { + const update = (index: number, patch: Partial) => { + onChange(pointsTable.map((e, i) => (i === index ? { ...e, ...patch } : e))); + }; + + const remove = (index: number) => { + onChange(pointsTable.filter((_, i) => i !== index)); + }; + + const add = () => { + const pos = pointsTable.length + 1; + onChange([ + ...pointsTable, + { points: 0, label: `P${pos}`, color: '#374151', textColor: '#ffffff', priority: pos } + ]); + }; + + return ( +
+ {pointsTable.map((entry, i) => ( +
+
+
+ + update(i, { label: e.target.value })} + className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ + update(i, { points: parseInt(e.target.value) || 0 })} + className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ + update(i, { priority: parseInt(e.target.value) || 0 })} + className="w-20 rounded bg-gray-600 px-2 py-1 text-sm text-white" + /> +
+
+ +
+ update(i, { color: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent" + /> + { + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) update(i, { color: e.target.value }); + }} + className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white" + /> +
+
+
+ +
+ update(i, { textColor: e.target.value })} + className="h-8 w-8 cursor-pointer rounded border-0 bg-transparent" + /> + { + if (/^#[0-9a-fA-F]{6}$/.test(e.target.value)) + update(i, { textColor: e.target.value }); + }} + className="w-24 rounded bg-gray-600 px-2 py-1 font-mono text-sm text-white" + /> +
+
+
+ + + {entry.label} + +
+
+ + +
+ ))} + + +
+ ); +} diff --git a/src/components/leaderboard/editors/TracksEditor.tsx b/src/components/leaderboard/editors/TracksEditor.tsx new file mode 100644 index 0000000..5452c72 --- /dev/null +++ b/src/components/leaderboard/editors/TracksEditor.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { LeaderboardDriver, LeaderboardTrack, PointsEntry, ResultValue } from '@/lib/schemas/leaderboard'; + +interface ResultInputProps { + value: ResultValue; + pointsTable: PointsEntry[]; + onChange: (value: ResultValue) => void; +} + +function ResultInput({ value, pointsTable, onChange }: ResultInputProps) { + const knownPoints = Array.from(new Set(pointsTable.map((e) => e.points))).sort((a, b) => b - a); + + const handleChange = (raw: string) => { + if (raw === 'DNF') return onChange('DNF'); + if (raw === 'DNS') return onChange('DNS'); + onChange(parseInt(raw) || 0); + }; + + return ( + + ); +} + +interface TracksEditorProps { + tracks: LeaderboardTrack[]; + drivers: LeaderboardDriver[]; + pointsTable: PointsEntry[]; + onChange: (tracks: LeaderboardTrack[]) => void; +} + +export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksEditorProps) { + const updateTrack = (index: number, patch: Partial) => { + onChange(tracks.map((t, i) => (i === index ? { ...t, ...patch } : t))); + }; + + const updateResult = (trackIndex: number, driverIndex: number, value: ResultValue) => { + const newResults = [...tracks[trackIndex].results]; + newResults[driverIndex] = value; + updateTrack(trackIndex, { results: newResults }); + }; + + const removeTrack = (index: number) => { + onChange(tracks.filter((_, i) => i !== index)); + }; + + const addTrack = () => { + onChange([ + ...tracks, + { + name: `Race ${tracks.length + 1}`, + results: drivers.map(() => 'DNS' as const), + fastestLapInitials: '' + } + ]); + }; + + return ( +
+ {tracks.map((track, ti) => ( +
+
+ updateTrack(ti, { name: e.target.value })} + className="flex-1 rounded bg-gray-600 px-2 py-1 text-sm font-semibold text-white" + placeholder="Track name" + /> +
+ + +
+ +
+ + {drivers.length === 0 ? ( +

Add drivers first to enter results.

+ ) : ( +
+ {drivers.map((driver, di) => ( +
+
+
+ {driver.name} +
+
+ updateResult(ti, di, v)} + /> +
+
+ ))} +
+ )} +
+ ))} + + +
+ ); +} diff --git a/src/components/server/ServerConfigurationTabs.tsx b/src/components/server/ServerConfigurationTabs.tsx index 7aafae2..499c825 100644 --- a/src/components/server/ServerConfigurationTabs.tsx +++ b/src/components/server/ServerConfigurationTabs.tsx @@ -7,13 +7,16 @@ import { EventConfigEditor } from '@/components/configuration/EventConfigEditor' import { EventRulesEditor } from '@/components/configuration/EventRulesEditor'; import { ServerSettingsEditor } from '@/components/configuration/ServerSettingsEditor'; import { StatisticsDashboard } from '@/components/statistics/StatisticsDashboard'; +import { LeaderboardManager } from '@/components/leaderboard/LeaderboardManager'; import { useState } from 'react'; import { StateHistoryStats } from '@/lib/schemas'; +import { Leaderboard } from '@/lib/schemas/leaderboard'; interface ServerConfigurationTabsProps { serverId: string; configurations: Configurations; statistics: StateHistoryStats; + leaderboard: Leaderboard; } const tabs = [ { id: ServerTab.statistics, name: 'Statistics', icon: 'πŸ“Š' }, @@ -21,13 +24,15 @@ const tabs = [ { id: ServerTab.assistRules, name: 'Assist Rules', icon: 'πŸš—' }, { id: ServerTab.event, name: 'Event Config', icon: '🏁' }, { id: ServerTab.eventRules, name: 'Event Rules', icon: 'πŸ“‹' }, - { id: ServerTab.settings, name: 'Server Settings', icon: 'πŸ”§' } + { id: ServerTab.settings, name: 'Server Settings', icon: 'πŸ”§' }, + { id: ServerTab.leaderboard, name: 'Leaderboard', icon: 'πŸ†' } ]; export function ServerConfigurationTabs({ serverId, configurations, - statistics + statistics, + leaderboard }: ServerConfigurationTabsProps) { const [currentTab, setCurrentTab] = useState(ServerTab.statistics); @@ -51,6 +56,9 @@ export function ServerConfigurationTabs({ case ServerTab.settings: return ; + case ServerTab.leaderboard: + return ; + default: return (
diff --git a/src/lib/actions/leaderboard.ts b/src/lib/actions/leaderboard.ts new file mode 100644 index 0000000..0c37a5a --- /dev/null +++ b/src/lib/actions/leaderboard.ts @@ -0,0 +1,24 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { requireAuth } from '@/lib/auth/server'; +import { fetchServerAPI } from '@/lib/api/server/base'; +import { leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard'; + +export async function saveLeaderboardAction(serverId: string, data: Leaderboard) { + try { + const session = await requireAuth(); + const validated = leaderboardSchema.safeParse(data); + if (!validated.success) { + return { success: false, message: validated.error.message }; + } + await fetchServerAPI(`/server/${serverId}/leaderboard`, session.token!, 'PUT', validated.data); + revalidatePath(`/dashboard/server/${serverId}`); + return { success: true, message: 'Leaderboard saved successfully' }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : 'Failed to save leaderboard' + }; + } +} diff --git a/src/lib/api/server/leaderboard.ts b/src/lib/api/server/leaderboard.ts new file mode 100644 index 0000000..56238ed --- /dev/null +++ b/src/lib/api/server/leaderboard.ts @@ -0,0 +1,8 @@ +import { fetchServerAPI } from './base'; +import { emptyLeaderboard, leaderboardSchema, type Leaderboard } from '@/lib/schemas/leaderboard'; + +export async function getLeaderboard(token: string, serverId: string): Promise { + const response = await fetchServerAPI(`/server/${serverId}/leaderboard`, token); + const parsed = leaderboardSchema.safeParse(response.data); + return parsed.success ? parsed.data : emptyLeaderboard; +} diff --git a/src/lib/schemas/config.ts b/src/lib/schemas/config.ts index 40748af..76b9a69 100644 --- a/src/lib/schemas/config.ts +++ b/src/lib/schemas/config.ts @@ -15,7 +15,8 @@ export enum ServerTab { assistRules = 'assistRules', event = 'event', eventRules = 'eventRules', - settings = 'settings' + settings = 'settings', + leaderboard = 'leaderboard' } export const serverTabSchema = z.enum(ServerTab); diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts index 1881dc0..fd9100e 100644 --- a/src/lib/schemas/index.ts +++ b/src/lib/schemas/index.ts @@ -3,3 +3,4 @@ export * from './user'; export * from './config'; export * from './statistics'; export * from './lookups'; +export * from './leaderboard'; diff --git a/src/lib/schemas/leaderboard.ts b/src/lib/schemas/leaderboard.ts new file mode 100644 index 0000000..163965c --- /dev/null +++ b/src/lib/schemas/leaderboard.ts @@ -0,0 +1,56 @@ +import * as z from 'zod'; + +export const resultValueSchema = z.union([z.literal('DNF'), z.literal('DNS'), z.coerce.number().int().min(0)]); +export type ResultValue = z.infer; + +export const leaderboardDriverSchema = z.object({ + name: z.string().min(1), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), + initials: z.string().min(1).max(10) +}); +export type LeaderboardDriver = z.infer; + +const pointsEntryFields = { + points: z.number().int().min(0), + label: z.string().min(1), + color: z.string().regex(/^#[0-9a-fA-F]{6}$/), + textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/), + priority: z.number().int().min(0) +}; + +export const pointsEntrySchema = z.object(pointsEntryFields); +export type PointsEntry = z.infer; + +export const flPointsSchema = z.object(pointsEntryFields); +export type FlPoints = z.infer; + +export const leaderboardTrackSchema = z.object({ + name: z.string().min(1), + results: z.array(resultValueSchema), + fastestLapInitials: z.string() +}); +export type LeaderboardTrack = z.infer; + +export const leaderboardSchema = z.object({ + drivers: z.array(leaderboardDriverSchema), + pointsTable: z.array(pointsEntrySchema), + flPoints: flPointsSchema, + tracks: z.array(leaderboardTrackSchema) +}); +export type Leaderboard = z.infer; + +export const emptyLeaderboard: Leaderboard = { + drivers: [], + pointsTable: [], + flPoints: { points: 1, label: 'FL +1', color: '#8b5cf6', textColor: '#000000', priority: 3 }, + tracks: [] +}; + +// Client-side derived type β€” never sent to API +export type StandingsRow = { + driver: LeaderboardDriver; + driverIndex: number; + totalPoints: number; + trackResults: ResultValue[]; + fastestLapCount: number; +};