) => {
+ 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) => (
+
+
+
+ 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;
+};