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
2 changes: 2 additions & 0 deletions frontend/app/[locale]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,10 @@ export default async function DashboardPage({
: null;

const userForDisplay = {
id: user.id,
name: user.name ?? null,
email: user.email ?? '',
image: user.image ?? null,
role: user.role ?? null,
points: user.points,
createdAt: user.createdAt ?? null,
Expand Down
181 changes: 174 additions & 7 deletions frontend/components/dashboard/ExplainedTermsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { BookOpen, ChevronDown, GripVertical, RotateCcw, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

import AIWordHelper from '@/components/q&a/AIWordHelper';
import { getCachedTerms } from '@/lib/ai/explainCache';
Expand All @@ -21,6 +21,13 @@ export function ExplainedTermsCard() {
const [selectedTerm, setSelectedTerm] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [touchDragState, setTouchDragState] = useState<{
sourceIndex: number;
targetIndex: number;
x: number;
y: number;
label: string;
} | null>(null);

/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
Expand Down Expand Up @@ -83,6 +90,131 @@ export function ExplainedTermsCard() {
setDraggedIndex(null);
};

// Touch drag support for mobile
const touchDragIndex = useRef<number | null>(null);
const termRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const dragTargetIndex = useRef<number | null>(null);
const cleanupRef = useRef<(() => void) | null>(null);

const setTermRef = useCallback(
(index: number) => (el: HTMLDivElement | null) => {
if (el) {
termRefs.current.set(index, el);
} else {
termRefs.current.delete(index);
}
},
[]
);

const setTouchDragStateRef = useRef(setTouchDragState);
useEffect(() => {
setTouchDragStateRef.current = setTouchDragState;
}, [setTouchDragState]);

const termsRef = useRef(terms);
useEffect(() => {
termsRef.current = terms;
}, [terms]);

const handleTouchStart = useCallback(
(index: number, e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchDragIndex.current = index;
dragTargetIndex.current = index;
setDraggedIndex(index);
setTouchDragStateRef.current({
sourceIndex: index,
targetIndex: index,
x: touch.clientX,
y: touch.clientY,
label: termsRef.current[index] ?? '',
});
},
[]
);

const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
if (cleanupRef.current) {
cleanupRef.current();
cleanupRef.current = null;
}

if (!node) return;

const onTouchMove = (e: TouchEvent) => {
if (touchDragIndex.current === null) return;
e.preventDefault();

const touch = e.touches[0];
if (!touch) return;

let newTarget = dragTargetIndex.current;

for (const [index, el] of termRefs.current.entries()) {
const rect = el.getBoundingClientRect();
if (
touch.clientX >= rect.left &&
touch.clientX <= rect.right &&
touch.clientY >= rect.top &&
touch.clientY <= rect.bottom &&
index !== touchDragIndex.current
) {
newTarget = index;
break;
}
}

dragTargetIndex.current = newTarget;
setDraggedIndex(newTarget);
setTouchDragStateRef.current(prev =>
prev
? {
...prev,
targetIndex: newTarget ?? prev.targetIndex,
x: touch.clientX,
y: touch.clientY,
}
: null
);
};

const onTouchEnd = () => {
const fromIndex = touchDragIndex.current;
const toIndex = dragTargetIndex.current;

if (
fromIndex !== null &&
toIndex !== null &&
fromIndex !== toIndex
) {
setTerms(prevTerms => {
const newTerms = [...prevTerms];
const [dragged] = newTerms.splice(fromIndex, 1);
newTerms.splice(toIndex, 0, dragged);
saveTermOrder(newTerms);
return newTerms;
});
}

touchDragIndex.current = null;
dragTargetIndex.current = null;
setDraggedIndex(null);
setTouchDragStateRef.current(null);
};

node.addEventListener('touchmove', onTouchMove, { passive: false });
node.addEventListener('touchend', onTouchEnd);
node.addEventListener('touchcancel', onTouchEnd);

cleanupRef.current = () => {
node.removeEventListener('touchmove', onTouchMove);
node.removeEventListener('touchend', onTouchEnd);
node.removeEventListener('touchcancel', onTouchEnd);
};
}, []);

const handleTermClick = (term: string) => {
setSelectedTerm(term);
setIsModalOpen(true);
Expand Down Expand Up @@ -132,19 +264,39 @@ export function ExplainedTermsCard() {
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
{t('termCount', { count: terms.length })}
</p>
<div className="flex flex-wrap gap-2">
{terms.map((term, index) => (
<div
ref={containerCallbackRef}
className="flex flex-wrap gap-2"
>
{terms.map((term, index) => {
const isSource =
touchDragState !== null &&
index === touchDragState.sourceIndex;
const isDropTarget =
touchDragState !== null &&
index === touchDragState.targetIndex &&
index !== touchDragState.sourceIndex;

return (
<div
key={`${term}-${index}`}
ref={setTermRef(index)}
onDragOver={handleDragOver}
onDrop={() => handleDrop(index)}
className={`group relative inline-flex items-center gap-1 rounded-lg border px-2 py-2 pr-8 transition-all ${
draggedIndex === index ? 'opacity-50' : ''
isSource
? 'scale-95 opacity-40'
: isDropTarget
? 'border-(--accent-primary) bg-(--accent-primary)/10 scale-105'
: draggedIndex === index
? 'opacity-50'
: ''
} border-gray-100 bg-gray-50/50 hover:border-(--accent-primary)/30 hover:bg-white dark:border-white/5 dark:bg-neutral-800/50 dark:hover:border-(--accent-primary)/30 dark:hover:bg-neutral-800`}
>
<button
draggable
onDragStart={() => handleDragStart(index)}
onTouchStart={e => handleTouchStart(index, e)}
aria-label={t('ariaDragHandle', { term })}
className={`cursor-grab active:cursor-grabbing touch-none ${
draggedIndex === index ? 'cursor-grabbing' : ''
Expand All @@ -164,12 +316,13 @@ export function ExplainedTermsCard() {
handleRemoveTerm(term);
}}
aria-label={t('ariaHide', { term })}
className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-0 shadow-sm transition-opacity hover:bg-red-50 hover:text-red-500 group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-red-900/20 dark:hover:text-red-400"
className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-100 shadow-sm transition-opacity hover:bg-red-50 hover:text-red-500 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<X className="h-3 w-3" />
</button>
</div>
))}
);
})}
</div>
</>
) : (
Expand Down Expand Up @@ -220,7 +373,7 @@ export function ExplainedTermsCard() {
handleRestoreTerm(term);
}}
aria-label={t('ariaRestore', { term })}
className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-0 shadow-sm transition-opacity hover:bg-green-50 hover:text-green-600 group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-green-900/20 dark:hover:text-green-400"
className="absolute -right-1 -top-1 rounded-full bg-white p-1 text-gray-400 opacity-100 shadow-sm transition-opacity hover:bg-green-50 hover:text-green-600 sm:opacity-0 sm:group-hover:opacity-100 dark:bg-neutral-800 dark:hover:bg-green-900/20 dark:hover:text-green-400"
>
<RotateCcw className="h-3 w-3" />
</button>
Expand All @@ -245,6 +398,20 @@ export function ExplainedTermsCard() {
onClose={handleModalClose}
/>
)}

{touchDragState && (
<div
className="pointer-events-none fixed z-50 inline-flex items-center gap-1 rounded-lg border border-(--accent-primary) bg-white px-3 py-2 font-medium text-gray-900 shadow-xl dark:bg-neutral-800 dark:text-white"
style={{
left: touchDragState.x,
top: touchDragState.y,
transform: 'translate(-50%, -120%)',
}}
>
<GripVertical className="h-4 w-4 text-(--accent-primary)" />
{touchDragState.label}
</div>
)}
</>
);
}
21 changes: 17 additions & 4 deletions frontend/components/dashboard/ProfileCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

import { useTranslations } from 'next-intl';

import { UserAvatar } from '@/components/leaderboard/UserAvatar';

interface ProfileCardProps {
user: {
id: string;
name: string | null;
email: string;
image: string | null;
role: string | null;
points: number;
createdAt: Date | null;
Expand All @@ -15,6 +19,11 @@ interface ProfileCardProps {

export function ProfileCard({ user, locale }: ProfileCardProps) {
const t = useTranslations('dashboard.profile');
const username = user.name || user.email.split('@')[0];
const seed = `${username}-${user.id}`;
const avatarSrc =
user.image ||
`https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`;

const cardStyles = `
relative overflow-hidden rounded-2xl
Expand All @@ -27,11 +36,15 @@ export function ProfileCard({ user, locale }: ProfileCardProps) {
<section className={cardStyles} aria-labelledby="profile-heading">
<div className="flex items-start gap-6">
<div
className="relative rounded-full bg-linear-to-br from-(--accent-primary) to-(--accent-hover) p-0.75"
aria-hidden="true"
className="relative shrink-0 rounded-full bg-linear-to-br from-(--accent-primary) to-(--accent-hover) p-0.75"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-white text-3xl font-bold text-gray-700 dark:bg-neutral-900 dark:text-gray-200">
{user.name?.[0]?.toUpperCase() || user.email[0].toUpperCase()}
<div className="relative h-20 w-20 overflow-hidden rounded-full bg-white dark:bg-neutral-900">
<UserAvatar
src={avatarSrc}
username={username}
userId={user.id}
sizes="80px"
/>
</div>
</div>

Expand Down
7 changes: 6 additions & 1 deletion frontend/components/header/MainSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ function isDashboardPath(pathname: string): boolean {
return segments[0] === 'dashboard' || segments[1] === 'dashboard';
}

function isLeaderboardPath(pathname: string): boolean {
const segments = pathname.split('/').filter(Boolean);
return segments[0] === 'leaderboard' || segments[1] === 'leaderboard';
}

type MainSwitcherProps = {
children: ReactNode;
userExists: boolean;
Expand Down Expand Up @@ -79,7 +84,7 @@ export function MainSwitcher({
return (
<main
className={
isQa || isHome || isQuizzesPath(pathname) || isDashboardPath(pathname)
isQa || isHome || isQuizzesPath(pathname) || isDashboardPath(pathname) || isLeaderboardPath(pathname)
? 'mx-auto'
: 'mx-auto min-h-[80vh] px-6'
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/components/leaderboard/LeaderboardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ export default function LeaderboardClient({
transition={{ duration: 0.6 }}
>
<h1 className="relative mb-6 inline-block pb-2 text-4xl font-black tracking-tight md:text-6xl lg:text-7xl">
<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">
<span className="relative inline-block bg-linear-to-r from-(--accent-primary)/70 via-[color-mix(in_srgb,var(--accent-primary)_70%,white)]/70 to-(--accent-hover)/70 bg-clip-text text-transparent">
{t('title')}
</span>
<span
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"
className="wave-text-gradient pointer-events-none absolute inset-0 inline-block bg-linear-to-r from-(--accent-primary) via-[color-mix(in_srgb,var(--accent-primary)_70%,white)] to-(--accent-hover) bg-clip-text text-transparent"
aria-hidden="true"
>
{t('title')}
Expand Down
Loading