From 96a7ebf95dff822b81ab4932bbf49172746e429c Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Thu, 12 Feb 2026 03:05:36 -0500 Subject: [PATCH 01/16] feat(mobile): improve dashboard, leaderboard & AI helper UX for touch devices - Add touch drag support for AI helper modal and explained terms reorder - Position explain button below selected word on mobile - Show delete/restore buttons always visible on mobile (no hover) - Add user avatar to dashboard profile card (same as leaderboard) - Fix leaderboard page layout - Fix Tailwind v4 canonical class warnings --- frontend/app/[locale]/dashboard/page.tsx | 2 + .../dashboard/ExplainedTermsCard.tsx | 179 +++++++++++++++++- frontend/components/dashboard/ProfileCard.tsx | 21 +- frontend/components/header/MainSwitcher.tsx | 7 +- .../leaderboard/LeaderboardClient.tsx | 4 +- frontend/components/q&a/AIWordHelper.tsx | 41 +++- frontend/components/q&a/AccordionList.tsx | 5 + .../components/q&a/FloatingExplainButton.tsx | 7 +- frontend/components/q&a/SelectableText.tsx | 3 +- 9 files changed, 249 insertions(+), 20 deletions(-) diff --git a/frontend/app/[locale]/dashboard/page.tsx b/frontend/app/[locale]/dashboard/page.tsx index be15de5f..7c19c8a0 100644 --- a/frontend/app/[locale]/dashboard/page.tsx +++ b/frontend/app/[locale]/dashboard/page.tsx @@ -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, diff --git a/frontend/components/dashboard/ExplainedTermsCard.tsx b/frontend/components/dashboard/ExplainedTermsCard.tsx index 6cb480b3..c0092954 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 { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import AIWordHelper from '@/components/q&a/AIWordHelper'; import { getCachedTerms } from '@/lib/ai/explainCache'; @@ -21,6 +21,13 @@ export function ExplainedTermsCard() { const [selectedTerm, setSelectedTerm] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); const [draggedIndex, setDraggedIndex] = useState(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(() => { @@ -83,6 +90,129 @@ export function ExplainedTermsCard() { setDraggedIndex(null); }; + // Touch drag support for mobile + const touchDragIndex = useRef(null); + const termRefs = useRef>(new Map()); + const dragTargetIndex = useRef(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); + }; + }, []); + const handleTermClick = (term: string) => { setSelectedTerm(term); setIsModalOpen(true); @@ -132,19 +262,39 @@ export function ExplainedTermsCard() {

{t('termCount', { count: terms.length })}

-
- {terms.map((term, index) => ( +
+ {terms.map((term, index) => { + const isSource = + touchDragState !== null && + index === touchDragState.sourceIndex; + const isDropTarget = + touchDragState !== null && + index === touchDragState.targetIndex && + index !== touchDragState.sourceIndex; + + return (
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`} >
- ))} + ); + })}
) : ( @@ -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" > @@ -245,6 +396,20 @@ export function ExplainedTermsCard() { onClose={handleModalClose} /> )} + + {touchDragState && ( +
+ + {touchDragState.label} +
+ )} ); } diff --git a/frontend/components/dashboard/ProfileCard.tsx b/frontend/components/dashboard/ProfileCard.tsx index a3f77fe1..601db04e 100644 --- a/frontend/components/dashboard/ProfileCard.tsx +++ b/frontend/components/dashboard/ProfileCard.tsx @@ -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; @@ -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 @@ -27,11 +36,15 @@ export function ProfileCard({ user, locale }: ProfileCardProps) {
diff --git a/frontend/app/[locale]/quiz/[slug]/page.tsx b/frontend/app/[locale]/quiz/[slug]/page.tsx index 47145586..5e3305f1 100644 --- a/frontend/app/[locale]/quiz/[slug]/page.tsx +++ b/frontend/app/[locale]/quiz/[slug]/page.tsx @@ -5,10 +5,10 @@ import { getTranslations } from 'next-intl/server'; import { QuizContainer } from '@/components/quiz/QuizContainer'; import { categoryTabStyles } from '@/data/categoryStyles'; -import { cn } from '@/lib/utils'; import { stripCorrectAnswers } from '@/db/queries/quiz'; import { getQuizBySlug, getQuizQuestionsRandomized } from '@/db/queries/quiz'; import { getCurrentUser } from '@/lib/auth'; +import { cn } from '@/lib/utils'; type MetadataProps = { params: Promise<{ locale: string; slug: string }> }; @@ -52,9 +52,10 @@ export default async function QuizPage({ notFound(); } - const categoryStyle = quiz.categorySlug - ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] - : null; + const categoryStyle = + quiz.categorySlug && quiz.categorySlug in categoryTabStyles + ? categoryTabStyles[quiz.categorySlug as keyof typeof categoryTabStyles] + : null; const parsedSeed = seedParam ? Number.parseInt(seedParam, 10) : Number.NaN; const seed = Number.isFinite(parsedSeed) diff --git a/frontend/components/quiz/QuizQuestion.tsx b/frontend/components/quiz/QuizQuestion.tsx index 75ab5a3d..3be075f7 100644 --- a/frontend/components/quiz/QuizQuestion.tsx +++ b/frontend/components/quiz/QuizQuestion.tsx @@ -1,7 +1,7 @@ 'use client'; - import { BookOpen, Check, Lightbulb, X } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useEffect, useRef } from 'react'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { QuizQuestionClient } from '@/db/queries/quiz'; @@ -36,6 +36,14 @@ export function QuizQuestion({ const isCorrectAnswer = isRevealed && isCorrect; + const nextButtonRef = useRef(null); + + useEffect(() => { + if (isRevealed) { + nextButtonRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [isRevealed]); + return (
@@ -130,6 +138,7 @@ export function QuizQuestion({ )} {isRevealed && ( + ))} +
+ + +
+ ); +} diff --git a/frontend/components/home/FloatingCode.tsx b/frontend/components/home/FloatingCode.tsx new file mode 100644 index 00000000..16d0e151 --- /dev/null +++ b/frontend/components/home/FloatingCode.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { useEffect, useRef, useState } from 'react'; + +interface CodeSnippet { + id: string; + code: string; + language: string; + position: { x: string; y: string }; + rotate: number; + delay: number; + color: string; +} + +const snippets: CodeSnippet[] = [ + { + id: 'react-hook', + code: 'useEffect(() => {\n fetchData();\n}, []);', + language: 'react', + position: { x: '12%', y: '25%' }, + rotate: -6, + delay: 0, + color: '#61DAFB', + }, + { + id: 'css-grid', + code: '.grid {\n display: grid;\n gap: 1rem;\n}', + language: 'css', + position: { x: '8%', y: '55%' }, + rotate: 3, + delay: 0.2, + color: '#1572B6', + }, + { + id: 'git-cmd', + code: 'git commit -m\n"feat: init"', + language: 'shell', + position: { x: '15%', y: '80%' }, + rotate: -4, + delay: 0.4, + color: '#F05032', + }, + { + id: 'ts-interface', + code: 'interface User {\n id: number;\n name: string;\n}', + language: 'typescript', + position: { x: '88%', y: '20%' }, + rotate: 5, + delay: 0.1, + color: '#3178C6', + }, + { + id: 'sql-query', + code: 'SELECT * FROM\nusers WHERE\nactive = true;', + language: 'sql', + position: { x: '92%', y: '50%' }, + rotate: -3, + delay: 0.3, + color: '#e34c26', + }, + { + id: 'js-async', + code: 'const data =\nawait api.get();', + language: 'javascript', + position: { x: '85%', y: '75%' }, + rotate: 4, + delay: 0.5, + color: '#F7DF1E', + }, +]; + +function CodeBlock({ snippet }: { snippet: CodeSnippet }) { + const [displayedCode, setDisplayedCode] = useState(''); + const [isTyping, setIsTyping] = useState(false); + + // Refs for cleanup + const typeIntervalRef = useRef(null); + const resetTimeoutRef = useRef(null); + const startTimeoutRef = useRef(null); + const initialTimeoutRef = useRef(null); + + useEffect(() => { + let currentIndex = 0; + const code = snippet.code; + const typingSpeed = 50 + Math.random() * 30; + + const startTyping = () => { + setIsTyping(true); + setDisplayedCode(''); + currentIndex = 0; + + // Clear any existing interval just in case + if (typeIntervalRef.current) clearInterval(typeIntervalRef.current); + + typeIntervalRef.current = setInterval(() => { + if (currentIndex < code.length) { + setDisplayedCode(code.substring(0, currentIndex + 1)); + currentIndex++; + } else { + if (typeIntervalRef.current) clearInterval(typeIntervalRef.current); + setIsTyping(false); + + resetTimeoutRef.current = setTimeout(() => { + setDisplayedCode(''); + startTimeoutRef.current = setTimeout(startTyping, 1000 + Math.random() * 2000); + }, 4000 + Math.random() * 2000); + } + }, typingSpeed); + }; + + initialTimeoutRef.current = setTimeout(startTyping, snippet.delay * 1000); + + return () => { + if (initialTimeoutRef.current) clearTimeout(initialTimeoutRef.current); + if (typeIntervalRef.current) clearInterval(typeIntervalRef.current); + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + if (startTimeoutRef.current) clearTimeout(startTimeoutRef.current); + }; + }, [snippet.code, snippet.delay]); + + return ( + + +
+          
+            {displayedCode.split('\n').map((line, i) => (
+              
+ {i + 1} + + {line} + {i === displayedCode.split('\n').length - 1 && ( + + )} + +
+ ))} +
+
+
+
+ ); +} + +export function FloatingCode() { + const [isMounted, setIsMounted] = useState(false); + + useEffect(() => { + setIsMounted(true); + }, []); + + if (!isMounted) return null; + + return ( + + ); +} diff --git a/frontend/components/home/HeroCodeCards.tsx b/frontend/components/home/HeroCodeCards.tsx deleted file mode 100644 index e8e21244..00000000 --- a/frontend/components/home/HeroCodeCards.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { CodeCard } from './CodeCard'; - -export function HeroCodeCards() { - return ( - <> - - type Arr1 = [ - - 'a' - - ,{' '} - - 'b' - - ,{' '} - - 'c' - - ]{'\n'} - type Arr2 = [ - 3,{' '} - 2,{' '} - 1] - - } - /> - - - function sum( - - a - ,{' '} - b){' '} - {'{'} - {'\n'} - {' '} - return{' '} - a{' '} - +{' '} - b; - {'\n'} - {'}'} - - } - /> - - ); -} diff --git a/frontend/components/home/HeroSection.tsx b/frontend/components/home/HeroSection.tsx deleted file mode 100644 index eba79791..00000000 --- a/frontend/components/home/HeroSection.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import * as React from 'react'; - -import { OnlineCounterPopup } from '@/components/shared/OnlineCounterPopup'; - -import { HeroBackground } from './HeroBackground'; -import { HeroCodeCards } from './HeroCodeCards'; -import { InteractiveCTAButton } from './InteractiveCTAButton'; - -export default function HeroSection() { - const ctaRef = React.useRef(null); - const t = useTranslations('homepage'); - - return ( -
- - -
- - -

- {t('subtitle')} -

- -
-

- - DevLovers - - - -

-
- -

- {t('description')} -

- -
- - -
-
-
- ); -} diff --git a/frontend/components/home/InteractiveCTAButton.tsx b/frontend/components/home/InteractiveCTAButton.tsx index 381ddb74..b057b111 100644 --- a/frontend/components/home/InteractiveCTAButton.tsx +++ b/frontend/components/home/InteractiveCTAButton.tsx @@ -1,22 +1,24 @@ 'use client'; -import { AnimatePresence, motion } from 'framer-motion'; -import { Heart } from 'lucide-react'; +import { AnimatePresence, motion, useMotionTemplate, useMotionValue, useSpring } from 'framer-motion'; import { useTranslations } from 'next-intl'; -import * as React from 'react'; - +import React, { useRef, useState, useEffect } from 'react'; import { Link } from '@/i18n/routing'; +const MotionLink = motion(Link); + export const InteractiveCTAButton = React.forwardRef( - function InteractiveCTAButton(_, ref) { + function InteractiveCTAButton(props, forwardedRef) { const t = useTranslations('homepage'); - - const [variantIndex, setVariantIndex] = React.useState(1); - const [isHovered, setIsHovered] = React.useState(false); - const [currentText, setCurrentText] = React.useState(t('cta')); - const [isFirstRender, setIsFirstRender] = React.useState(true); + const internalRef = useRef(null); + const [isHovered, setIsHovered] = useState(false); + + const [currentText, setCurrentText] = useState(t('cta')); + const [variantIndex, setVariantIndex] = useState(0); + const [isFirstRender, setIsFirstRender] = useState(true); const textVariants = [ + t('cta'), t('ctaVariants.1'), t('ctaVariants.2'), t('ctaVariants.3'), @@ -27,139 +29,110 @@ export const InteractiveCTAButton = React.forwardRef( t('ctaVariants.8'), ]; - React.useEffect(() => { - setIsFirstRender(false); + useEffect(() => { + setIsFirstRender(false); }, []); - const handleEnter = () => { - if (!window.matchMedia('(hover: hover)').matches) return; - setIsHovered(true); - setCurrentText(textVariants[variantIndex]); + const x = useMotionValue(0); + const y = useMotionValue(0); + + const springConfig = { damping: 15, stiffness: 150, mass: 0.1 }; + const springX = useSpring(x, springConfig); + const springY = useSpring(y, springConfig); + + const rotate = useMotionValue(0); + const background = useMotionTemplate`linear-gradient(${rotate}deg, var(--accent-primary), var(--accent-hover))`; + + const handleMouseMove = (e: React.MouseEvent) => { + const el = internalRef.current; + if (!el) return; + + const rect = el.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + const distanceX = e.clientX - centerX; + const distanceY = e.clientY - centerY; + + x.set(distanceX / 4); + y.set(distanceY / 4); }; - const handleLeave = () => { - if (!window.matchMedia('(hover: hover)').matches) return; + const handleMouseLeave = () => { setIsHovered(false); - setVariantIndex(prev => (prev + 1) % textVariants.length); + x.set(0); + y.set(0); }; - const particles = Array.from({ length: 12 }, (_, i) => ({ - id: i, - angle: (i * 360) / 12, - })); + const handleMouseEnter = () => { + setIsHovered(true); + const nextIndex = (variantIndex + 1) % textVariants.length; + const finalIndex = nextIndex === 0 ? 1 : nextIndex; + + setVariantIndex(finalIndex); + setCurrentText(textVariants[finalIndex]); + }; + useEffect(() => { + if (isHovered) { + const interval = setInterval(() => { + rotate.set((rotate.get() + 2) % 360); + }, 16); + return () => clearInterval(interval); + } + }, [isHovered, rotate]); + return ( - { + internalRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + onMouseMove={handleMouseMove} + onMouseLeave={handleMouseLeave} + onMouseEnter={handleMouseEnter} + style={{ x: springX, y: springY }} + className="group relative inline-flex items-center justify-center overflow-hidden rounded-full px-12 py-4 text-sm font-bold tracking-widest text-white uppercase shadow-[0_10px_30px_rgba(0,0,0,0.15)] transition-shadow duration-300 hover:shadow-[0_20px_40px_rgba(30,94,255,0.4)] dark:hover:shadow-[0_20px_40px_rgba(255,45,85,0.5)] cursor-pointer" + {...props} > - - - - - - {isHovered && - particles.map(particle => ( - - - - ))} - - - - - - - - {currentText} - - - - + + + + + + + + {currentText} + + + + ); } ); diff --git a/frontend/components/home/InteractiveConstellation.tsx b/frontend/components/home/InteractiveConstellation.tsx new file mode 100644 index 00000000..c350a0ba --- /dev/null +++ b/frontend/components/home/InteractiveConstellation.tsx @@ -0,0 +1,335 @@ +'use client'; + +import { useTheme } from 'next-themes'; +import React, { useEffect, useRef } from 'react'; + +interface Point { + x: number; + y: number; +} + +type IconType = 'react' | 'next' | 'git' | 'code' | 'heart' | 'js' | 'ts' | 'css' | 'node' | 'brackets'; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + size: number; + icon: IconType; + rotation: number; + rotationSpeed: number; +} + +export function InteractiveConstellation() { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const mouseRef = useRef({ x: -1000, y: -1000 }); + const { theme } = useTheme(); + + const icons: Record void> = { + react: (ctx, size) => { + ctx.beginPath(); + ctx.ellipse(0, 0, size * 1.2, size * 0.4, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, 0, size * 1.2, size * 0.4, Math.PI / 3, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.ellipse(0, 0, size * 1.2, size * 0.4, -Math.PI / 3, 0, Math.PI * 2); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, 0, size * 0.25, 0, Math.PI * 2); + ctx.fill(); + }, + next: (ctx, size) => { + ctx.beginPath(); + ctx.moveTo(-size * 0.6, -size * 0.8); + ctx.lineTo(-size * 0.6, size * 0.8); + ctx.lineTo(size * 0.6, -size * 0.8); + ctx.lineTo(size * 0.6, size * 0.8); + ctx.lineWidth = 2.5; + ctx.stroke(); + }, + git: (ctx, size) => { + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, size * 0.9); + ctx.lineTo(0, -size * 0.9); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(0, -size * 0.9, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.quadraticCurveTo(size * 0.7, 0, size * 0.7, -size * 0.5); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(size * 0.7, -size * 0.5, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.arc(0, size * 0.9, size * 0.3, 0, Math.PI * 2); + ctx.fill(); + }, + code: (ctx, size) => { + ctx.font = `bold ${size * 1.3}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('', 0, 0); + }, + heart: (ctx, size) => { + ctx.beginPath(); + ctx.moveTo(0, size * 0.4); + ctx.bezierCurveTo( + -size * 0.8, -size * 0.3, + -size * 1.0, size * 0.2, + 0, size * 1.0 + ); + ctx.bezierCurveTo( + size * 1.0, size * 0.2, + size * 0.8, -size * 0.3, + 0, size * 0.4 + ); + ctx.fill(); + }, + js: (ctx, size) => { + ctx.font = `bold ${size * 1.6}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('JS', 0, 0); + }, + ts: (ctx, size) => { + ctx.font = `bold ${size * 1.6}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('TS', 0, 0); + }, + css: (ctx, size) => { + ctx.font = `bold ${size * 1.4}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('CSS', 0, 0); + }, + node: (ctx, size) => { + ctx.font = `bold ${size * 1.2}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('NODE', 0, 0); + }, + brackets: (ctx, size) => { + ctx.font = `bold ${size * 1.8}px monospace`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('{}', 0, 0); + } + }; + + useEffect(() => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + let animationFrameId: number; + let particles: Particle[] = []; + + const connectionDistance = 140; + const particleCountFull = 50; + const interactionRadius = 220; + const magneticForce = 0.6; + const iconTypes = Object.keys(icons) as IconType[]; + + const resize = () => { + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + initParticles(); + }; + + const initParticles = () => { + particles = []; + const density = (canvas.width * canvas.height) / (1920 * 1080); + const count = Math.floor(particleCountFull * density) || 20; + + for (let i = 0; i < count; i++) { + particles.push({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.3, + size: Math.random() * 8 + 10, + icon: iconTypes[Math.floor(Math.random() * iconTypes.length)], + rotation: Math.random() * Math.PI * 2, + rotationSpeed: (Math.random() - 0.5) * 0.02, + }); + } + }; + + const draw = () => { + if (!ctx || !canvas) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Colors + const isDark = theme === 'dark' || document.documentElement.classList.contains('dark'); + const r = isDark ? 255 : 30; + const g = isDark ? 45 : 94; + const b = isDark ? 85 : 255; + + + particles.forEach((p, i) => { + const pulse = Math.sin((Date.now() * 0.002) + p.rotation * 5) * 0.5 + 0.5; + const baseAlpha = 0.4; + const pulseAlpha = baseAlpha + (pulse * 0.2); + + const dx = mouseRef.current.x - p.x; + const dy = mouseRef.current.y - p.y; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist < interactionRadius) { + const force = (interactionRadius - dist) / interactionRadius; + const safeDist = Math.max(dist, 0.1); + p.vx -= (dx / safeDist) * force * magneticForce * 0.2; + p.vy -= (dy / safeDist) * force * magneticForce * 0.2; + } + + const centerX = canvas.width / 2; + const centerY = canvas.height / 2; + const dxCenter = p.x - centerX; + const dyCenter = p.y - centerY; + const distCenter = Math.sqrt(dxCenter * dxCenter + dyCenter * dyCenter); + const centerClearRadius = 450; + + if (distCenter < centerClearRadius) { + const force = (centerClearRadius - distCenter) / centerClearRadius; + const safeDistCenter = Math.max(distCenter, 0.1); + p.vx += (dxCenter / safeDistCenter) * force * 2.0; + p.vy += (dyCenter / safeDistCenter) * force * 2.0; + } + + for (let j = 0; j < particles.length; j++) { + if (i === j) continue; + const p2 = particles[j]; + const dx2 = p.x - p2.x; + const dy2 = p.y - p2.y; + const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + const minDistance = 60; + + if (dist2 < minDistance && dist2 > 0) { + const repulsionForce = (minDistance - dist2) / minDistance; + const safeDist2 = Math.max(dist2, 0.1); + const pushX = (dx2 / safeDist2) * repulsionForce * 0.5; + const pushY = (dy2 / safeDist2) * repulsionForce * 0.5; + p.vx += pushX; + p.vy += pushY; + } + } + + const mouseInfluenceRadius = 300; + let alpha = pulseAlpha; + if (dist < mouseInfluenceRadius) { + const boost = (mouseInfluenceRadius - dist) / mouseInfluenceRadius; + alpha += boost * 0.5; + } + alpha = Math.min(alpha, 1); + + const currentParticleColor = `rgba(${r}, ${g}, ${b}, ${alpha})`; + + const padding = 50; + const pushStrength = 0.05; + + if (p.x < padding) p.vx += pushStrength; + if (p.x > canvas.width - padding) p.vx -= pushStrength; + if (p.y < padding) p.vy += pushStrength; + if (p.y > canvas.height - padding) p.vy -= pushStrength; + + p.x += p.vx; + p.y += p.vy; + p.rotation += p.rotationSpeed; + + if (p.x < 0 || p.x > canvas.width) p.vx *= -1; + if (p.y < 0 || p.y > canvas.height) p.vy *= -1; + p.vx *= 0.98; + p.vy *= 0.98; + + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rotation); + + ctx.shadowBlur = alpha * 15; + ctx.shadowColor = `rgba(${r}, ${g}, ${b}, 0.8)`; + + ctx.strokeStyle = currentParticleColor; + ctx.fillStyle = currentParticleColor; + ctx.lineWidth = 2; + + icons[p.icon](ctx, p.size); + + ctx.restore(); + + for (let j = i + 1; j < particles.length; j++) { + const p2 = particles[j]; + const dx2 = p.x - p2.x; + const dy2 = p.y - p2.y; + const dist2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + + if (dist2 < connectionDistance) { + const maxAlpha = Math.max(alpha, 0.4); + + const connectionAlpha = (1 - dist2 / connectionDistance) * maxAlpha; + + if (connectionAlpha > 0.05) { + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p2.x, p2.y); + + ctx.shadowBlur = connectionAlpha * 10; + ctx.shadowColor = `rgba(${r}, ${g}, ${b}, 0.5)`; + + ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${connectionAlpha})`; + ctx.lineWidth = 1.5; + ctx.lineCap = 'round'; + ctx.stroke(); + + ctx.shadowBlur = 0; + } + } + } + }); + + animationFrameId = requestAnimationFrame(draw); + }; + + const handleMouseMove = (e: MouseEvent) => { + const rect = container.getBoundingClientRect(); + mouseRef.current = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + }; + + const handleMouseLeave = () => { + mouseRef.current = { x: -1000, y: -1000 }; + }; + + window.addEventListener('resize', resize); + container.addEventListener('mousemove', handleMouseMove); + container.addEventListener('mouseleave', handleMouseLeave); + + resize(); + draw(); + + return () => { + window.removeEventListener('resize', resize); + container.removeEventListener('mousemove', handleMouseMove); + container.removeEventListener('mouseleave', handleMouseLeave); + cancelAnimationFrame(animationFrameId); + }; + }, [theme]); + + return ( +
+ +
+ ); +} diff --git a/frontend/components/home/HeroBackground.tsx b/frontend/components/home/WelcomeHeroBackground.tsx similarity index 76% rename from frontend/components/home/HeroBackground.tsx rename to frontend/components/home/WelcomeHeroBackground.tsx index 06e360d2..db6a4d24 100644 --- a/frontend/components/home/HeroBackground.tsx +++ b/frontend/components/home/WelcomeHeroBackground.tsx @@ -1,8 +1,10 @@ -export function HeroBackground() { +import React from 'react'; + +export function WelcomeHeroBackground() { return ( <> -
-
+
+
diff --git a/frontend/components/home/WelcomeHeroSection.tsx b/frontend/components/home/WelcomeHeroSection.tsx new file mode 100644 index 00000000..bf64bf2e --- /dev/null +++ b/frontend/components/home/WelcomeHeroSection.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import React from 'react'; +import { motion } from 'framer-motion'; +import { ChevronDown } from 'lucide-react'; + + +import { InteractiveConstellation } from '@/components/home/InteractiveConstellation'; +import { InteractiveCTAButton } from '@/components/home/InteractiveCTAButton'; +import { WelcomeHeroBackground } from '@/components/home/WelcomeHeroBackground'; + +export default function WelcomeHeroSection() { + const t = useTranslations('homepage'); + + return ( +
+ + +
+ + {t('subtitle')} + +
+

+ + DevLovers + + + +

+
+ +

+ {t('welcomeDescription')} +

+ +
+ +
+
+ + +
+ +
+ + + +
+
+ ); +} diff --git a/frontend/components/ui/particle-canvas.tsx b/frontend/components/ui/particle-canvas.tsx index 27cd2ea2..15130e25 100644 --- a/frontend/components/ui/particle-canvas.tsx +++ b/frontend/components/ui/particle-canvas.tsx @@ -363,4 +363,4 @@ export function ParticleCanvas({ }, []); return
)} - {product.description && ( -

{product.description}

- )} + {(() => { + const desc = + (productDescriptions[slug] as string) || product.description; + if (!desc) return null; + return ( +
+ {desc.split('\n').map((line: string, i: number) => ( +

{line}

+ ))} +
+ ); + })()} {!isUnavailable && (
diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index ce374bdc..11253323 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -243,7 +243,9 @@ export function AppMobileMenu({ {variant === 'platform' && ( <> {links - .filter(link => link.href !== '/shop') + .filter( + link => link.href !== '/shop' && link.href !== '/blog' + ) .map(link => ( ))} + + {t('blog')} + + + {isBlog && } + + + {userExists && ( )} - {isBlog && } - - - {isShop && } {!userExists ? ( diff --git a/frontend/components/header/DesktopNav.tsx b/frontend/components/header/DesktopNav.tsx index d370a572..12353f60 100644 --- a/frontend/components/header/DesktopNav.tsx +++ b/frontend/components/header/DesktopNav.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ShoppingBag } from 'lucide-react'; +import { BookOpen, ShoppingBag } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { BlogCategoryLinks } from '@/components/blog/BlogCategoryLinks'; @@ -41,13 +41,19 @@ export function DesktopNav({ variant, blogCategories = [] }: DesktopNavProps) { return (
- {SITE_LINKS.filter(link => link.href !== '/shop').map(link => ( + {SITE_LINKS.filter( + link => link.href !== '/shop' && link.href !== '/blog' + ).map(link => ( {t(link.labelKey)} ))}
+ + {t('blog')} + + - {isBlog && } + {isBlog && } {isShop && }
-
+
{hasResults ? ( ) : ( diff --git a/frontend/components/leaderboard/LeaderboardTable.tsx b/frontend/components/leaderboard/LeaderboardTable.tsx index 5b580a57..ee18cb77 100644 --- a/frontend/components/leaderboard/LeaderboardTable.tsx +++ b/frontend/components/leaderboard/LeaderboardTable.tsx @@ -39,6 +39,11 @@ export function LeaderboardTable({
+ + + + + @@ -83,6 +88,11 @@ export function LeaderboardTable({
{t('tableCaption')}
+ + + + + diff --git a/frontend/components/shared/LanguageSwitcher.tsx b/frontend/components/shared/LanguageSwitcher.tsx index e78e0750..be20ae44 100644 --- a/frontend/components/shared/LanguageSwitcher.tsx +++ b/frontend/components/shared/LanguageSwitcher.tsx @@ -8,8 +8,8 @@ import { type Locale, locales } from '@/i18n/config'; import { Link } from '@/i18n/routing'; const localeLabels: Record = { - uk: 'UA', en: 'EN', + uk: 'UA', pl: 'PL', }; @@ -69,7 +69,7 @@ export default function LanguageSwitcher() { {isOpen && ( -
+
{locales.map(locale => ( Date: Sat, 14 Feb 2026 01:03:37 +0200 Subject: [PATCH 08/16] (SP: 1) [Frontend] Q&A: Next.js tab states + faster loader start (#324) * fix(qa): align Next.js tab states and speed up loader startup * feat(home,qa): improve home snap flow and add configurable Q&A page size * fix(i18n,qa,seed): address review issues for locale handling and pagination state --- frontend/app/[locale]/page.tsx | 24 +- .../app/api/questions/[category]/route.ts | 2 +- frontend/app/layout.tsx | 14 +- frontend/components/header/MainSwitcher.tsx | 4 +- .../components/home/FeaturesHeroSection.tsx | 6 +- frontend/components/home/HomePageScroll.tsx | 52 + .../components/home/WelcomeHeroSection.tsx | 7 +- frontend/components/q&a/Pagination.tsx | 202 ++-- frontend/components/q&a/QaSection.tsx | 8 +- frontend/components/q&a/useQaTabs.ts | 46 +- frontend/components/shared/Footer.tsx | 21 +- frontend/components/shared/Loader.tsx | 7 + .../components/tests/q&a/pagination.test.tsx | 22 + .../components/tests/q&a/use-qa-tabs.test.tsx | 23 + frontend/data/categoryStyles.ts | 2 +- frontend/db/seed-demo-leaderboard.ts | 87 -- frontend/db/seed-questions.ts | 102 +- frontend/db/seed-quiz-angular-advanced.ts | 285 ----- frontend/db/seed-quiz-angular.ts | 281 ----- frontend/db/seed-quiz-css-advanced.ts | 280 ----- frontend/db/seed-quiz-css.ts | 276 ----- frontend/db/seed-quiz-from-json.ts | 202 ---- frontend/db/seed-quiz-git.ts | 275 ----- frontend/db/seed-quiz-html-advanced.ts | 276 ----- frontend/db/seed-quiz-html.ts | 276 ----- frontend/db/seed-quiz-javascript-advanced.ts | 276 ----- frontend/db/seed-quiz-javascript.ts | 290 ----- frontend/db/seed-quiz-nodejs-advanced.ts | 285 ----- frontend/db/seed-quiz-nodejs.ts | 285 ----- frontend/db/seed-quiz-react.ts | 1010 ----------------- frontend/db/seed-quiz-types.ts | 70 -- frontend/db/seed-quiz-typescript-advanced.ts | 280 ----- frontend/db/seed-quiz-typescript.ts | 278 ----- frontend/db/seed-quiz-verify.ts | 198 ---- frontend/db/seed-quiz-vue.ts | 282 ----- frontend/db/seed-users.ts | 66 -- frontend/messages/en.json | 4 +- frontend/messages/pl.json | 4 +- frontend/messages/uk.json | 4 +- 39 files changed, 434 insertions(+), 5678 deletions(-) create mode 100644 frontend/components/home/HomePageScroll.tsx delete mode 100644 frontend/db/seed-demo-leaderboard.ts delete mode 100644 frontend/db/seed-quiz-angular-advanced.ts delete mode 100644 frontend/db/seed-quiz-angular.ts delete mode 100644 frontend/db/seed-quiz-css-advanced.ts delete mode 100644 frontend/db/seed-quiz-css.ts delete mode 100644 frontend/db/seed-quiz-from-json.ts delete mode 100644 frontend/db/seed-quiz-git.ts delete mode 100644 frontend/db/seed-quiz-html-advanced.ts delete mode 100644 frontend/db/seed-quiz-html.ts delete mode 100644 frontend/db/seed-quiz-javascript-advanced.ts delete mode 100644 frontend/db/seed-quiz-javascript.ts delete mode 100644 frontend/db/seed-quiz-nodejs-advanced.ts delete mode 100644 frontend/db/seed-quiz-nodejs.ts delete mode 100644 frontend/db/seed-quiz-react.ts delete mode 100644 frontend/db/seed-quiz-types.ts delete mode 100644 frontend/db/seed-quiz-typescript-advanced.ts delete mode 100644 frontend/db/seed-quiz-typescript.ts delete mode 100644 frontend/db/seed-quiz-verify.ts delete mode 100644 frontend/db/seed-quiz-vue.ts delete mode 100644 frontend/db/seed-users.ts diff --git a/frontend/app/[locale]/page.tsx b/frontend/app/[locale]/page.tsx index 5b7cbb03..6c9abbfa 100644 --- a/frontend/app/[locale]/page.tsx +++ b/frontend/app/[locale]/page.tsx @@ -1,6 +1,9 @@ import { getTranslations } from 'next-intl/server'; import FeaturesHeroSection from '@/components/home/FeaturesHeroSection'; +import HomePageScroll from '@/components/home/HomePageScroll'; +import WelcomeHeroSection from '@/components/home/WelcomeHeroSection'; +import Footer from '@/components/shared/Footer'; export async function generateMetadata({ params, @@ -57,13 +60,22 @@ export async function generateMetadata({ }; } -import WelcomeHeroSection from '@/components/home/WelcomeHeroSection'; - export default function Home() { return ( - <> - - - + +
+ +
+
+ +
+