Skip to content

Commit 039d6e9

Browse files
authored
Merge pull request #6 from eWloYW8/master
feat: dark mode and better trend chart
2 parents 57da649 + 0b78653 commit 039d6e9

File tree

12 files changed

+294
-376
lines changed

12 files changed

+294
-376
lines changed

app/(main)/contests/page.tsx

Lines changed: 14 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
1414
import { useToast } from "@/hooks/use-toast";
1515
import MarkdownViewer from '@/components/shared/markdown-viewer';
1616
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
17-
import { ResponsiveContainer, LineChart as RechartsLineChart, CartesianGrid, XAxis, YAxis, Tooltip, Legend, Line } from 'recharts';
1817
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
1918
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
2019
import { UserProfileCard } from '@/components/shared/user-profile-card';
2120
import { getInitials } from '@/lib/utils';
21+
import EchartsTrendChart from '@/components/charts/echarts-trend-chart';
2222

2323
const fetcher = (url: string) => api.get(url).then(res => res.data.data);
2424

25-
// --- ContestList (no changes) ---
25+
// --- ContestList ---
2626
function ContestList() {
2727
const { data: contests, error, isLoading } = useSWR<Record<string, Contest>>('/contests', fetcher);
2828

@@ -125,52 +125,21 @@ function ContestProblems({ contestId }: { contestId: string }) {
125125
);
126126
}
127127

128-
const COLORS = ['#8884d8', '#82ca9d', '#ffc658', '#ff8042', '#0088FE', '#00C49F', '#FFBB28', '#FF8042'];
129-
130128
function ContestTrend({ contestId }: { contestId: string }) {
131129
const { data: trendData, error, isLoading } = useSWR<TrendEntry[]>(`/contests/${contestId}/trend`, fetcher, { refreshInterval: 30000 });
132130

133131
if (isLoading) return <Skeleton className="h-96 w-full" />;
134132
if (error) return <div>Failed to load trend data.</div>;
135133
if (!trendData || trendData.length === 0) return <div>No trend data available yet.</div>;
136134

137-
const allTimePoints = new Set<number>();
138-
trendData.forEach(user => {
139-
user.history.forEach(point => {
140-
allTimePoints.add(new Date(point.time).getTime());
141-
});
142-
});
143-
144-
const sortedTimePoints = Array.from(allTimePoints).sort();
145-
146-
const chartData = sortedTimePoints.map(time => {
147-
const dataPoint: { [key: string]: any } = { time: format(new Date(time), 'HH:mm:ss') };
148-
trendData.forEach(user => {
149-
const lastPoint = [...user.history].reverse().find(p => new Date(p.time).getTime() <= time);
150-
dataPoint[user.nickname] = lastPoint ? lastPoint.score : 0;
151-
});
152-
return dataPoint;
153-
});
154-
155135
return (
156136
<Card>
157137
<CardHeader>
158138
<CardTitle>Score Trend</CardTitle>
159139
<CardDescription>Score progression of top users over time.</CardDescription>
160140
</CardHeader>
161141
<CardContent className="h-96 w-full">
162-
<ResponsiveContainer>
163-
<RechartsLineChart data={chartData} margin={{ top: 5, right: 20, left: -10, bottom: 5 }}>
164-
<CartesianGrid strokeDasharray="3 3" />
165-
<XAxis dataKey="time" />
166-
<YAxis />
167-
<Tooltip />
168-
<Legend />
169-
{trendData.map((user, index) => (
170-
<Line key={user.user_id} type="stepAfter" dataKey={user.nickname} stroke={COLORS[index % COLORS.length]} strokeWidth={2} dot={false} />
171-
))}
172-
</RechartsLineChart>
173-
</ResponsiveContainer>
142+
<EchartsTrendChart trendData={trendData} />
174143
</CardContent>
175144
</Card>
176145
);
@@ -220,7 +189,7 @@ function LeaderboardRow({ entry, rank, problemIds }: { entry: LeaderboardEntry,
220189
}
221190

222191

223-
// --- UPDATED ContestLeaderboard component ---
192+
// --- ContestLeaderboard component ---
224193
function ContestLeaderboard({ contestId }: { contestId: string }) {
225194
// Fetch contest details to get the problem IDs in order
226195
const { data: contest, error: contestError, isLoading: isContestLoading } = useSWR<Contest>(`/contests/${contestId}`, fetcher);
@@ -292,7 +261,7 @@ function ContestDetailView({ contestId, view }: { contestId: string, view: strin
292261
toast({ variant: "destructive", title: "Registration Failed", description: error.response?.data?.message || "An unexpected error occurred." });
293262
}
294263
};
295-
264+
296265
const now = new Date();
297266
const canRegister = contest && now >= new Date(contest.starttime) && now <= new Date(contest.endtime);
298267

@@ -313,20 +282,24 @@ function ContestDetailView({ contestId, view }: { contestId: string, view: strin
313282
)}
314283
</div>
315284
<Tabs value={view} className="w-full">
316-
<TabsList className="grid w-full grid-cols-3">
285+
<TabsList className="grid w-full grid-cols-2">
317286
<TabsTrigger value="problems" asChild>
318287
<Link href={`/contests?id=${contestId}&view=problems`}>Problems</Link>
319288
</TabsTrigger>
320289
<TabsTrigger value="leaderboard" asChild>
321290
<Link href={`/contests?id=${contestId}&view=leaderboard`}>Leaderboard</Link>
322291
</TabsTrigger>
323-
<TabsTrigger value="trend" asChild>
324-
<Link href={`/contests?id=${contestId}&view=trend`}>Trend</Link>
325-
</TabsTrigger>
326292
</TabsList>
327293
</Tabs>
328294
<div className="mt-6">
329-
{view === 'leaderboard' ? <ContestLeaderboard contestId={contestId} /> : view === 'trend' ? <ContestTrend contestId={contestId} /> : <ContestProblems contestId={contestId} />}
295+
{view === 'leaderboard' ? (
296+
<div className="space-y-6">
297+
<ContestTrend contestId={contestId} />
298+
<ContestLeaderboard contestId={contestId} />
299+
</div>
300+
) : (
301+
<ContestProblems contestId={contestId} />
302+
)}
330303
</div>
331304
</div>
332305
);

app/(main)/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import withAuth from "@/components/layout/with-auth";
33
import { MainNav } from "@/components/layout/main-nav";
44
import { UserNav } from "@/components/layout/user-nav";
5+
import { ThemeToggle } from "@/components/layout/theme-toggle";
56

67
function MainLayout({ children }: { children: React.ReactNode }) {
78
return (
@@ -11,6 +12,7 @@ function MainLayout({ children }: { children: React.ReactNode }) {
1112
<div className="flex w-full items-center gap-4 md:ml-auto md:gap-2 lg:gap-4">
1213
<div className="ml-auto flex-1 sm:flex-initial">
1314
</div>
15+
<ThemeToggle />
1416
<UserNav />
1517
</div>
1618
</header>

app/globals.css

Lines changed: 19 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -46,34 +46,25 @@
4646
}
4747

4848
.dark {
49-
--background: 222.2 84% 4.9%;
50-
--foreground: 210 40% 98%;
51-
52-
--card: 222.2 84% 4.9%;
53-
--card-foreground: 210 40% 98%;
54-
55-
--popover: 222.2 84% 4.9%;
56-
--popover-foreground: 210 40% 98%;
57-
58-
--primary: 210 40% 98%;
59-
--primary-foreground: 222.2 47.4% 11.2%;
60-
61-
--secondary: 217.2 32.6% 17.5%;
62-
--secondary-foreground: 210 40% 98%;
63-
64-
--muted: 217.2 32.6% 17.5%;
65-
--muted-foreground: 215 20.2% 65.1%;
66-
67-
--accent: 217.2 32.6% 17.5%;
68-
--accent-foreground: 210 40% 98%;
69-
70-
--destructive: 0 62.8% 30.6%;
71-
--destructive-foreground: 210 40% 98%;
72-
73-
--border: 217.2 32.6% 17.5%;
74-
--input: 217.2 32.6% 17.5%;
75-
--ring: 212.7 26.8% 83.9%;
76-
--chart-1: 220 70% 50%;
49+
--background: 240 4% 12%;
50+
--foreground: 0 0% 83%;
51+
--card: 240 4% 15%;
52+
--card-foreground: 0 0% 83%;
53+
--popover: 240 4% 12%;
54+
--popover-foreground: 0 0% 83%;
55+
--primary: 205 92% 35%;
56+
--primary-foreground: 210 20% 98%;
57+
--secondary: 240 3% 20%;
58+
--secondary-foreground: 210 20% 98%;
59+
--muted: 240 3% 20%;
60+
--muted-foreground: 240 2% 50%;
61+
--accent: 240 3% 20%;
62+
--accent-foreground: 210 20% 98%;
63+
--destructive: 0 63% 31%;
64+
--destructive-foreground: 210 20% 98%;
65+
--border: 240 3% 24%;
66+
--input: 240 3% 24%;
67+
--ring: 205 92% 35%;
7768
--chart-2: 160 60% 45%;
7869
--chart-3: 30 80% 55%;
7970
--chart-4: 280 65% 60%;

app/layout.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cn } from "@/lib/utils";
55
import { SWRProvider } from "@/providers/swr-provider";
66
import { AuthProvider } from "@/providers/auth-provider";
77
import { Toaster } from "@/components/ui/toaster";
8+
import { ThemeProvider } from "@/providers/theme-provider";
89

910
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
1011

@@ -26,10 +27,16 @@ export default function RootLayout({
2627
inter.variable
2728
)}
2829
>
29-
<AuthProvider>
30-
<SWRProvider>{children}</SWRProvider>
31-
</AuthProvider>
32-
<Toaster />
30+
<ThemeProvider
31+
attribute="class"
32+
defaultTheme="system"
33+
enableSystem
34+
>
35+
<AuthProvider>
36+
<SWRProvider>{children}</SWRProvider>
37+
</AuthProvider>
38+
<Toaster />
39+
</ThemeProvider>
3340
</body>
3441
</html>
3542
);

components/auth/login-form.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function LoginForm() {
3636
const { login } = useAuth();
3737
const router = useRouter();
3838
const { toast } = useToast();
39-
const gitlabLoginUrl = `${process.env.NEXT_PUBLIC_API_URL || ""}/api/v1/auth/gitlab/login`;
39+
const gitlabLoginUrl = `/api/v1/auth/gitlab/login`;
4040

4141
const form = useForm<z.infer<typeof formSchema>>({
4242
resolver: zodResolver(formSchema),
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"use client";
2+
3+
import ReactECharts from 'echarts-for-react';
4+
import type { EChartsOption, LineSeriesOption } from 'echarts';
5+
import { useTheme } from 'next-themes';
6+
import { format } from 'date-fns';
7+
import { TrendEntry } from '@/lib/types';
8+
9+
interface EchartsTrendChartProps {
10+
trendData: TrendEntry[];
11+
}
12+
13+
const EchartsTrendChart: React.FC<EchartsTrendChartProps> = ({ trendData }) => {
14+
const { theme } = useTheme();
15+
16+
const truncateToMinute = (timestamp: number): number => {
17+
const date = new Date(timestamp);
18+
date.setSeconds(0, 0);
19+
return date.getTime();
20+
};
21+
22+
const allTimePoints = new Set<number>();
23+
trendData.forEach(user => {
24+
user.history.forEach(point => {
25+
allTimePoints.add(truncateToMinute(new Date(point.time).getTime()));
26+
});
27+
});
28+
29+
allTimePoints.add(truncateToMinute(new Date().getTime()));
30+
31+
const sortedTimePoints = Array.from(allTimePoints).sort((a, b) => a - b);
32+
33+
34+
const seriesData: LineSeriesOption[] = trendData.map(user => {
35+
const data = sortedTimePoints.map(time => {
36+
const lastPoint = [...user.history]
37+
.filter(p => new Date(p.time).getTime() <= time)
38+
.pop();
39+
const score = lastPoint ? lastPoint.score : 0;
40+
return [time, score];
41+
});
42+
return {
43+
name: user.nickname,
44+
type: 'line',
45+
step: 'end',
46+
symbol: 'none',
47+
data: data,
48+
};
49+
});
50+
51+
const option: EChartsOption = {
52+
backgroundColor: 'transparent',
53+
tooltip: {
54+
trigger: 'axis',
55+
axisPointer: {
56+
type: 'cross',
57+
label: {
58+
backgroundColor: '#6a7985'
59+
}
60+
},
61+
formatter: (params: any) => {
62+
const time = format(new Date(params[0].axisValue), 'yyyy-MM-dd HH:mm:ss');
63+
let tooltipHtml = `${time}<br/>`;
64+
params.forEach((param: any) => {
65+
tooltipHtml += `${param.marker} ${param.seriesName}: <strong>${param.value[1]}</strong><br/>`;
66+
});
67+
return tooltipHtml;
68+
}
69+
},
70+
legend: {
71+
data: trendData.map(user => user.nickname),
72+
textStyle: {
73+
color: theme === 'dark' ? '#ccc' : '#333',
74+
},
75+
bottom: 45,
76+
type: 'scroll',
77+
},
78+
grid: {
79+
left: '3%',
80+
right: '50px',
81+
bottom: 80,
82+
containLabel: true
83+
},
84+
toolbox: {
85+
feature: {
86+
saveAsImage: {
87+
title: 'Download',
88+
name: 'contest-trend',
89+
backgroundColor: theme === 'dark' ? '#1f2937' : '#fff'
90+
}
91+
}
92+
},
93+
xAxis: [
94+
{
95+
type: 'time',
96+
axisLabel: {
97+
color: theme === 'dark' ? '#ccc' : '#333',
98+
formatter: (value: number) => {
99+
return format(new Date(value), 'yyyy-MM-dd HH:mm');
100+
}
101+
}
102+
}
103+
],
104+
yAxis: [
105+
{
106+
type: 'value',
107+
axisLabel: {
108+
color: theme === 'dark' ? '#ccc' : '#333'
109+
}
110+
}
111+
],
112+
dataZoom: [
113+
{
114+
type: 'slider',
115+
xAxisIndex: 0,
116+
start: 0,
117+
end: 100,
118+
bottom: 10,
119+
height: 20,
120+
},
121+
{
122+
type: 'slider',
123+
yAxisIndex: 0,
124+
start: 0,
125+
end: 100,
126+
right: 10,
127+
width: 20,
128+
},
129+
{
130+
type: 'inside',
131+
xAxisIndex: 0,
132+
start: 0,
133+
end: 100,
134+
},
135+
],
136+
series: seriesData,
137+
};
138+
139+
return (
140+
<ReactECharts
141+
option={option}
142+
theme={theme === 'dark' ? 'dark' : 'light'}
143+
style={{ height: '100%', width: '100%' }}
144+
notMerge={true}
145+
lazyUpdate={true}
146+
/>
147+
);
148+
};
149+
150+
export default EchartsTrendChart;

0 commit comments

Comments
 (0)