From fd97aad6585ec70b3342e0fd258c72d0dd1c8548 Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Sat, 21 Feb 2026 19:11:36 -0500 Subject: [PATCH 1/2] fix: feedback, leaderboard --- frontend/app/[locale]/dashboard/page.tsx | 17 +- frontend/app/[locale]/leaderboard/page.tsx | 61 +++++- frontend/app/api/feedback/route.ts | 88 ++++++++ frontend/app/globals.css | 18 ++ .../dashboard/AchievementsSection.tsx | 5 +- .../dashboard/ActivityHeatmapCard.tsx | 20 +- .../dashboard/ExplainedTermsCard.tsx | 45 ++-- .../components/dashboard/FeedbackForm.tsx | 95 +++++---- frontend/components/dashboard/ProfileCard.tsx | 44 ++-- .../components/dashboard/QuizResultRow.tsx | 6 +- .../dashboard/QuizResultsSection.tsx | 7 +- frontend/components/dashboard/StatsCard.tsx | 14 +- frontend/components/header/AppMobileMenu.tsx | 22 +- .../components/header/MobileMenuContext.tsx | 7 +- .../leaderboard/AchievementPips.tsx | 193 ++++++++++++++++++ .../leaderboard/LeaderboardPodium.tsx | 53 +++-- .../leaderboard/LeaderboardTable.tsx | 113 +++++----- frontend/components/leaderboard/types.ts | 5 +- frontend/lib/github-stars.ts | 51 ++++- frontend/messages/en.json | 4 +- frontend/messages/pl.json | 5 +- frontend/messages/uk.json | 8 +- 22 files changed, 640 insertions(+), 241 deletions(-) create mode 100644 frontend/app/api/feedback/route.ts create mode 100644 frontend/components/leaderboard/AchievementPips.tsx diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index f692531b..61b8f96c 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { getTranslations } from 'next-intl/server'; import { Heart, MessageSquare } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { PostAuthQuizSync } from '@/components/auth/PostAuthQuizSync'; import { AchievementsSection } from '@/components/dashboard/AchievementsSection'; @@ -12,11 +12,11 @@ import { QuizSavedBanner } from '@/components/dashboard/QuizSavedBanner'; import { StatsCard } from '@/components/dashboard/StatsCard'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; import { getUserLastAttemptPerQuiz, getUserQuizStats } from '@/db/queries/quizzes/quiz'; -import { getUserProfile, getUserGlobalRank } from '@/db/queries/users'; +import { getUserGlobalRank, getUserProfile } from '@/db/queries/users'; import { redirect } from '@/i18n/routing'; -import { getSponsors, getAllSponsors } from '@/lib/about/github-sponsors'; -import { getCurrentUser } from '@/lib/auth'; +import { getAllSponsors, getSponsors } from '@/lib/about/github-sponsors'; import { computeAchievements } from '@/lib/achievements'; +import { getCurrentUser } from '@/lib/auth'; import { checkHasStarredRepo, resolveGitHubLogin } from '@/lib/github-stars'; export async function generateMetadata({ @@ -188,7 +188,6 @@ export default async function DashboardPage({ const highScores = attempts.filter((a) => Number(a.percentage) >= 90).length; const uniqueQuizzes = lastAttempts.length; - // Night Owl: any attempt completed between 00:00 and 05:00 local time const hasNightOwl = attempts.some((a) => { if (!a.completedAt) return false; const hour = new Date(a.completedAt).getHours(); @@ -205,7 +204,7 @@ export default async function DashboardPage({ totalPoints: user.points, topLeaderboard: false, hasStarredRepo, - sponsorCount: matchedSponsor ? 1 : 0, // TODO: wire to actual sponsorship history count + sponsorCount: matchedSponsor ? 1 : 0, hasNightOwl, }); @@ -257,7 +256,7 @@ export default async function DashboardPage({ totalAttempts={totalAttempts} globalRank={globalRank} /> -
+
@@ -265,7 +264,7 @@ export default async function DashboardPage({
-
+
@@ -281,4 +280,4 @@ export default async function DashboardPage({
); -} +} \ No newline at end of file diff --git a/frontend/app/[locale]/leaderboard/page.tsx b/frontend/app/[locale]/leaderboard/page.tsx index ca110f9b..536e857b 100644 --- a/frontend/app/[locale]/leaderboard/page.tsx +++ b/frontend/app/[locale]/leaderboard/page.tsx @@ -3,7 +3,9 @@ import { Metadata } from 'next'; import LeaderboardClient from '@/components/leaderboard/LeaderboardClient'; import { getLeaderboardData } from '@/db/queries/leaderboard'; import { getSponsors } from '@/lib/about/github-sponsors'; +import { ACHIEVEMENTS } from '@/lib/achievements'; import { getCurrentUser } from '@/lib/auth'; +import { getAllStargazers } from '@/lib/github-stars'; export const metadata: Metadata = { title: 'Leaderboard | DevLovers', @@ -12,25 +14,72 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic'; +// Map GitHub sponsor tier color → achievement id +const TIER_ACHIEVEMENT: Record<'gold' | 'silver' | 'bronze', string> = { + gold: 'golden_patron', + silver: 'silver_patron', + bronze: 'supporter', +}; + export default async function LeaderboardPage() { - const [rows, session, sponsors] = await Promise.all([ + const [rows, session, sponsors, stargazerList] = await Promise.all([ getLeaderboardData(), getCurrentUser(), getSponsors(), + getAllStargazers(), ]); + // Build O(1) lookup sets for stargazer matching + const stargazerLogins = new Set(stargazerList.map(s => s.login)); + const stargazerAvatars = new Set(stargazerList.map(s => s.avatarBase)); + const users = rows.map(({ email, ...user }) => { const emailLower = email.toLowerCase(); const nameLower = user.username.toLowerCase(); - const isSponsor = sponsors.some( + + const matchedSponsor = sponsors.find( s => (s.email && s.email.toLowerCase() === emailLower) || - (nameLower && s.login.toLowerCase() === nameLower) || - (nameLower && s.name.toLowerCase() === nameLower) || + (nameLower && s.login && s.login.toLowerCase() === nameLower) || + (nameLower && s.name && s.name.toLowerCase() === nameLower) || (user.avatar && s.avatarUrl && user.avatar.includes(s.avatarUrl.split('?')[0])) ); - return { ...user, isSponsor }; + + const isSponsor = !!matchedSponsor; + let achievements = user.achievements ?? []; + + // ── Inject sponsor achievement based on GitHub tier color ────────── + if (matchedSponsor) { + const achievementId = TIER_ACHIEVEMENT[matchedSponsor.tierColor]; + const def = ACHIEVEMENTS.find(a => a.id === achievementId); + if (def && !achievements.some(a => a.id === achievementId)) { + achievements = [ + { id: def.id, icon: def.icon, gradient: def.gradient, glow: def.glow }, + ...achievements, + ]; + } + } + + // ── Inject star_gazer if user has starred the repo ───────────────── + // Match by GitHub login (username) or by avatar URL base + const avatarBase = user.avatar?.split('?')[0] ?? ''; + const hasStarred = + stargazerLogins.has(nameLower) || + (avatarBase.includes('avatars.githubusercontent.com') && + stargazerAvatars.has(avatarBase)); + + if (hasStarred && !achievements.some(a => a.id === 'star_gazer')) { + const def = ACHIEVEMENTS.find(a => a.id === 'star_gazer'); + if (def) { + achievements = [ + { id: def.id, icon: def.icon, gradient: def.gradient, glow: def.glow }, + ...achievements, + ]; + } + } + + return { ...user, isSponsor, achievements }; }); return ; -} +} \ No newline at end of file diff --git a/frontend/app/api/feedback/route.ts b/frontend/app/api/feedback/route.ts new file mode 100644 index 00000000..a9cbcdb8 --- /dev/null +++ b/frontend/app/api/feedback/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from 'next/server'; +import nodemailer from 'nodemailer'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB +const MAX_FILES = 5; + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export async function POST(req: NextRequest) { + const gmailUser = process.env.GMAIL_USER; + const gmailPass = process.env.GMAIL_APP_PASSWORD; + const emailFrom = process.env.EMAIL_FROM; + + if (!gmailUser || !gmailPass || !emailFrom) { + console.error('Feedback API: Missing email environment variables'); + return NextResponse.json({ success: false }, { status: 500 }); + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return NextResponse.json({ success: false }, { status: 400 }); + } + + const name = (formData.get('name') as string | null)?.trim(); + const email = (formData.get('email') as string | null)?.trim(); + const category = (formData.get('category') as string | null)?.trim(); + const message = (formData.get('message') as string | null)?.trim(); + + if (!name || !email || !category || !message) { + return NextResponse.json({ success: false }, { status: 400 }); + } + + const rawFiles = formData.getAll('attachment'); + const files = rawFiles.filter((f): f is File => f instanceof File && f.size > 0); + + if (files.length > MAX_FILES) { + return NextResponse.json({ success: false }, { status: 400 }); + } + + const attachments: { filename: string; content: Buffer; contentType: string }[] = []; + + for (const f of files) { + if (f.size > MAX_FILE_SIZE) { + return NextResponse.json({ success: false, tooLarge: true }, { status: 413 }); + } + const buffer = Buffer.from(await f.arrayBuffer()); + attachments.push({ filename: f.name, content: buffer, contentType: f.type }); + } + + const mailer = nodemailer.createTransport({ + service: 'gmail', + auth: { user: gmailUser, pass: gmailPass }, + }); + + // Sanitize name for use in email header (strip CR/LF and RFC 5322 specials) + const safeNameForHeader = name.replace(/[\r\n"<>\\]/g, ''); + + try { + await mailer.sendMail({ + from: emailFrom, + replyTo: safeNameForHeader ? `"${safeNameForHeader}" <${email}>` : email, + to: emailFrom, + subject: `DevLovers Feedback: ${escapeHtml(category)}`, + html: ` +

Name: ${escapeHtml(name)}

+

Email: ${escapeHtml(email)}

+

Category: ${escapeHtml(category)}

+

Message:

+

${escapeHtml(message).replace(/\n/g, '
')}

+ `, + attachments, + }); + + return NextResponse.json({ success: true }); + } catch (err) { + console.error('Feedback API: Failed to send email', err); + return NextResponse.json({ success: false }, { status: 500 }); + } +} \ No newline at end of file diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 8a08fd99..88a72fec 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -174,6 +174,24 @@ html { .btn:hover { background-color: var(--accent-hover); } + + .dashboard-card { + @apply relative z-10 overflow-hidden rounded-3xl border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md transition-all duration-300; + + &:hover { + @apply -translate-y-1 shadow-md; + + border-color: color-mix(in srgb, var(--accent-primary) 30%, transparent); + } + + &:is(.dark *) { + @apply border-neutral-800 bg-neutral-900/10; + } + + &:is(.dark *):hover { + border-color: color-mix(in srgb, #ff2d55 30%, transparent); + } + } } .container-main { diff --git a/frontend/components/dashboard/AchievementsSection.tsx b/frontend/components/dashboard/AchievementsSection.tsx index 19b6cd51..9d2d07f9 100644 --- a/frontend/components/dashboard/AchievementsSection.tsx +++ b/frontend/components/dashboard/AchievementsSection.tsx @@ -18,8 +18,7 @@ export function AchievementsSection({ achievements }: AchievementsSectionProps) const earnedCount = achievements.filter((a) => a.earned).length; - const cardStyles = - 'relative z-10 overflow-hidden rounded-3xl border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:border-(--accent-primary)/30 dark:border-neutral-800 dark:bg-neutral-900/10 dark:hover:border-(--accent-primary)/30'; + const cardStyles = 'dashboard-card'; const previewBadges = achievements.slice(0, 6); const remainingBadges = achievements.slice(6); @@ -92,4 +91,4 @@ export function AchievementsSection({ achievements }: AchievementsSectionProps)
); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/ActivityHeatmapCard.tsx b/frontend/components/dashboard/ActivityHeatmapCard.tsx index fc25d299..8b4d8ec4 100644 --- a/frontend/components/dashboard/ActivityHeatmapCard.tsx +++ b/frontend/components/dashboard/ActivityHeatmapCard.tsx @@ -52,13 +52,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit left: number; } | null>(null); - const cardStyles = ` - relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-6 sm:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md - hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30 - `; + const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8'; const todayStart = useMemo(() => { const d = new Date(); @@ -278,7 +272,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
{currentStreak !== undefined && currentStreak > 0 && ( - + {currentStreak} {currentStreak === 1 ? tProfile('dayStreak', { fallback: 'Day Streak' }) : tProfile('daysStreak', { fallback: 'Days Streak' })} @@ -294,7 +288,7 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit aria-expanded={isDropdownOpen} aria-haspopup="listbox" > - + {periodOptions.find(o => o.value === periodOffset)?.label}
-
+
{monthsData.map((m, i) => ( @@ -458,14 +452,14 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 4 }} transition={{ duration: 0.1 }} - className="fixed z-[9999] pointer-events-none" + className="fixed z-9999 pointer-events-none" style={{ top: tooltip.top, left: tooltip.left, transform: 'translate(-50%, -100%)', }} > -
+

{tooltip.date.toLocaleDateString(locale, { weekday: 'short', month: 'short', day: 'numeric' })}

@@ -520,4 +514,4 @@ export function ActivityHeatmapCard({ attempts, locale, currentStreak }: Activit
); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index e68521c4..11fbe3ab 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -15,8 +15,22 @@ import { saveTermOrder, sortTermsByOrder } from '@/lib/ai/termOrder'; export function ExplainedTermsCard() { const t = useTranslations('dashboard.explainedTerms'); - const [terms, setTerms] = useState([]); - const [hiddenTerms, setHiddenTerms] = useState([]); + const [terms, setTerms] = useState(() => { + if (typeof window === 'undefined') return []; + const cached = getCachedTerms(); + const hidden = getHiddenTerms(); + return sortTermsByOrder( + cached.filter(term => !hidden.has(term.toLowerCase().trim())) + ); + }); + const [hiddenTerms, setHiddenTerms] = useState(() => { + if (typeof window === 'undefined') return []; + const cached = getCachedTerms(); + const hidden = getHiddenTerms(); + return sortTermsByOrder( + cached.filter(term => hidden.has(term.toLowerCase().trim())) + ); + }); const [showMore, setShowMore] = useState(false); const [selectedTerm, setSelectedTerm] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); @@ -29,23 +43,6 @@ export function ExplainedTermsCard() { label: string; } | null>(null); - useEffect(() => { - const cached = getCachedTerms(); - const hidden = getHiddenTerms(); - - const visibleTerms = cached.filter( - term => !hidden.has(term.toLowerCase().trim()) - ); - - const sortedTerms = sortTermsByOrder(visibleTerms); - - setTerms(sortedTerms); - const hiddenArray = cached.filter(term => - hidden.has(term.toLowerCase().trim()) - ); - setHiddenTerms(sortTermsByOrder(hiddenArray)); - }, []); - const handleRemoveTerm = (term: string) => { hideTermFromDashboard(term); setTerms(prevTerms => prevTerms.filter(t => t !== term)); @@ -225,13 +222,7 @@ export function ExplainedTermsCard() { const hasTerms = terms.length > 0; const hasHiddenTerms = hiddenTerms.length > 0; - const cardStyles = ` - relative z-10 flex flex-col overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-6 sm:p-8 lg:p-10 transition-all duration-300 hover:-translate-y-1 hover:shadow-md - hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30 - `; + const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10'; return ( <> @@ -412,4 +403,4 @@ export function ExplainedTermsCard() { )} ); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/FeedbackForm.tsx b/frontend/components/dashboard/FeedbackForm.tsx index 1b56e0dc..cd6a5f71 100644 --- a/frontend/components/dashboard/FeedbackForm.tsx +++ b/frontend/components/dashboard/FeedbackForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ChevronDown, MessageSquare, Send, Paperclip } from 'lucide-react'; +import { ChevronDown, MessageSquare, Paperclip, Send, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; @@ -16,9 +16,11 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { const [categoryOpen, setCategoryOpen] = useState(false); const [category, setCategory] = useState(''); const [categoryError, setCategoryError] = useState(false); - const [attachmentName, setAttachmentName] = useState(null); + const [attachmentNames, setAttachmentNames] = useState([]); const categoryRef = useRef(null); const successTimerRef = useRef | null>(null); + const accumulatedFilesRef = useRef([]); + const fileInputRef = useRef(null); const categories = [ { value: 'Bug Report', label: t('categoryBug') }, @@ -29,6 +31,12 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { const selectedLabel = categories.find(c => c.value === category)?.label; + function removeFile(name: string) { + const updated = accumulatedFilesRef.current.filter(f => f.name !== name); + accumulatedFilesRef.current = updated; + setAttachmentNames(updated.map(f => f.name)); + } + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -59,12 +67,7 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { }; }, []); - const cardStyles = ` - relative overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-6 sm:p-8 lg:p-10 - `; + const cardStyles = 'dashboard-card p-6 sm:p-8 lg:p-10'; const inputStyles = 'w-full rounded-xl border border-gray-200 dark:border-white/10 bg-white/50 dark:bg-neutral-800/50 px-4 py-3 text-sm text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 outline-none transition-colors focus:border-(--accent-primary) focus:ring-1 focus:ring-(--accent-primary)'; @@ -86,23 +89,15 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { setLoading(true); setStatus('idle'); - const accessKey = process.env.NEXT_PUBLIC_WEB3FORMS_KEY; - if (!accessKey) { - console.error( - 'FeedbackForm: NEXT_PUBLIC_WEB3FORMS_KEY is not defined. Add it to your .env file.' - ); - setStatus('error'); - setLoading(false); - return; - } - const formData = new FormData(e.currentTarget); - formData.append('access_key', accessKey); - formData.append('subject', `DevLovers Feedback: ${formData.get('category')}`); + // Replace the stale file input value with our accumulated files + formData.delete('attachment'); + for (const f of accumulatedFilesRef.current) { + formData.append('attachment', f); + } try { - const res = await fetch('https://api.web3forms.com/submit', { + const res = await fetch('/api/feedback', { method: 'POST', - headers: { Accept: 'application/json' }, body: formData, }); @@ -111,7 +106,8 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { if (result.success) { setStatus('success'); setCategory(''); - setAttachmentName(null); + setAttachmentNames([]); + accumulatedFilesRef.current = []; (e.target as HTMLFormElement).reset(); successTimerRef.current = setTimeout(() => setStatus('idle'), 5000); } else { @@ -152,8 +148,6 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) {
) : (
- -
(e.target as HTMLInputElement).setCustomValidity(t('requiredField'))} - onInput={e => (e.target as HTMLInputElement).setCustomValidity('')} - className={inputStyles} + readOnly + className={`${inputStyles} cursor-default select-none`} />
@@ -244,27 +236,60 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) {
{ - const file = e.target.files?.[0]; - setAttachmentName(file ? file.name : null); + const newFiles = Array.from(e.target.files ?? []); + const merged = [...accumulatedFilesRef.current]; + for (const f of newFiles) { + if (!merged.some(ex => ex.name === f.name)) { + merged.push(f); + } + } + accumulatedFilesRef.current = merged; + setAttachmentNames(merged.map(f => f.name)); + // Reset so the same file can be re-picked and input is ready for next selection + if (fileInputRef.current) fileInputRef.current.value = ''; }} />
+ + {attachmentNames.length > 0 && ( +
    + {attachmentNames.map(name => ( +
  • + + {name} + +
  • + ))} +
+ )} )} ); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx index eccc44c3..b85cda77 100644 --- a/frontend/components/dashboard/ProfileCard.tsx +++ b/frontend/components/dashboard/ProfileCard.tsx @@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { UserAvatar } from '@/components/leaderboard/UserAvatar'; +import { Link } from '@/i18n/routing'; interface ProfileCardProps { user: { @@ -41,13 +42,15 @@ export function ProfileCard({ user.image || `https://api.dicebear.com/9.x/avataaars/svg?seed=${encodeURIComponent(seed)}`; - const cardStyles = ` - relative z-10 flex flex-col overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-5 sm:p-6 lg:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md - hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30 - `; + const cardStyles = 'dashboard-card flex flex-col p-5 sm:p-6 lg:p-8'; + + const scrollTo = (id: string) => (e: React.MouseEvent) => { + e.preventDefault(); + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' }); + }; + + const statItemBase = + 'flex flex-row items-center gap-2 sm:gap-3 rounded-2xl border border-gray-100 bg-white/50 p-2 sm:p-3 text-left dark:border-white/5 dark:bg-black/20 xl:flex-row-reverse xl:items-center xl:text-right xl:p-3 xl:px-4 transition-all hover:border-(--accent-primary)/40 hover:bg-gray-50 dark:hover:bg-white/5 dark:hover:border-(--accent-primary)/20'; return (
@@ -76,23 +79,22 @@ export function ProfileCard({

- {isSponsor ? ( + + {user.role || t('defaultRole')} + + {isSponsor && ( - + {t('sponsor')} - ) : ( - - {user.role || t('defaultRole')} - )}
{/* Attempts */} - -
+ {/* Points */} -
+
@@ -119,22 +121,22 @@ export function ProfileCard({ {user.points}
-
+ {/* Global rank */} -
+
- {t('globalRank', { fallback: 'Global Rank' })} + {t('globalRank')}
{globalRank ? `#${globalRank}` : '—'}
-
+ {/* Joined */}
@@ -268,4 +270,4 @@ export function ProfileCard({ ); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/QuizResultRow.tsx b/frontend/components/dashboard/QuizResultRow.tsx index 6606a445..b336d3be 100644 --- a/frontend/components/dashboard/QuizResultRow.tsx +++ b/frontend/components/dashboard/QuizResultRow.tsx @@ -98,7 +98,7 @@ export function QuizResultRow({ attempt, locale }: QuizResultRowProps) { {/* Mobile layout: left content + right badge */}
-
+
{catStyle && (
- + {attempt.categoryName ?? attempt.categorySlug ?? ''} · @@ -143,7 +143,7 @@ export function QuizResultRow({ attempt, locale }: QuizResultRowProps) { )}
-
+
{t(status.label)} diff --git a/frontend/components/dashboard/QuizResultsSection.tsx b/frontend/components/dashboard/QuizResultsSection.tsx index 9d4520c4..b74ffbae 100644 --- a/frontend/components/dashboard/QuizResultsSection.tsx +++ b/frontend/components/dashboard/QuizResultsSection.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Shield, Star, ClipboardList } from 'lucide-react'; +import { ClipboardList } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { QuizResultRow } from '@/components/dashboard/QuizResultRow'; @@ -15,8 +15,7 @@ interface QuizResultsSectionProps { export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps) { const t = useTranslations('dashboard.quizResults'); - const cardStyles = - 'relative z-10 flex flex-col overflow-hidden rounded-3xl border border-gray-200 bg-white/10 p-6 sm:p-8 lg:p-10 shadow-sm backdrop-blur-md transition-all duration-300 hover:-translate-y-1 hover:shadow-md hover:border-(--accent-primary)/30 dark:border-neutral-800 dark:bg-neutral-900/10 dark:hover:border-(--accent-primary)/30'; + const cardStyles = 'dashboard-card flex flex-col p-6 sm:p-8 lg:p-10'; const primaryBtnStyles = 'group relative inline-flex items-center justify-center rounded-full px-8 py-3 text-sm font-semibold tracking-widest uppercase text-white bg-(--accent-primary) hover:bg-(--accent-hover) transition-all hover:scale-105'; @@ -100,4 +99,4 @@ export function QuizResultsSection({ attempts, locale }: QuizResultsSectionProps
); -} +} \ No newline at end of file diff --git a/frontend/components/dashboard/StatsCard.tsx b/frontend/components/dashboard/StatsCard.tsx index f276f4a4..ce8da4fe 100644 --- a/frontend/components/dashboard/StatsCard.tsx +++ b/frontend/components/dashboard/StatsCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { motion } from 'framer-motion'; -import { History, Target,TrendingUp } from 'lucide-react'; +import { Target } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { Link } from '@/i18n/routing'; @@ -26,13 +26,7 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) { const tProfile = useTranslations('dashboard.profile'); const hasActivity = stats && stats.totalAttempts > 0; - const cardStyles = ` - relative z-10 flex flex-col justify-between overflow-hidden rounded-3xl - border border-gray-200 bg-white/10 shadow-sm backdrop-blur-md - dark:border-neutral-800 dark:bg-neutral-900/10 - p-6 sm:p-8 transition-all duration-300 hover:-translate-y-1 hover:shadow-md - hover:border-(--accent-primary)/30 dark:hover:border-(--accent-primary)/30 - `; + const cardStyles = 'dashboard-card flex flex-col justify-between p-6 sm:p-8'; const primaryBtnStyles = ` group relative inline-flex items-center justify-center rounded-full @@ -93,7 +87,7 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) { ) : ( <>
-
+
@@ -229,4 +223,4 @@ export function StatsCard({ stats, attempts = [] }: StatsCardProps) { )} ); -} +} \ No newline at end of file diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index c401bc6b..68de3c70 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -121,22 +121,14 @@ export function AppMobileMenu({ // Lock body scroll when menu is open useEffect(() => { if (open) { - const scrollY = window.scrollY; - Object.assign(document.body.style, { - position: 'fixed', - top: `-${scrollY}px`, - width: '100%', - overflow: 'hidden', - }); + // Use overflow:hidden on instead of position:fixed on . + // position:fixed shifts the body by -scrollY which hides the sticky + // header; overflow:hidden keeps everything in place. + const prev = document.documentElement.style.overflowY; + document.documentElement.style.overflowY = 'hidden'; return () => { - Object.assign(document.body.style, { - position: '', - top: '', - width: '', - overflow: '', - }); - window.scrollTo(0, scrollY); + document.documentElement.style.overflowY = prev; }; } }, [open]); @@ -326,4 +318,4 @@ export function AppMobileMenu({ )} ); -} +} \ No newline at end of file diff --git a/frontend/components/header/MobileMenuContext.tsx b/frontend/components/header/MobileMenuContext.tsx index b764dd7a..2bd61217 100644 --- a/frontend/components/header/MobileMenuContext.tsx +++ b/frontend/components/header/MobileMenuContext.tsx @@ -51,7 +51,7 @@ export function MobileMenuProvider({ children }: { children: ReactNode }) { setTimeout(() => { setIsOpen(false); wasNavigatingRef.current = false; - }, 200); + }, 310); }, []); const open = useCallback(() => { @@ -76,6 +76,7 @@ export function MobileMenuProvider({ children }: { children: ReactNode }) { const strippedPathname = pathname.replace(/^\/(en|uk|pl)/, '') || '/'; if (strippedPathname === targetPath && targetSearch === currentSearch) { + close(); return; } @@ -85,7 +86,7 @@ export function MobileMenuProvider({ children }: { children: ReactNode }) { setIsNavigating(true); router.push(href); }, - [router, pathname, searchParams] + [router, pathname, searchParams, close] ); useEffect(() => { @@ -137,4 +138,4 @@ export function MobileMenuProvider({ children }: { children: ReactNode }) { {children} ); -} +} \ No newline at end of file diff --git a/frontend/components/leaderboard/AchievementPips.tsx b/frontend/components/leaderboard/AchievementPips.tsx new file mode 100644 index 00000000..80cc581b --- /dev/null +++ b/frontend/components/leaderboard/AchievementPips.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { + Brain, + Code, + Crown, + Diamond, + Fire, + GithubLogo, + Heart, + Infinity as InfinityIcon, + Lightning, + Medal, + Moon, + Rocket, + Seal, + Shield, + Star, + Target, + Trophy, + Waves, +} from '@phosphor-icons/react'; +import { useTranslations } from 'next-intl'; +import { useState, useSyncExternalStore } from 'react'; +import { createPortal } from 'react-dom'; + +import type { Achievement, AchievementIconName } from '@/lib/achievements'; + +const ICON_MAP: Record = { + Fire, + Target, + Lightning, + Brain, + Diamond, + Star, + Heart, + Trophy, + Rocket, + Crown, + Code, + Infinity: InfinityIcon, + GithubLogo, + Medal, + Seal, + Moon, + Shield, + Waves, +}; + +const HEX = 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)'; + +interface TooltipState { + label: string; + x: number; + y: number; +} + +interface AchievementPipsProps { + achievements: Achievement[]; +} + +function getSponsorBadgeClasses(id: string): string { + switch (id) { + case 'golden_patron': + return 'border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400'; + case 'silver_patron': + return 'border-slate-400/30 bg-slate-400/10 text-slate-500 dark:text-slate-300'; + default: + return 'border-(--accent-primary)/20 bg-(--accent-primary)/10 text-(--accent-primary)'; + } +} + +export function AchievementPips({ achievements }: AchievementPipsProps) { + const t = useTranslations('dashboard.achievements'); + const [tooltip, setTooltip] = useState(null); + const mounted = useSyncExternalStore( + () => () => {}, + () => true, + () => false, + ); + + if (!achievements.length) return null; + + // Text pill: golden_patron > silver_patron only + const sponsorBadge = + achievements.find(a => a.id === 'golden_patron') || + achievements.find(a => a.id === 'silver_patron'); + + // Hex pips: supporter + star_gazer — icon-only, clickable + const hexPips = [ + achievements.find(a => a.id === 'supporter'), + achievements.find(a => a.id === 'star_gazer'), + ].filter(Boolean) as Achievement[]; + + const SponsorIcon = sponsorBadge ? ICON_MAP[sponsorBadge.icon] : null; + + // Nothing to show + if (!sponsorBadge && hexPips.length === 0) return null; + + const handleMouseEnter = ( + e: React.MouseEvent, + achievement: Achievement + ) => { + const rect = e.currentTarget.getBoundingClientRect(); + setTooltip({ + label: t(`badges.${achievement.id}.name`), + x: rect.left + rect.width / 2, + y: rect.top, + }); + }; + + const handleMouseLeave = () => setTooltip(null); + + return ( + <> + + {/* ── Sponsor tier text badge (Golden Patron / Silver Patron) ── */} + {sponsorBadge && SponsorIcon && ( + + + {t(`badges.${sponsorBadge.id}.name`)} + + )} + + {/* ── Hex pips: supporter + star_gazer ── */} + {hexPips.length > 0 && ( + + {hexPips.map(achievement => { + const Icon = ICON_MAP[achievement.icon]; + const [from, to] = achievement.gradient; + const href = + achievement.id === 'supporter' + ? 'https://github.com/sponsors/DevLoversTeam' + : 'https://github.com/DevLoversTeam/devlovers.net'; + return ( + handleMouseEnter(e, achievement)} + onMouseLeave={handleMouseLeave} + aria-label={t(`badges.${achievement.id}.name`)} + > +
+
+ +
+
+
+ ); + })} +
+ )} +
+ + {/* ── Portal: hover tooltip ── */} + {mounted && + tooltip && + createPortal( +
+ {tooltip.label} +
+
, + document.body + )} + + ); +} \ No newline at end of file diff --git a/frontend/components/leaderboard/LeaderboardPodium.tsx b/frontend/components/leaderboard/LeaderboardPodium.tsx index 021d750c..041d33f2 100644 --- a/frontend/components/leaderboard/LeaderboardPodium.tsx +++ b/frontend/components/leaderboard/LeaderboardPodium.tsx @@ -9,6 +9,20 @@ import { cn } from '@/lib/utils'; import { User } from './types'; import { UserAvatar } from './UserAvatar'; +const SPONSOR_TIER_STYLE: Record = { + golden_patron: + 'bg-amber-500/10 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400 dark:bg-amber-500/15 dark:hover:bg-amber-500/25', + silver_patron: + 'bg-slate-400/10 text-slate-600 hover:bg-slate-400/20 dark:text-slate-300 dark:bg-slate-400/15 dark:hover:bg-slate-400/25', +}; + +function getSponsorAchievement(user: User) { + return ( + user.achievements?.find(a => a.id === 'golden_patron') || + user.achievements?.find(a => a.id === 'silver_patron') + ); +} + const rankConfig = { 1: { height: '70%', @@ -46,7 +60,7 @@ const rankConfig = { } as const; export function LeaderboardPodium({ topThree }: { topThree: User[] }) { - const t = useTranslations('leaderboard'); + const tBadges = useTranslations('dashboard.achievements.badges'); const podiumOrder = [ topThree.find(u => u.rank === 2), topThree.find(u => u.rank === 1), @@ -111,18 +125,29 @@ export function LeaderboardPodium({ topThree }: { topThree: User[] }) { {user.username}
- {user.isSponsor && ( - - - )} + {(() => { + const sponsorAch = getSponsorAchievement(user); + if (!sponsorAch) return null; + const tierStyle = + SPONSOR_TIER_STYLE[sponsorAch.id] ?? SPONSOR_TIER_STYLE['silver_patron']; + return ( + + + ); + })()}
); -} +} \ No newline at end of file diff --git a/frontend/components/leaderboard/LeaderboardTable.tsx b/frontend/components/leaderboard/LeaderboardTable.tsx index d75469c8..eb529a6c 100644 --- a/frontend/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/components/leaderboard/LeaderboardTable.tsx @@ -1,11 +1,11 @@ 'use client'; -import { motion } from 'framer-motion'; -import { Heart, Medal, TrendingUp, Trophy } from 'lucide-react'; +import { Medal, TrendingUp, Trophy } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { cn } from '@/lib/utils'; +import { AchievementPips } from './AchievementPips'; import { CurrentUser, User } from './types'; import { UserAvatar } from './UserAvatar'; @@ -103,33 +103,38 @@ export function LeaderboardTable({
)} -
-
- - - - - - - - - {contextRows.map(user => { - const isMe = - user.userId === normalizedCurrentUserId || - (currentUsername && user.username === currentUsername); +
+ {contextRows.map(user => { + const isMe = + user.userId === normalizedCurrentUserId || + (currentUsername && user.username === currentUsername); - return ( + return ( +
+
{t('contextTableCaption')}
+ + + + + + - ); - })} - -
-
+ + +
+ ); + })}
)} @@ -140,24 +145,40 @@ export function LeaderboardTable({ function TableRow({ user, isCurrentUser, + inContext = false, t, }: { user: User; isCurrentUser: boolean; + inContext?: boolean; t: ReturnType; }) { - const cellClass = - 'px-2 sm:px-6 py-3 sm:py-4 border-b border-gray-200/50 dark:border-white/5'; + // inContext = true → glow is on the wrapper div; cells stay plain + // inContext = false → top-15 row; accent borders drawn on cells directly + const cellClass = cn( + 'px-2 sm:px-6 py-3 sm:py-4 border-b', + isCurrentUser && !inContext + ? 'border-t border-t-(--accent-primary)/60 border-b-(--accent-primary)/60' + : 'border-b-gray-200/50 dark:border-b-white/5' + ); - const leftBorderClass = 'border-l border-l-transparent'; - const rightBorderClass = 'border-r border-r-transparent'; + const leftBorderClass = cn( + isCurrentUser && !inContext + ? 'border-l-2 border-l-(--accent-primary)/70' + : 'border-l border-l-transparent' + ); + const rightBorderClass = cn( + isCurrentUser && !inContext + ? 'border-r-2 border-r-(--accent-primary)/70' + : 'border-r border-r-transparent' + ); return ( @@ -195,38 +216,8 @@ function TableRow({ > {user.username} - {user.isSponsor && ( - - - {t('sponsor')} - - )} - - {isCurrentUser && ( -
- - - - - -
+{user.achievements && user.achievements.length > 0 && ( + )} @@ -295,4 +286,4 @@ function RankBadge({ rank }: { rank: number }) { {rank} ); -} +} \ No newline at end of file diff --git a/frontend/components/leaderboard/types.ts b/frontend/components/leaderboard/types.ts index e83a0fd4..54b2e897 100644 --- a/frontend/components/leaderboard/types.ts +++ b/frontend/components/leaderboard/types.ts @@ -1,3 +1,5 @@ +import type { Achievement } from '@/lib/achievements'; + export interface User { id: number; userId: string; @@ -7,9 +9,10 @@ export interface User { avatar: string; change: number; isSponsor?: boolean; + achievements?: Achievement[]; } export interface CurrentUser { id: string; username: string; -} +} \ No newline at end of file diff --git a/frontend/lib/github-stars.ts b/frontend/lib/github-stars.ts index 1d799c31..fffa6201 100644 --- a/frontend/lib/github-stars.ts +++ b/frontend/lib/github-stars.ts @@ -38,11 +38,45 @@ export async function resolveGitHubLogin(providerId: string): Promise { + const token = getToken(); + if (!token) return []; + + const all: StargazerEntry[] = []; + + for (let page = 1; page <= MAX_PAGES; page++) { + const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/stargazers?per_page=100&page=${page}`; + try { + const res = await fetch(url, { + headers: makeHeaders(), + next: { revalidate: 3600 }, + }); + if (!res.ok) break; + + const pageData: { login: string; avatar_url: string }[] = await res.json(); + if (pageData.length === 0) break; + + for (const s of pageData) { + all.push({ + login: s.login.toLowerCase(), + avatarBase: s.avatar_url.split('?')[0], + }); + } + + if (pageData.length < 100) break; + } catch { + break; + } + } + + return all; +} + export async function checkHasStarredRepo( githubLogin: string, ): Promise { @@ -57,7 +91,7 @@ export async function checkHasStarredRepo( try { const res = await fetch(url, { headers: makeHeaders(), - next: { revalidate: 3600 }, // cache 1 hour per page + next: { revalidate: 3600 }, }); if (!res.ok) { @@ -67,14 +101,11 @@ export async function checkHasStarredRepo( const stargazers: { login: string }[] = await res.json(); - // Empty page = we've exhausted all stargazers if (stargazers.length === 0) return false; if (stargazers.some((s) => s.login.toLowerCase() === loginLower)) { return true; } - - // Last page (less than 100 results) — user not found if (stargazers.length < 100) return false; } catch (err) { console.error('❌ Failed to check GitHub stargazers:', err); @@ -83,4 +114,4 @@ export async function checkHasStarredRepo( } return false; -} +} \ No newline at end of file diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f7a35303..f0697239 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1211,7 +1211,9 @@ "expand": "View all", "collapse": "Collapse", "clickInfo": "Click for info", - "clickBack": "Click to flip back" + "clickBack": "Click to flip back", + "more": "more", + "moreCount": "{count} more" }, "badges": { "first_blood": { diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 6d72c0a9..da9960c3 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -1088,6 +1088,7 @@ "profile": { "title": "Profil", "subtitle": "Zarządzaj swoim profilem publicznym i preferencjami", + "sponsor": "Sponsor", "becomeSponsor": "Zostań sponsorem", "supportAgain": "Okaż więcej wsparcia", "points": "Pkt", @@ -1212,7 +1213,9 @@ "expand": "Zobacz wszystkie", "collapse": "Zwiń", "clickInfo": "Kliknij po info", - "clickBack": "Kliknij, aby wrócić" + "clickBack": "Kliknij, aby wrócić", + "more": "więcej", + "moreCount": "{count} więcej" }, "badges": { "first_blood": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 01de8ea0..b0dec453 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -1120,7 +1120,7 @@ "avgScore": "Середній бал", "mastered": "Засвоєно", "review": "Потребує повторення", - "study": "Вивчення", + "study": "Потребує вивчення", "totalAttempts": "Всього спроб", "noActivity": "Ви ще не проходили жодного квізу. Почніть навчання та відстежуйте свій прогрес тут!", "startQuiz": "Почати перший квіз", @@ -1249,7 +1249,7 @@ "hint": "Отримайте 100% у 3 квізах" }, "supporter": { - "name": "Підтримувач", + "name": "Спонсор", "desc": "Дякуємо за підтримку!", "hint": "Станьте спонсором на GitHub" }, @@ -1279,7 +1279,7 @@ "hint": "Наберіть 1000 балів" }, "star_gazer": { - "name": "Зоряний глядач", + "name": "Володар зірки", "desc": "Ти додав зірочку репозиторію DevLovers на GitHub!", "hint": "Постав зірочку нашому GitHub репозиторію" }, @@ -1304,7 +1304,7 @@ "hint": "Наберіть 100 балів" }, "deep_diver": { - "name": "Глибокий ныряльщик", + "name": "Глибоке занурення", "desc": "Середній результат 80%+ за 10+ квізів!", "hint": "Тримай середній результат 80%+ у 10+ спробах" } From a1da4753cc2ab92e364479ee497256104bbd2632 Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Sat, 21 Feb 2026 20:09:35 -0500 Subject: [PATCH 2/2] fix: leaderboard table semantics, feedback UX & i18n --- frontend/app/api/feedback/route.ts | 26 +++++++- .../dashboard/ExplainedTermsCard.tsx | 25 ++++---- .../components/dashboard/FeedbackForm.tsx | 18 +++++- frontend/components/header/AppMobileMenu.tsx | 34 +++++++---- .../leaderboard/AchievementPips.tsx | 32 +++++++--- .../leaderboard/LeaderboardTable.tsx | 60 +++++++++---------- frontend/lib/github-stars.ts | 8 ++- frontend/messages/en.json | 4 +- frontend/messages/pl.json | 4 +- frontend/messages/uk.json | 4 +- 10 files changed, 141 insertions(+), 74 deletions(-) diff --git a/frontend/app/api/feedback/route.ts b/frontend/app/api/feedback/route.ts index a9cbcdb8..50132abe 100644 --- a/frontend/app/api/feedback/route.ts +++ b/frontend/app/api/feedback/route.ts @@ -1,8 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import nodemailer from 'nodemailer'; +import { + enforceRateLimit, + getRateLimitSubject, + rateLimitResponse, +} from '@/lib/security/rate-limit'; + const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB const MAX_FILES = 5; +const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,64}@[a-zA-Z0-9.-]{1,253}$/; function escapeHtml(str: string): string { return str @@ -14,6 +21,14 @@ function escapeHtml(str: string): string { } export async function POST(req: NextRequest) { + const subject = getRateLimitSubject(req); + const rl = await enforceRateLimit({ + key: `feedback:${subject}`, + limit: 5, + windowSeconds: 3600, // 5 submissions per IP per hour + }); + if (!rl.ok) return rateLimitResponse({ retryAfterSeconds: rl.retryAfterSeconds }); + const gmailUser = process.env.GMAIL_USER; const gmailPass = process.env.GMAIL_APP_PASSWORD; const emailFrom = process.env.EMAIL_FROM; @@ -39,6 +54,13 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: false }, { status: 400 }); } + if (!EMAIL_RE.test(email)) { + return NextResponse.json({ success: false }, { status: 400 }); + } + + // Strip CRLF and RFC 5322 specials from email before use in headers + const safeEmail = email.replace(/[\r\n<>"]/g, ''); + const rawFiles = formData.getAll('attachment'); const files = rawFiles.filter((f): f is File => f instanceof File && f.size > 0); @@ -67,9 +89,9 @@ export async function POST(req: NextRequest) { try { await mailer.sendMail({ from: emailFrom, - replyTo: safeNameForHeader ? `"${safeNameForHeader}" <${email}>` : email, + replyTo: safeNameForHeader ? `"${safeNameForHeader}" <${safeEmail}>` : safeEmail, to: emailFrom, - subject: `DevLovers Feedback: ${escapeHtml(category)}`, + subject: `DevLovers Feedback: ${category.replace(/[\r\n]/g, '')}`, html: `

Name: ${escapeHtml(name)}

Email: ${escapeHtml(email)}

diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index 11fbe3ab..6d13e356 100644 --- a/frontend/components/dashboard/ExplainedTermsCard.tsx +++ b/frontend/components/dashboard/ExplainedTermsCard.tsx @@ -2,7 +2,7 @@ import { BookOpen, ChevronDown, GripVertical, RotateCcw, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { startTransition, useCallback, useEffect, useRef, useState } from 'react'; import AIWordHelper from '@/components/q&a/AIWordHelper'; import { getCachedTerms } from '@/lib/ai/explainCache'; @@ -15,22 +15,17 @@ import { saveTermOrder, sortTermsByOrder } from '@/lib/ai/termOrder'; export function ExplainedTermsCard() { const t = useTranslations('dashboard.explainedTerms'); - const [terms, setTerms] = useState(() => { - if (typeof window === 'undefined') return []; - const cached = getCachedTerms(); - const hidden = getHiddenTerms(); - return sortTermsByOrder( - cached.filter(term => !hidden.has(term.toLowerCase().trim())) - ); - }); - const [hiddenTerms, setHiddenTerms] = useState(() => { - if (typeof window === 'undefined') return []; + const [terms, setTerms] = useState([]); + const [hiddenTerms, setHiddenTerms] = useState([]); + + useEffect(() => { const cached = getCachedTerms(); const hidden = getHiddenTerms(); - return sortTermsByOrder( - cached.filter(term => hidden.has(term.toLowerCase().trim())) - ); - }); + startTransition(() => { + setTerms(sortTermsByOrder(cached.filter(term => !hidden.has(term.toLowerCase().trim())))); + setHiddenTerms(sortTermsByOrder(cached.filter(term => hidden.has(term.toLowerCase().trim())))); + }); + }, []); const [showMore, setShowMore] = useState(false); const [selectedTerm, setSelectedTerm] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); diff --git a/frontend/components/dashboard/FeedbackForm.tsx b/frontend/components/dashboard/FeedbackForm.tsx index cd6a5f71..33f00e87 100644 --- a/frontend/components/dashboard/FeedbackForm.tsx +++ b/frontend/components/dashboard/FeedbackForm.tsx @@ -4,6 +4,8 @@ import { ChevronDown, MessageSquare, Paperclip, Send, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useEffect, useRef, useState } from 'react'; +const MAX_FILES = 5; + interface FeedbackFormProps { userName?: string | null; userEmail?: string | null; @@ -17,6 +19,7 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { const [category, setCategory] = useState(''); const [categoryError, setCategoryError] = useState(false); const [attachmentNames, setAttachmentNames] = useState([]); + const [fileLimitError, setFileLimitError] = useState(false); const categoryRef = useRef(null); const successTimerRef = useRef | null>(null); const accumulatedFilesRef = useRef([]); @@ -35,6 +38,7 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { const updated = accumulatedFilesRef.current.filter(f => f.name !== name); accumulatedFilesRef.current = updated; setAttachmentNames(updated.map(f => f.name)); + if (updated.length < MAX_FILES) setFileLimitError(false); } useEffect(() => { @@ -246,11 +250,17 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { onChange={(e) => { const newFiles = Array.from(e.target.files ?? []); const merged = [...accumulatedFilesRef.current]; + let limitReached = false; for (const f of newFiles) { if (!merged.some(ex => ex.name === f.name)) { + if (merged.length >= MAX_FILES) { + limitReached = true; + break; + } merged.push(f); } } + if (limitReached) setFileLimitError(true); accumulatedFilesRef.current = merged; setAttachmentNames(merged.map(f => f.name)); // Reset so the same file can be re-picked and input is ready for next selection @@ -267,6 +277,12 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) {
+ {fileLimitError && ( +

+ {t('tooManyFiles', { max: MAX_FILES })} +

+ )} + {attachmentNames.length > 0 && (
    {attachmentNames.map(name => ( @@ -280,7 +296,7 @@ export function FeedbackForm({ userName, userEmail }: FeedbackFormProps) { type="button" onClick={() => removeFile(name)} className="ml-0.5 rounded-full p-0.5 hover:bg-gray-200 dark:hover:bg-neutral-700" - aria-label={`Remove ${name}`} + aria-label={t('removeFile', { name })} > diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index 68de3c70..77f8e4f7 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -118,19 +118,29 @@ export function AppMobileMenu({ : 'text-muted-foreground active:text-[var(--accent-hover)]' }`; - // Lock body scroll when menu is open + // Lock body scroll when menu is open. + // overflow:hidden on works on desktop but iOS Safari ignores it for + // touch events. Adding a non-passive touchmove listener lets us call + // preventDefault() to block background scrolling while still allowing the + // nav itself (which has overflow-y-auto) to scroll normally. useEffect(() => { - if (open) { - // Use overflow:hidden on instead of position:fixed on . - // position:fixed shifts the body by -scrollY which hides the sticky - // header; overflow:hidden keeps everything in place. - const prev = document.documentElement.style.overflowY; - document.documentElement.style.overflowY = 'hidden'; - - return () => { - document.documentElement.style.overflowY = prev; - }; - } + if (!open) return; + + const prev = document.documentElement.style.overflowY; + document.documentElement.style.overflowY = 'hidden'; + + const preventTouchMove = (e: TouchEvent) => { + const nav = document.getElementById('app-mobile-nav'); + if (nav && nav.contains(e.target as Node)) return; + e.preventDefault(); + }; + + document.addEventListener('touchmove', preventTouchMove, { passive: false }); + + return () => { + document.documentElement.style.overflowY = prev; + document.removeEventListener('touchmove', preventTouchMove); + }; }, [open]); return ( diff --git a/frontend/components/leaderboard/AchievementPips.tsx b/frontend/components/leaderboard/AchievementPips.tsx index 80cc581b..1070a754 100644 --- a/frontend/components/leaderboard/AchievementPips.tsx +++ b/frontend/components/leaderboard/AchievementPips.tsx @@ -21,7 +21,7 @@ import { Waves, } from '@phosphor-icons/react'; import { useTranslations } from 'next-intl'; -import { useState, useSyncExternalStore } from 'react'; +import { useRef, useState, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import type { Achievement, AchievementIconName } from '@/lib/achievements'; @@ -49,6 +49,10 @@ const ICON_MAP: Record = { const HEX = 'polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)'; +// Half the maximum tooltip width (generous estimate for longest badge label). +// Used to clamp the tooltip anchor so it never overflows the viewport edge. +const TOOLTIP_HALF_W = 60; + interface TooltipState { label: string; x: number; @@ -73,6 +77,7 @@ function getSponsorBadgeClasses(id: string): string { export function AchievementPips({ achievements }: AchievementPipsProps) { const t = useTranslations('dashboard.achievements'); const [tooltip, setTooltip] = useState(null); + const scrollResizeCleanupRef = useRef<(() => void) | null>(null); const mounted = useSyncExternalStore( () => () => {}, () => true, @@ -102,14 +107,27 @@ export function AchievementPips({ achievements }: AchievementPipsProps) { achievement: Achievement ) => { const rect = e.currentTarget.getBoundingClientRect(); - setTooltip({ - label: t(`badges.${achievement.id}.name`), - x: rect.left + rect.width / 2, - y: rect.top, - }); + const rawX = rect.left + rect.width / 2; + const x = Math.min( + Math.max(rawX, TOOLTIP_HALF_W), + window.innerWidth - TOOLTIP_HALF_W + ); + setTooltip({ label: t(`badges.${achievement.id}.name`), x, y: rect.top }); + + const hide = () => setTooltip(null); + window.addEventListener('scroll', hide, { passive: true, capture: true }); + window.addEventListener('resize', hide, { passive: true }); + scrollResizeCleanupRef.current = () => { + window.removeEventListener('scroll', hide, { capture: true }); + window.removeEventListener('resize', hide); + }; }; - const handleMouseLeave = () => setTooltip(null); + const handleMouseLeave = () => { + setTooltip(null); + scrollResizeCleanupRef.current?.(); + scrollResizeCleanupRef.current = null; + }; return ( <> diff --git a/frontend/components/leaderboard/LeaderboardTable.tsx b/frontend/components/leaderboard/LeaderboardTable.tsx index eb529a6c..cc310dc9 100644 --- a/frontend/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/components/leaderboard/LeaderboardTable.tsx @@ -104,37 +104,30 @@ export function LeaderboardTable({ )}
    - {contextRows.map(user => { - const isMe = - user.userId === normalizedCurrentUserId || - (currentUsername && user.username === currentUsername); + + + + + + + + {contextRows.map(user => { + const isMe = + user.userId === normalizedCurrentUserId || + (currentUsername && user.username === currentUsername); - return ( -
    -
    - - - - - - - - -
    -
    - ); - })} + return ( + + ); + })} + +
)} @@ -153,7 +146,7 @@ function TableRow({ inContext?: boolean; t: ReturnType; }) { - // inContext = true → glow is on the wrapper div; cells stay plain + // inContext = true → glow applied on ; cells stay plain // inContext = false → top-15 row; accent borders drawn on cells directly const cellClass = cn( 'px-2 sm:px-6 py-3 sm:py-4 border-b', @@ -179,7 +172,10 @@ function TableRow({ 'group transition-all duration-300', isCurrentUser ? 'bg-[color-mix(in_srgb,var(--accent-primary),transparent_90%)]' - : 'hover:bg-white/30 dark:hover:bg-white/5' + : 'hover:bg-white/30 dark:hover:bg-white/5', + isCurrentUser && + inContext && + '[box-shadow:inset_0_0_0_2px_color-mix(in_srgb,var(--accent-primary)_70%,transparent),inset_0_0_20px_color-mix(in_srgb,var(--accent-primary)_30%,transparent)]' )} > diff --git a/frontend/lib/github-stars.ts b/frontend/lib/github-stars.ts index fffa6201..b517aa2f 100644 --- a/frontend/lib/github-stars.ts +++ b/frontend/lib/github-stars.ts @@ -56,7 +56,10 @@ export async function getAllStargazers(): Promise { headers: makeHeaders(), next: { revalidate: 3600 }, }); - if (!res.ok) break; + if (!res.ok) { + console.warn(`⚠️ GitHub stargazers API error [${url}]: ${res.status} ${res.statusText}`); + break; + } const pageData: { login: string; avatar_url: string }[] = await res.json(); if (pageData.length === 0) break; @@ -69,7 +72,8 @@ export async function getAllStargazers(): Promise { } if (pageData.length < 100) break; - } catch { + } catch (err) { + console.error(`❌ Failed to fetch GitHub stargazers [${url}]:`, err); break; } } diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f0697239..2f1cc9bf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1202,7 +1202,9 @@ "attachFile": "Attach File", "success": "Thank you! Your feedback has been sent.", "error": "Something went wrong. Please try again.", - "requiredField": "Please fill out this field." + "requiredField": "Please fill out this field.", + "tooManyFiles": "You can attach up to {max} files.", + "removeFile": "Remove {name}" }, "achievements": { "title": "Achievements", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index da9960c3..5ca8de2f 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -1204,7 +1204,9 @@ "attachFile": "Dołącz plik", "success": "Dziękujemy! Twoja opinia została wysłana.", "error": "Coś poszło nie tak. Spróbuj ponownie.", - "requiredField": "Proszę wypełnić to pole." + "requiredField": "Proszę wypełnić to pole.", + "tooManyFiles": "Możesz dołączyć maksymalnie {max} pliki.", + "removeFile": "Usuń {name}" }, "achievements": { "title": "Osiągnięcia", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index b0dec453..7c144be5 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -1206,7 +1206,9 @@ "attachFile": "Прикріпити файл", "success": "Дякуємо! Ваш відгук надіслано.", "error": "Щось пішло не так. Спробуйте ще раз.", - "requiredField": "Будь ласка, заповніть це поле." + "requiredField": "Будь ласка, заповніть це поле.", + "tooManyFiles": "Можна прикріпити не більше {max} файлів.", + "removeFile": "Видалити {name}" }, "achievements": { "title": "Досягнення",