Skip to content

Commit a0f5433

Browse files
Merge pull request #283 from DevLoversTeam/qa-ui-fixes
(SP: 1) [Frontend] Polish Q&A and leaderboard UI
2 parents ce038ed + dbd9029 commit a0f5433

5 files changed

Lines changed: 194 additions & 36 deletions

File tree

frontend/app/[locale]/q&a/page.tsx

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getTranslations } from 'next-intl/server';
22
import { Suspense } from 'react';
33

44
import QaSection from '@/components/q&a/QaSection';
5+
import { QaLoader } from '@/components/shared/QaLoader';
56
import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground';
67

78
export async function generateMetadata({
@@ -27,21 +28,25 @@ export default async function QAPage({
2728
const t = await getTranslations({ locale, namespace: 'qa' });
2829

2930
return (
30-
<div className="min-h-screen">
31-
<DynamicGridBackground className="bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent">
32-
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
33-
<div className="mb-8">
34-
<p className="text-sm font-semibold text-(--accent-primary)">
35-
{t('pretitle')}
36-
</p>
37-
<h1 className="text-3xl font-bold">{t('title')}</h1>
38-
<p className="text-gray-600 dark:text-gray-400">{t('subtitle')}</p>
39-
</div>
40-
<Suspense fallback={<>...</>}>
41-
<QaSection />
42-
</Suspense>
43-
</main>
44-
</DynamicGridBackground>
45-
</div>
31+
<DynamicGridBackground className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent">
32+
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
33+
<div className="mb-8">
34+
<p className="text-sm font-semibold text-(--accent-primary)">
35+
{t('pretitle')}
36+
</p>
37+
<h1 className="text-3xl font-bold">{t('title')}</h1>
38+
<p className="text-gray-600 dark:text-gray-400">{t('subtitle')}</p>
39+
</div>
40+
<Suspense
41+
fallback={
42+
<div className="flex justify-center py-16">
43+
<QaLoader className="mx-auto" size={260} />
44+
</div>
45+
}
46+
>
47+
<QaSection />
48+
</Suspense>
49+
</main>
50+
</DynamicGridBackground>
4651
);
4752
}

frontend/components/leaderboard/LeaderboardClient.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,23 +25,24 @@ export default function LeaderboardClient({
2525
const hasResults = topThree.length > 0;
2626

2727
return (
28-
<div className="relative min-h-screen w-full">
29-
<div className="fixed inset-0 z-0">
30-
<DynamicGridBackground
31-
showStaticGrid
32-
className="h-full w-full bg-gray-50 transition-colors duration-300 dark:bg-transparent"
33-
/>
34-
</div>
35-
28+
<DynamicGridBackground className="min-h-screen bg-gray-50 transition-colors duration-300 dark:bg-transparent">
3629
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center px-4 pt-20 pb-10 sm:px-6 lg:px-8">
3730
<header className="mb-16 max-w-3xl text-center">
3831
<motion.div
3932
initial={{ y: 20, opacity: 0 }}
4033
animate={{ y: 0, opacity: 1 }}
4134
transition={{ duration: 0.6 }}
4235
>
43-
<h1 className="mb-6 text-4xl font-black tracking-tight text-[var(--accent-primary)] drop-shadow-sm md:text-6xl lg:text-7xl">
44-
{t('title')}
36+
<h1 className="relative mb-6 inline-block pb-2 text-4xl font-black tracking-tight md:text-6xl lg:text-7xl">
37+
<span className="relative inline-block bg-gradient-to-r from-[var(--accent-primary)]/70 via-[color-mix(in_srgb,var(--accent-primary)_70%,white)]/70 to-[var(--accent-hover)]/70 bg-clip-text text-transparent">
38+
{t('title')}
39+
</span>
40+
<span
41+
className="wave-text-gradient pointer-events-none absolute inset-0 inline-block bg-gradient-to-r from-[var(--accent-primary)] via-[color-mix(in_srgb,var(--accent-primary)_70%,white)] to-[var(--accent-hover)] bg-clip-text text-transparent"
42+
aria-hidden="true"
43+
>
44+
{t('title')}
45+
</span>
4546
</h1>
4647
</motion.div>
4748

@@ -81,6 +82,6 @@ export default function LeaderboardClient({
8182
</div>
8283
</div>
8384
</div>
84-
</div>
85+
</DynamicGridBackground>
8586
);
8687
}

frontend/components/q&a/AccordionList.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -379,16 +379,18 @@ export default function AccordionList({ items }: { items: QuestionEntry[] }) {
379379
const accent =
380380
categoryTabStyles[q.category as keyof typeof categoryTabStyles]
381381
?.accent;
382+
const animationDelay = `${Math.min(idx, 10) * 60}ms`;
383+
const itemStyle: CSSProperties = {
384+
animationDelay,
385+
animationFillMode: 'both',
386+
...(accent ? ({ '--qa-accent': accent } as CSSProperties) : {}),
387+
};
382388
return (
383389
<AccordionItem
384390
key={key}
385391
value={String(key)}
386-
className="qa-accordion-item mb-3 rounded-xl border border-black/5 bg-white/90 shadow-sm transition-colors last:mb-0 last:border-b dark:border-white/10 dark:bg-neutral-900/80"
387-
style={
388-
accent
389-
? ({ '--qa-accent': accent } as CSSProperties)
390-
: undefined
391-
}
392+
className="qa-accordion-item mb-3 rounded-xl border border-black/5 bg-white/90 shadow-sm transition-colors last:mb-0 last:border-b dark:border-white/10 dark:bg-neutral-900/80 animate-in fade-in slide-in-from-bottom-2 duration-500"
393+
style={itemStyle}
392394
>
393395
<AccordionTrigger
394396
className="px-4 hover:no-underline"

frontend/components/q&a/QaSection.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client';
22

33
import { useTranslations } from 'next-intl';
4-
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
55

66
import AccordionList from '@/components/q&a/AccordionList';
7+
import { QaLoader } from '@/components/shared/QaLoader';
78
import { Pagination } from '@/components/q&a/Pagination';
89
import type { CategorySlug } from '@/components/q&a/types';
910
import { useQaTabs } from '@/components/q&a/useQaTabs';
@@ -28,6 +29,10 @@ export default function TabsSection() {
2829
localeKey,
2930
totalPages,
3031
} = useQaTabs();
32+
const animationKey = useMemo(
33+
() => `qa-${active}-${currentPage}`,
34+
[active, currentPage]
35+
);
3136

3237
useEffect(() => {
3338
const media = window.matchMedia('(max-width: 640px)');
@@ -93,7 +98,7 @@ export default function TabsSection() {
9398
<TabsContent key={category.slug} value={category.slug}>
9499
{isLoading && (
95100
<div className="flex justify-center py-12">
96-
<div className="h-8 w-8 animate-spin border-b-2" />
101+
<QaLoader className="mx-auto" size={240} />
97102
</div>
98103
)}
99104
<div
@@ -104,7 +109,7 @@ export default function TabsSection() {
104109
aria-busy={isLoading}
105110
>
106111
{items.length ? (
107-
<AccordionList items={items} />
112+
<AccordionList key={animationKey} items={items} />
108113
) : (
109114
<p className="py-12 text-center">{t('noQuestions')}</p>
110115
)}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
'use client';
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
import { cn } from '@/lib/utils';
6+
7+
interface QaLoaderProps {
8+
className?: string;
9+
size?: number;
10+
}
11+
12+
interface ParticleState {
13+
x: number;
14+
y: number;
15+
angle: number;
16+
speed: number;
17+
accel: number;
18+
radius: number;
19+
decay: number;
20+
life: number;
21+
}
22+
23+
const TWO_PI = Math.PI * 2;
24+
25+
export function QaLoader({ className, size = 240 }: QaLoaderProps) {
26+
const canvasRef = useRef<HTMLCanvasElement>(null);
27+
const particlesRef = useRef<ParticleState[]>([]);
28+
const animationRef = useRef<number | null>(null);
29+
30+
useEffect(() => {
31+
const canvas = canvasRef.current;
32+
if (!canvas) return;
33+
34+
const ctx = canvas.getContext('2d');
35+
if (!ctx) return;
36+
37+
const dpr = window.devicePixelRatio || 1;
38+
const width = size;
39+
const height = size;
40+
41+
canvas.width = width * dpr;
42+
canvas.height = height * dpr;
43+
canvas.style.width = `${width}px`;
44+
canvas.style.height = `${height}px`;
45+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
46+
ctx.globalCompositeOperation = 'lighter';
47+
48+
const particles = particlesRef.current;
49+
const min = width * 0.5;
50+
let globalAngle = 0;
51+
let tick = 0;
52+
let lastFrame = 0;
53+
54+
const spawnParticle = () => {
55+
particles.push({
56+
x: width / 2 + Math.cos(tick / 20) * (min / 2),
57+
y: height / 2 + Math.sin(tick / 20) * (min / 2),
58+
angle: globalAngle,
59+
speed: 0,
60+
accel: 0.012,
61+
radius: 7,
62+
decay: 0.012,
63+
life: 1,
64+
});
65+
};
66+
67+
const step = () => {
68+
spawnParticle();
69+
spawnParticle();
70+
71+
for (let i = particles.length - 1; i >= 0; i -= 1) {
72+
const p = particles[i];
73+
p.speed += p.accel;
74+
p.x += Math.cos(p.angle) * p.speed;
75+
p.y += Math.sin(p.angle) * p.speed;
76+
p.angle += Math.PI / 64;
77+
p.accel *= 1.01;
78+
p.life -= p.decay;
79+
80+
if (p.life <= 0) {
81+
particles.splice(i, 1);
82+
}
83+
}
84+
85+
globalAngle += Math.PI / 3.2;
86+
};
87+
88+
const draw = () => {
89+
ctx.clearRect(0, 0, width, height);
90+
91+
particles.forEach((p, i) => {
92+
const hue = 300 - (1 - p.life) * 120;
93+
const alpha = Math.max(0, p.life);
94+
ctx.fillStyle = `hsla(${hue}, 100%, 60%, ${alpha})`;
95+
ctx.strokeStyle = `hsla(${hue}, 100%, 60%, ${alpha})`;
96+
97+
if (particles[i - 1]) {
98+
ctx.beginPath();
99+
ctx.moveTo(p.x, p.y);
100+
ctx.lineTo(particles[i - 1].x, particles[i - 1].y);
101+
ctx.stroke();
102+
}
103+
104+
ctx.beginPath();
105+
ctx.arc(p.x, p.y, Math.max(0.001, p.life * p.radius), 0, TWO_PI);
106+
ctx.fill();
107+
108+
const sparkle = Math.random() * 1.2;
109+
ctx.fillRect(
110+
Math.round(p.x + (Math.random() - 0.5) * 35 * p.life),
111+
Math.round(p.y + (Math.random() - 0.5) * 35 * p.life),
112+
sparkle,
113+
sparkle
114+
);
115+
});
116+
};
117+
118+
const loop = (timestamp: number) => {
119+
animationRef.current = requestAnimationFrame(loop);
120+
if (!lastFrame) lastFrame = timestamp;
121+
const frameDiff = timestamp - lastFrame;
122+
if (frameDiff < 1000 / 60) return;
123+
lastFrame = timestamp;
124+
step();
125+
draw();
126+
tick += 1;
127+
};
128+
129+
animationRef.current = requestAnimationFrame(loop);
130+
131+
return () => {
132+
if (animationRef.current) cancelAnimationFrame(animationRef.current);
133+
};
134+
}, [size]);
135+
136+
return (
137+
<div
138+
className={cn('relative flex items-center justify-center', className)}
139+
style={{ width: size, height: size }}
140+
aria-hidden="true"
141+
>
142+
<canvas ref={canvasRef} className="block" />
143+
</div>
144+
);
145+
}

0 commit comments

Comments
 (0)