Skip to content

Commit 5c2f8ec

Browse files
TiZoriiViktorSvertoka
authored andcommitted
feat(dashboard): explained terms card, layout fixes, support link
1 parent 8a4c0d4 commit 5c2f8ec

10 files changed

Lines changed: 448 additions & 28 deletions

File tree

frontend/app/[locale]/dashboard/page.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getTranslations } from 'next-intl/server';
22

33
import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync';
4+
import { ExplainedTermsCard } from '@/components/dashboard/ExplainedTermsCard';
45
import { ProfileCard } from '@/components/dashboard/ProfileCard';
56
import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner';
67
import { StatsCard } from '@/components/dashboard/StatsCard';
@@ -82,10 +83,9 @@ export default async function DashboardPage({
8283
<div className="min-h-screen">
8384
<PostAuthQuizSync />
8485
<DynamicGridBackground
85-
showStaticGrid
86-
className="min-h-screen bg-gray-50 py-12 transition-colors duration-300 dark:bg-transparent"
86+
className="min-h-screen bg-gray-50 py-10 transition-colors duration-300 dark:bg-transparent"
8787
>
88-
<main className="relative z-10 mx-auto max-w-5xl px-6">
88+
<main className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
8989
<header className="mb-12 flex flex-col justify-between gap-6 md:flex-row md:items-center">
9090
<div>
9191
<h1 className="text-4xl font-black tracking-tight md:text-5xl">
@@ -96,13 +96,23 @@ export default async function DashboardPage({
9696
</p>
9797
</div>
9898

99-
<span className={outlineBtnStyles}>{t('supportLink')}</span>
99+
<a
100+
href="https://t.me/devloversteam"
101+
target="_blank"
102+
rel="noopener noreferrer"
103+
className={outlineBtnStyles}
104+
>
105+
{t('supportLink')}
106+
</a>
100107
</header>
101108
<QuizSavedBanner />
102109
<div className="grid gap-8 md:grid-cols-2">
103110
<ProfileCard user={userForDisplay} locale={locale} />
104111
<StatsCard stats={stats} />
105112
</div>
113+
<div className="mt-8">
114+
<ExplainedTermsCard />
115+
</div>
106116
</main>
107117
</DynamicGridBackground>
108118
</div>
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
'use client';
2+
3+
import { BookOpen, ChevronDown, GripVertical, RotateCcw, X } from 'lucide-react';
4+
import { useTranslations } from 'next-intl';
5+
import { useEffect, useState } from 'react';
6+
7+
import AIWordHelper from '@/components/q&a/AIWordHelper';
8+
import { getCachedTerms } from '@/lib/ai/explainCache';
9+
import {
10+
getHiddenTerms,
11+
hideTermFromDashboard,
12+
unhideTermFromDashboard,
13+
} from '@/lib/ai/hiddenTerms';
14+
import { saveTermOrder, sortTermsByOrder } from '@/lib/ai/termOrder';
15+
16+
export function ExplainedTermsCard() {
17+
const t = useTranslations('dashboard.explainedTerms');
18+
const [terms, setTerms] = useState<string[]>([]);
19+
const [hiddenTerms, setHiddenTerms] = useState<string[]>([]);
20+
const [showMore, setShowMore] = useState(false);
21+
const [selectedTerm, setSelectedTerm] = useState<string | null>(null);
22+
const [isModalOpen, setIsModalOpen] = useState(false);
23+
const [draggedTerm, setDraggedTerm] = useState<string | null>(null);
24+
25+
/* eslint-disable react-hooks/set-state-in-effect */
26+
useEffect(() => {
27+
const cached = getCachedTerms();
28+
const hidden = getHiddenTerms();
29+
30+
const visibleTerms = cached.filter(
31+
term => !hidden.has(term.toLowerCase().trim())
32+
);
33+
34+
const sortedTerms = sortTermsByOrder(visibleTerms);
35+
36+
setTerms(sortedTerms);
37+
const hiddenArray = cached.filter(term =>
38+
hidden.has(term.toLowerCase().trim())
39+
);
40+
setHiddenTerms(sortTermsByOrder(hiddenArray));
41+
}, []);
42+
/* eslint-enable react-hooks/set-state-in-effect */
43+
44+
const handleRemoveTerm = (term: string) => {
45+
hideTermFromDashboard(term);
46+
setTerms(prevTerms => prevTerms.filter(t => t !== term));
47+
setHiddenTerms(prevHidden => [...prevHidden, term]);
48+
};
49+
50+
const handleRestoreTerm = (term: string) => {
51+
unhideTermFromDashboard(term);
52+
setHiddenTerms(prevHidden => prevHidden.filter(t => t !== term));
53+
setTerms(prevTerms => {
54+
const updated = [...prevTerms, term];
55+
saveTermOrder(updated);
56+
return updated;
57+
});
58+
};
59+
60+
const handleDragStart = (term: string) => {
61+
setDraggedTerm(term);
62+
};
63+
64+
const handleDragOver = (e: React.DragEvent) => {
65+
e.preventDefault();
66+
};
67+
68+
const handleDrop = (targetTerm: string) => {
69+
if (!draggedTerm || draggedTerm === targetTerm) {
70+
setDraggedTerm(null);
71+
return;
72+
}
73+
74+
setTerms(prevTerms => {
75+
const newTerms = [...prevTerms];
76+
const draggedIndex = newTerms.indexOf(draggedTerm);
77+
const targetIndex = newTerms.indexOf(targetTerm);
78+
79+
if (draggedIndex === -1 || targetIndex === -1) {
80+
return prevTerms;
81+
}
82+
83+
newTerms.splice(draggedIndex, 1);
84+
newTerms.splice(targetIndex, 0, draggedTerm);
85+
86+
saveTermOrder(newTerms);
87+
return newTerms;
88+
});
89+
90+
setDraggedTerm(null);
91+
};
92+
93+
const handleTermClick = (term: string) => {
94+
setSelectedTerm(term);
95+
setIsModalOpen(true);
96+
};
97+
98+
const handleModalClose = () => {
99+
setIsModalOpen(false);
100+
setSelectedTerm(null);
101+
};
102+
103+
const hasTerms = terms.length > 0;
104+
const hasHiddenTerms = hiddenTerms.length > 0;
105+
106+
const cardStyles = `
107+
relative overflow-hidden rounded-2xl
108+
border border-gray-100 dark:border-white/5
109+
bg-white/60 dark:bg-neutral-900/60 backdrop-blur-xl
110+
p-8 transition-all hover:border-[var(--accent-primary)]/30 dark:hover:border-[var(--accent-primary)]/30
111+
`;
112+
113+
return (
114+
<>
115+
<section className={cardStyles} aria-labelledby="explained-terms-heading">
116+
<div>
117+
<div className="mb-6 flex items-center gap-3">
118+
<div
119+
className="rounded-full bg-gray-100 p-3 dark:bg-neutral-800/50"
120+
aria-hidden="true"
121+
>
122+
<BookOpen className="h-6 w-6 text-(--accent-primary)" />
123+
</div>
124+
<div>
125+
<h3
126+
id="explained-terms-heading"
127+
className="text-xl font-bold text-gray-900 dark:text-white"
128+
>
129+
{t('title')}
130+
</h3>
131+
<p className="text-sm text-gray-500 dark:text-gray-400">
132+
{t('subtitle')}
133+
</p>
134+
</div>
135+
</div>
136+
137+
{hasTerms && (
138+
<>
139+
<p className="mb-4 text-sm text-gray-500 dark:text-gray-400">
140+
{t('termCount', { count: terms.length })}
141+
</p>
142+
<div className="flex flex-wrap gap-2">
143+
{terms.map(term => (
144+
<div
145+
key={term}
146+
onDragOver={handleDragOver}
147+
onDrop={() => handleDrop(term)}
148+
className={`group relative inline-flex items-center gap-1 rounded-lg border px-2 py-2 pr-8 transition-all ${
149+
draggedTerm === term ? 'opacity-50' : ''
150+
} 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`}
151+
>
152+
<button
153+
draggable
154+
onDragStart={() => handleDragStart(term)}
155+
className={`cursor-grab active:cursor-grabbing touch-none ${
156+
draggedTerm === term ? 'cursor-grabbing' : ''
157+
}`}
158+
>
159+
<GripVertical className="h-4 w-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" />
160+
</button>
161+
<button
162+
onClick={() => handleTermClick(term)}
163+
className="font-medium text-gray-900 dark:text-white"
164+
>
165+
{term}
166+
</button>
167+
<button
168+
onClick={e => {
169+
e.stopPropagation();
170+
handleRemoveTerm(term);
171+
}}
172+
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"
173+
>
174+
<X className="h-3 w-3" />
175+
</button>
176+
</div>
177+
))}
178+
</div>
179+
</>
180+
)}
181+
182+
{/* Explained Terms Section */}
183+
<div className="mt-6">
184+
<button
185+
onClick={() => setShowMore(!showMore)}
186+
className="mb-3 flex w-full items-center justify-between rounded-lg border border-gray-200 bg-gray-50/50 px-4 py-2 text-left text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-white/5 dark:bg-neutral-800/50 dark:text-gray-300 dark:hover:bg-neutral-800"
187+
>
188+
<span>
189+
{showMore
190+
? t('hideExplainedTerms')
191+
: t('explainedTermsButton', { count: hiddenTerms.length })}
192+
</span>
193+
<ChevronDown
194+
className={`h-4 w-4 transition-transform ${showMore ? 'rotate-180' : ''}`}
195+
/>
196+
</button>
197+
198+
{showMore && (
199+
<div>
200+
{hasHiddenTerms ? (
201+
<div className="flex flex-wrap gap-2">
202+
{hiddenTerms.map(term => (
203+
<div
204+
key={term}
205+
className="group relative inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-100/50 px-3 py-2 pr-8 opacity-60 transition-all hover:opacity-100 dark:border-white/10 dark:bg-neutral-800/30"
206+
>
207+
<button
208+
onClick={() => handleTermClick(term)}
209+
className="font-medium text-gray-700 dark:text-gray-400"
210+
>
211+
{term}
212+
</button>
213+
<button
214+
onClick={e => {
215+
e.stopPropagation();
216+
handleRestoreTerm(term);
217+
}}
218+
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"
219+
>
220+
<RotateCcw className="h-3 w-3" />
221+
</button>
222+
</div>
223+
))}
224+
</div>
225+
) : (
226+
<p className="text-center text-sm text-gray-500 dark:text-gray-400">
227+
{t('noHiddenTerms')}
228+
</p>
229+
)}
230+
</div>
231+
)}
232+
</div>
233+
</div>
234+
</section>
235+
236+
{selectedTerm && (
237+
<AIWordHelper
238+
term={selectedTerm}
239+
isOpen={isModalOpen}
240+
onClose={handleModalClose}
241+
/>
242+
)}
243+
</>
244+
);
245+
}

frontend/components/dashboard/ProfileCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,14 @@ export function ProfileCard({ user, locale }: ProfileCardProps) {
3535
</div>
3636
</div>
3737

38-
<div className="flex-1">
38+
<div className="flex-1 min-w-0">
3939
<h2
4040
id="profile-heading"
4141
className="text-2xl font-bold text-gray-900 dark:text-white"
4242
>
4343
{user.name || t('defaultName')}
4444
</h2>
45-
<p className="font-mono text-sm text-gray-500 dark:text-gray-400">
45+
<p className="truncate font-mono text-sm text-gray-500 dark:text-gray-400">
4646
{user.email}
4747
</p>
4848

frontend/components/header/MainSwitcher.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ function isQuizzesPath(pathname: string): boolean {
3434
segments[0] === 'quiz' || segments[1] === 'quiz';
3535
}
3636

37+
function isDashboardPath(pathname: string): boolean {
38+
const segments = pathname.split('/').filter(Boolean);
39+
return segments[0] === 'dashboard' || segments[1] === 'dashboard';
40+
}
41+
3742
type MainSwitcherProps = {
3843
children: ReactNode;
3944
userExists: boolean;
@@ -70,7 +75,7 @@ export function MainSwitcher({
7075
}
7176

7277
return (
73-
<main className={isQa || isHome || isQuizzesPath(pathname) ? 'mx-auto' : 'mx-auto min-h-[80vh] px-6'}>
78+
<main className={isQa || isHome || isQuizzesPath(pathname) || isDashboardPath(pathname) ? 'mx-auto' : 'mx-auto min-h-[80vh] px-6'}>
7479
{children}
7580
</main>
7681
);

frontend/components/q&a/HighlightCachedTerms.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { useTranslations } from 'next-intl';
34
import React, { useMemo } from 'react';
45

56
import { cn } from '@/lib/utils';
@@ -17,6 +18,8 @@ export default function HighlightCachedTerms({
1718
onTermClick,
1819
className,
1920
}: HighlightCachedTermsProps) {
21+
const t = useTranslations('aiHelper');
22+
2023
const segments = useMemo(() => {
2124
if (cachedTerms.size === 0) {
2225
return [{ text, isCached: false }];
@@ -34,7 +37,10 @@ export default function HighlightCachedTerms({
3437
return [{ text, isCached: false }];
3538
}
3639

37-
const pattern = new RegExp(`\\b(${escapedTerms.join('|')})\\b`, 'gi');
40+
const pattern = new RegExp(
41+
`(?<![\\p{L}\\p{N}])(${escapedTerms.join('|')})(?![\\p{L}\\p{N}])`,
42+
'giu'
43+
);
3844

3945
const result: { text: string; isCached: boolean; originalTerm?: string }[] =
4046
[];
@@ -92,15 +98,14 @@ export default function HighlightCachedTerms({
9298
}
9399
}}
94100
className={cn(
95-
'cursor-pointer',
96-
'border-b border-dashed border-emerald-500/60',
101+
'cursor-pointer inline-block',
102+
'border-b-2 border-dashed border-emerald-500/60',
97103
'bg-emerald-50/50 dark:bg-emerald-900/20',
98104
'hover:bg-emerald-100 dark:hover:bg-emerald-900/40',
99105
'focus:ring-2 focus:ring-emerald-500/50 focus:ring-offset-1 focus:outline-none',
100-
'transition-colors duration-150',
101-
'-mx-0.5 rounded-sm px-0.5'
106+
'transition-colors duration-150'
102107
)}
103-
title="Click to see explanation (cached)"
108+
title={t('cachedTooltip')}
104109
>
105110
{segment.text}
106111
</span>

0 commit comments

Comments
 (0)