Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
179 changes: 172 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,129 @@ 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);

cleanupRef.current = () => {
node.removeEventListener('touchmove', onTouchMove);
node.removeEventListener('touchend', onTouchEnd);
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}, []);

const handleTermClick = (term: string) => {
setSelectedTerm(term);
setIsModalOpen(true);
Expand Down Expand Up @@ -132,19 +262,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 +314,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 +371,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 +396,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