From 96a7ebf95dff822b81ab4932bbf49172746e429c Mon Sep 17 00:00:00 2001 From: tetiana zorii Date: Thu, 12 Feb 2026 03:05:36 -0500 Subject: [PATCH 01/10] 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/10] (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 ( - <> - - - + +
+ +
+
+ +
+
+ ); } diff --git a/frontend/app/api/questions/[category]/route.ts b/frontend/app/api/questions/[category]/route.ts index 76470c04..2e82a697 100644 --- a/frontend/app/api/questions/[category]/route.ts +++ b/frontend/app/api/questions/[category]/route.ts @@ -37,7 +37,7 @@ export async function GET( const page = Math.max(1, Number(searchParams.get('page') ?? DEFAULT_PAGE)); const limit = Math.min( - 50, + 100, Math.max(1, Number(searchParams.get('limit') ?? DEFAULT_LIMIT)) ); const offset = (page - 1) * limit; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 8b7b99c3..3028e0b4 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,9 +2,8 @@ import './globals.css'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; -import { GeistMono } from 'geist/font/mono'; -import { GeistSans } from 'geist/font/sans'; import type { Metadata } from 'next'; +import { Geist, Geist_Mono } from 'next/font/google'; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://devlovers.net'; @@ -50,8 +49,15 @@ export const metadata: Metadata = { }, }; -const geistSans = GeistSans; -const geistMono = GeistMono; +const geistSans = Geist({ + subsets: ['latin', 'cyrillic'], + variable: '--font-geist-sans', +}); + +const geistMono = Geist_Mono({ + subsets: ['latin', 'cyrillic'], + variable: '--font-geist-mono', +}); export default function RootLayout({ children, diff --git a/frontend/components/header/MainSwitcher.tsx b/frontend/components/header/MainSwitcher.tsx index 3754e2f7..2bdf7a64 100644 --- a/frontend/components/header/MainSwitcher.tsx +++ b/frontend/components/header/MainSwitcher.tsx @@ -4,6 +4,7 @@ import { usePathname } from 'next/navigation'; import type { ReactNode } from 'react'; import { UnifiedHeader } from '@/components/header/UnifiedHeader'; +import { locales } from '@/i18n/config'; function isShopPath(pathname: string): boolean { const segments = pathname.split('/').filter(Boolean); @@ -24,7 +25,8 @@ function isHomePath(pathname: string): boolean { const segments = pathname.split('/').filter(Boolean); return ( segments.length === 0 || - (segments.length === 1 && ['en', 'pl', 'uk'].includes(segments[0])) + (segments.length === 1 && + locales.includes(segments[0] as (typeof locales)[number])) ); } diff --git a/frontend/components/home/FeaturesHeroSection.tsx b/frontend/components/home/FeaturesHeroSection.tsx index 63d50b66..75acde0b 100644 --- a/frontend/components/home/FeaturesHeroSection.tsx +++ b/frontend/components/home/FeaturesHeroSection.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useTranslations } from 'next-intl'; +import { BrainCircuit, MessageCircleQuestion, TrendingUp } from 'lucide-react'; import Link from 'next/link'; +import { useTranslations } from 'next-intl'; import * as React from 'react'; -import { BrainCircuit, MessageCircleQuestion, TrendingUp } from 'lucide-react'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; @@ -16,7 +16,7 @@ export default function FeaturesHeroSection() { return ( diff --git a/frontend/components/home/HomePageScroll.tsx b/frontend/components/home/HomePageScroll.tsx new file mode 100644 index 00000000..5a44d647 --- /dev/null +++ b/frontend/components/home/HomePageScroll.tsx @@ -0,0 +1,52 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { cn } from '@/lib/utils'; + +type HomePageScrollProps = { + children: ReactNode; +}; + +export default function HomePageScroll({ children }: HomePageScrollProps) { + const containerRef = useRef(null); + const [snapEnabled, setSnapEnabled] = useState(true); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const updateSnapMode = () => { + const steps = container.querySelectorAll('[data-home-step]'); + const secondStep = steps[1]; + if (!secondStep) return; + + const shouldEnableSnap = container.scrollTop <= secondStep.offsetTop + 4; + setSnapEnabled(prev => + prev === shouldEnableSnap ? prev : shouldEnableSnap + ); + }; + + updateSnapMode(); + container.addEventListener('scroll', updateSnapMode, { passive: true }); + window.addEventListener('resize', updateSnapMode); + + return () => { + container.removeEventListener('scroll', updateSnapMode); + window.removeEventListener('resize', updateSnapMode); + }; + }, []); + + return ( +
+ {children} +
+ ); +} diff --git a/frontend/components/home/WelcomeHeroSection.tsx b/frontend/components/home/WelcomeHeroSection.tsx index bf64bf2e..43aa5a83 100644 --- a/frontend/components/home/WelcomeHeroSection.tsx +++ b/frontend/components/home/WelcomeHeroSection.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useTranslations } from 'next-intl'; -import React from 'react'; import { motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; - +import { useTranslations } from 'next-intl'; +import React from 'react'; import { InteractiveConstellation } from '@/components/home/InteractiveConstellation'; import { InteractiveCTAButton } from '@/components/home/InteractiveCTAButton'; @@ -14,7 +13,7 @@ export default function WelcomeHeroSection() { const t = useTranslations('homepage'); return ( -
+
diff --git a/frontend/components/q&a/Pagination.tsx b/frontend/components/q&a/Pagination.tsx index 5d905300..1c09abd8 100644 --- a/frontend/components/q&a/Pagination.tsx +++ b/frontend/components/q&a/Pagination.tsx @@ -1,5 +1,6 @@ 'use client'; +import { ChevronDown } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useEffect, useState } from 'react'; @@ -10,6 +11,9 @@ interface PaginationProps { totalPages: number; onPageChange: (page: number) => void; accentColor: string; + pageSize?: number; + pageSizeOptions?: readonly number[]; + onPageSizeChange?: (size: number) => void; } function hexToRgba(hex: string, alpha: number): string { @@ -26,6 +30,9 @@ export function Pagination({ totalPages, onPageChange, accentColor, + pageSize = 10, + pageSizeOptions = [10], + onPageSizeChange, }: PaginationProps) { const t = useTranslations('qa.pagination'); const accentSoft = hexToRgba(accentColor, 0.16); @@ -40,29 +47,33 @@ export function Pagination({ return () => media.removeEventListener('change', update); }, []); - if (totalPages <= 1) return null; + const effectiveTotalPages = Math.max(totalPages, 1); const getPageNumbers = (): (number | 'ellipsis')[] => { const pages: (number | 'ellipsis')[] = []; const maxVisible = isMobile ? 3 : 5; - if (totalPages <= maxVisible + 2) { - for (let i = 1; i <= totalPages; i++) { + if (effectiveTotalPages <= maxVisible + 2) { + for (let i = 1; i <= effectiveTotalPages; i++) { pages.push(i); } } else { pages.push(1); if (currentPage <= 3) { - for (let i = 2; i <= Math.min(maxVisible, totalPages - 1); i++) { + for (let i = 2; i <= Math.min(maxVisible, effectiveTotalPages - 1); i++) { pages.push(i); } - if (totalPages > maxVisible) { + if (effectiveTotalPages > maxVisible) { pages.push('ellipsis'); } - } else if (currentPage >= totalPages - 2) { + } else if (currentPage >= effectiveTotalPages - 2) { pages.push('ellipsis'); - for (let i = totalPages - maxVisible + 1; i < totalPages; i++) { + for ( + let i = effectiveTotalPages - maxVisible + 1; + i < effectiveTotalPages; + i++ + ) { if (i > 1) pages.push(i); } } else { @@ -73,7 +84,7 @@ export function Pagination({ pages.push('ellipsis'); } - pages.push(totalPages); + pages.push(effectiveTotalPages); } return pages; @@ -82,8 +93,8 @@ export function Pagination({ const pages = getPageNumbers(); return ( - + + {onPageSizeChange && pageSizeOptions.length > 1 && ( +
+ +
+ + +
+
+ )} +
); } diff --git a/frontend/components/q&a/QaSection.tsx b/frontend/components/q&a/QaSection.tsx index d4b8d9bc..434d7da4 100644 --- a/frontend/components/q&a/QaSection.tsx +++ b/frontend/components/q&a/QaSection.tsx @@ -24,9 +24,12 @@ export default function TabsSection() { currentPage, handleCategoryChange, handlePageChange, + handlePageSizeChange, isLoading, items, localeKey, + pageSize, + pageSizeOptions, totalPages, } = useQaTabs(); const animationKey = useMemo( @@ -123,11 +126,14 @@ export default function TabsSection() { ))} - {!isLoading && totalPages > 1 && ( + {!isLoading && items.length > 0 && ( category.slug); const DEFAULT_CATEGORY = CATEGORY_SLUGS[0] || 'html'; +const PAGE_SIZE_OPTIONS = [10, 20, 40, 60, 80, 100] as const; +const DEFAULT_PAGE_SIZE = PAGE_SIZE_OPTIONS[0]; +type QaPageSize = (typeof PAGE_SIZE_OPTIONS)[number]; function isCategorySlug(value: string): value is CategorySlug { return CATEGORY_SLUGS.includes(value); } +function isQaPageSize(value: number): value is QaPageSize { + return PAGE_SIZE_OPTIONS.includes(value as QaPageSize); +} + export function useQaTabs() { const router = useRouter(); const searchParams = useSearchParams(); @@ -30,21 +37,28 @@ export function useQaTabs() { const pageFromUrl = rawPage ? Number(rawPage) : 1; const safePageFromUrl = Number.isFinite(pageFromUrl) && pageFromUrl > 0 ? pageFromUrl : 1; + const rawSize = searchParams.get('size'); + const parsedSize = rawSize ? Number(rawSize) : DEFAULT_PAGE_SIZE; + const safePageSizeFromUrl = isQaPageSize(parsedSize) + ? parsedSize + : DEFAULT_PAGE_SIZE; const categoryFromUrl = searchParams.get('category') || DEFAULT_CATEGORY; const [active, setActive] = useState( isCategorySlug(categoryFromUrl) ? categoryFromUrl : DEFAULT_CATEGORY ); const [currentPage, setCurrentPage] = useState(safePageFromUrl); + const [pageSize, setPageSize] = useState(safePageSizeFromUrl); const [items, setItems] = useState([]); const [totalPages, setTotalPages] = useState(0); const [isLoading, setIsLoading] = useState(true); const updateUrl = useCallback( - (category: CategorySlug, page: number) => { + (category: CategorySlug, page: number, size: QaPageSize) => { const params = new URLSearchParams(); if (category !== DEFAULT_CATEGORY) params.set('category', category); if (page > 1) params.set('page', String(page)); + if (size !== DEFAULT_PAGE_SIZE) params.set('size', String(size)); const queryString = params.toString(); @@ -59,6 +73,10 @@ export function useQaTabs() { setCurrentPage(safePageFromUrl); }, [safePageFromUrl]); + useEffect(() => { + setPageSize(safePageSizeFromUrl); + }, [safePageSizeFromUrl]); + useEffect(() => { if (!isCategorySlug(categoryFromUrl)) { return; @@ -75,7 +93,7 @@ export function useQaTabs() { try { const res = await fetch( - `/api/questions/${active}?page=${currentPage}&limit=10&locale=${localeKey}`, + `/api/questions/${active}?page=${currentPage}&limit=${pageSize}&locale=${localeKey}`, { signal: controller.signal } ); @@ -113,7 +131,7 @@ export function useQaTabs() { isActive = false; controller.abort(); }; - }, [active, currentPage, localeKey]); + }, [active, currentPage, localeKey, pageSize]); const handleCategoryChange = useCallback( (category: string) => { @@ -122,15 +140,28 @@ export function useQaTabs() { } setActive(category); setCurrentPage(1); - updateUrl(category, 1); + updateUrl(category, 1, pageSize); }, - [updateUrl] + [pageSize, updateUrl] ); const handlePageChange = useCallback( (page: number) => { setCurrentPage(page); - updateUrl(active, page); + updateUrl(active, page, pageSize); + }, + [active, pageSize, updateUrl] + ); + + const handlePageSizeChange = useCallback( + (size: number) => { + if (!isQaPageSize(size)) { + return; + } + + setPageSize(size); + setCurrentPage(1); + updateUrl(active, 1, size); }, [active, updateUrl] ); @@ -140,9 +171,12 @@ export function useQaTabs() { currentPage, handleCategoryChange, handlePageChange, + handlePageSizeChange, isLoading, items, localeKey, + pageSize, + pageSizeOptions: PAGE_SIZE_OPTIONS, totalPages, }; } diff --git a/frontend/components/shared/Footer.tsx b/frontend/components/shared/Footer.tsx index d4a71d83..b20d3378 100644 --- a/frontend/components/shared/Footer.tsx +++ b/frontend/components/shared/Footer.tsx @@ -1,11 +1,12 @@ 'use client'; import { Github, Linkedin, Send } from 'lucide-react'; -import { useSelectedLayoutSegments } from 'next/navigation'; +import { usePathname, useSelectedLayoutSegments } from 'next/navigation'; import { useTranslations } from 'next-intl'; import type { Ref } from 'react'; import { ThemeToggle } from '@/components/theme/ThemeToggle'; +import { locales } from '@/i18n/config'; import { Link } from '@/i18n/routing'; import { cn } from '@/lib/utils'; @@ -21,12 +22,27 @@ const SOCIAL = [ export default function Footer({ footerRef, + className, + forceVisible = false, }: { footerRef?: Ref; + className?: string; + forceVisible?: boolean; }) { const t = useTranslations('footer'); const segments = useSelectedLayoutSegments(); + const pathname = usePathname(); const isShop = segments.includes('shop'); + const pathSegments = pathname.split('/').filter(Boolean); + const isHome = + pathSegments.length === 0 || + (pathSegments.length === 1 && + locales.includes(pathSegments[0] as (typeof locales)[number])); + + if (isHome && !forceVisible) { + return null; + } + return (
diff --git a/frontend/components/shared/Loader.tsx b/frontend/components/shared/Loader.tsx index 2982dd4e..71907a9e 100644 --- a/frontend/components/shared/Loader.tsx +++ b/frontend/components/shared/Loader.tsx @@ -21,6 +21,7 @@ interface ParticleState { } const TWO_PI = Math.PI * 2; +const STARTUP_WARMUP_FRAMES = 100; export function Loader({ className, size = 240 }: LoaderProps) { const canvasRef = useRef(null); @@ -126,6 +127,12 @@ export function Loader({ className, size = 240 }: LoaderProps) { tick += 1; }; + for (let i = 0; i < STARTUP_WARMUP_FRAMES; i += 1) { + step(); + tick += 1; + } + draw(); + animationRef.current = requestAnimationFrame(loop); return () => { diff --git a/frontend/components/tests/q&a/pagination.test.tsx b/frontend/components/tests/q&a/pagination.test.tsx index f5587e37..865b83a3 100644 --- a/frontend/components/tests/q&a/pagination.test.tsx +++ b/frontend/components/tests/q&a/pagination.test.tsx @@ -126,4 +126,26 @@ describe('Pagination', () => { expect(screen.getAllByLabelText(/page-/).length).toBe(4); }); + + it('calls onPageSizeChange when selecting a different size', () => { + const onPageChange = vi.fn(); + const onPageSizeChange = vi.fn(); + + render( + + ); + + const select = screen.getByLabelText('itemsPerPageAria'); + fireEvent.change(select, { target: { value: '40' } }); + + expect(onPageSizeChange).toHaveBeenCalledWith(40); + }); }); diff --git a/frontend/components/tests/q&a/use-qa-tabs.test.tsx b/frontend/components/tests/q&a/use-qa-tabs.test.tsx index 75793854..aa46a2d8 100644 --- a/frontend/components/tests/q&a/use-qa-tabs.test.tsx +++ b/frontend/components/tests/q&a/use-qa-tabs.test.tsx @@ -139,4 +139,27 @@ describe('useQaTabs', () => { expect(result.current.totalPages).toBe(0); consoleSpy.mockRestore(); }); + + it('updates page size and URL on size change', async () => { + const { result } = renderHook(() => useQaTabs()); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + act(() => { + result.current.handlePageSizeChange(40); + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/questions/git?page=1&limit=40&locale=en', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + }); + + expect(routerReplace).toHaveBeenCalledWith('/q&a?size=40', { + scroll: false, + }); + }); }); diff --git a/frontend/data/categoryStyles.ts b/frontend/data/categoryStyles.ts index 52b8aa5d..5208535a 100644 --- a/frontend/data/categoryStyles.ts +++ b/frontend/data/categoryStyles.ts @@ -57,7 +57,7 @@ export const categoryTabStyles = { 'group-hover:border-black/50 dark:group-hover:border-white/50 group-hover:bg-black/5 dark:group-hover:bg-white/10 data-[state=active]:border-black/50 dark:data-[state=active]:border-white/50 data-[state=active]:bg-black/5 dark:data-[state=active]:bg-white/10', glow: 'bg-black dark:bg-white', iconClassName: 'dark:invert', - accent: '#111111', + accent: '#A1A1AA', }, vue: { icon: '/icons/vuejs.svg', diff --git a/frontend/db/seed-demo-leaderboard.ts b/frontend/db/seed-demo-leaderboard.ts deleted file mode 100644 index ca8568cd..00000000 --- a/frontend/db/seed-demo-leaderboard.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { and, eq, inArray } from 'drizzle-orm'; - -import { db } from './index'; -import { pointTransactions } from './schema/points'; -import { users } from './schema/users'; - -async function main() { - console.log('Seeding demo leaderboard users...'); - - const demoUsers = [ - { - id: 'demo_1', - name: 'CyberNinja', - email: 'ninja@demo.com', - image: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Felix', - points: 1500, - }, - { - id: 'demo_2', - name: 'CodeMaster', - email: 'master@demo.com', - image: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Aneka', - points: 1200, - }, - { - id: 'demo_3', - name: 'PixelArtist', - email: 'pixel@demo.com', - image: 'https://api.dicebear.com/9.x/avataaars/svg?seed=Jude', - points: 950, - }, - { - id: 'demo_4', - name: 'BugHunter', - email: 'bug@demo.com', - points: 800, - }, - { - id: 'demo_5', - name: 'DevOps_Guru', - email: 'ops@demo.com', - points: 600, - }, - ]; - - await db - .insert(users) - .values( - demoUsers.map(u => ({ - id: u.id, - name: u.name, - email: u.email, - image: u.image, - role: 'user', - createdAt: new Date(), - })) - ) - .onConflictDoNothing(); - - const userIds = demoUsers.map(u => u.id); - - await db - .delete(pointTransactions) - .where( - and( - eq(pointTransactions.source, 'seed'), - inArray(pointTransactions.userId, userIds) - ) - ); - - await db.insert(pointTransactions).values( - demoUsers.map(u => ({ - userId: u.id, - points: u.points, - source: 'seed', - metadata: { reason: 'demo' }, - })) - ); - - console.log('Demo users created!'); - process.exit(0); -} - -main().catch(err => { - console.error('Seeding failed:', err); - process.exit(1); -}); diff --git a/frontend/db/seed-questions.ts b/frontend/db/seed-questions.ts index 2c86ce17..666d56a2 100644 --- a/frontend/db/seed-questions.ts +++ b/frontend/db/seed-questions.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import rawData from '../parse/questions.json'; import { db } from './index'; @@ -20,12 +20,33 @@ type RawQuestion = { const data = Array.isArray(rawData) ? (rawData as RawQuestion[]) : []; +function normalizeLocale(locale: string) { + return locale.trim().toLowerCase(); +} + async function seedQuestions() { if (!data.length) { console.log('No questions to seed - skipping.'); return; } + const localeTotals = new Map(); + for (const q of data) { + for (const locale of Object.keys(q.translations ?? {})) { + const normalized = normalizeLocale(locale); + localeTotals.set(normalized, (localeTotals.get(normalized) ?? 0) + 1); + } + } + + console.log( + `[seed] Loaded ${data.length} questions with locales: ${Array.from( + localeTotals.entries() + ) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([locale, count]) => `${locale}(${count})`) + .join(', ')}` + ); + for (const q of data) { const [category] = await db .select() @@ -38,24 +59,83 @@ async function seedQuestions() { continue; } - const [question] = await db - .insert(questions) - .values({ - categoryId: category.id, - sortOrder: q.order ?? 0, - }) - .returning(); + const sortOrder = q.order ?? 0; + + const [existingQuestion] = await db + .select({ id: questions.id }) + .from(questions) + .where( + and( + eq(questions.categoryId, category.id), + eq(questions.sortOrder, sortOrder) + ) + ) + .limit(1); - const translations = Object.entries(q.translations).map( + const questionId = + existingQuestion?.id ?? + ( + await db + .insert(questions) + .values({ + categoryId: category.id, + sortOrder, + }) + .returning({ id: questions.id }) + )[0]!.id; + + const normalizedTranslations = new Map< + string, + { question: string; answerBlocks: unknown } + >(); + for (const [locale, content] of Object.entries(q.translations ?? {})) { + normalizedTranslations.set(normalizeLocale(locale), content); + } + + const translations = Array.from(normalizedTranslations.entries()).map( ([locale, content]) => ({ - questionId: question.id, + questionId, locale, question: content.question, answerBlocks: content.answerBlocks, }) ); - await db.insert(questionTranslations).values(translations); + if (!translations.length) { + console.warn( + `[seed] Question in category ${q.category} has no translations, skipping translations insert` + ); + continue; + } + + const insertedLocales: string[] = []; + for (const translation of translations) { + const [inserted] = await db + .insert(questionTranslations) + .values(translation) + .onConflictDoUpdate({ + target: [questionTranslations.questionId, questionTranslations.locale], + set: { + question: translation.question, + answerBlocks: translation.answerBlocks, + }, + }) + .returning({ locale: questionTranslations.locale }); + if (inserted?.locale) { + insertedLocales.push(inserted.locale); + } + } + + const expectedLocales = translations.map(t => t.locale).sort(); + const uniqueInsertedLocales = Array.from(new Set(insertedLocales)).sort(); + + if (uniqueInsertedLocales.join(',') !== expectedLocales.join(',')) { + console.warn( + `[seed] Translation insert mismatch for question ${questionId}: expected [${expectedLocales.join( + ', ' + )}] but inserted [${uniqueInsertedLocales.join(', ')}]` + ); + } } console.log('Questions seeded!'); diff --git a/frontend/db/seed-quiz-angular-advanced.ts b/frontend/db/seed-quiz-angular-advanced.ts deleted file mode 100644 index 94ecb066..00000000 --- a/frontend/db/seed-quiz-angular-advanced.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'angular'; - -const QUIZ_METADATA = { - slug: 'angular-advanced', - questionsCount: 40, - timeLimitSeconds: 2400, - translations: { - uk: { - title: 'Angular Advanced', - description: - 'Поглиблені концепції Angular: lifecycle hooks, DI декоратори, NgRx, кастомні директиви, SSR, Signals, OnPush та оптимізація продуктивності.', - }, - en: { - title: 'Angular Advanced', - description: - 'Advanced Angular concepts: lifecycle hooks, DI decorators, NgRx, custom directives, SSR, Signals, OnPush and performance optimization.', - }, - pl: { - title: 'Angular Advanced', - description: - 'Zaawansowane koncepcje Angular: lifecycle hooks, dekoratory DI, NgRx, niestandardowe dyrektywy, SSR, Signals, OnPush i optymalizacja wydajności.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -const projectRoot = existsSync(join(process.cwd(), 'json')) - ? process.cwd() - : join(process.cwd(), '..'); - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - projectRoot, - 'json', - 'quizzes', - 'angular', - 'advanced', - `angular-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-angular-advanced.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-angular-advanced.ts 1'); - console.log( - 'Or upload all: npx tsx db/seeds/seed-quiz-angular-advanced.ts all' - ); - process.exit(1); - } - - console.log('Starting Angular Advanced quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-angular.ts b/frontend/db/seed-quiz-angular.ts deleted file mode 100644 index c9a4cdec..00000000 --- a/frontend/db/seed-quiz-angular.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'angular'; - -const QUIZ_METADATA = { - slug: 'angular-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1200, - translations: { - uk: { - title: 'Основи Angular', - description: - 'Перевірте свої знання базових концепцій Angular: компоненти, директиви, сервіси, DI, маршрутизація, форми, Signals та RxJS.', - }, - en: { - title: 'Angular Fundamentals', - description: - 'Test your knowledge of Angular basics: components, directives, services, DI, routing, forms, Signals, and RxJS.', - }, - pl: { - title: 'Podstawy Angular', - description: - 'Sprawdź swoją wiedzę o podstawach Angular: komponenty, dyrektywy, serwisy, DI, routing, formularze, Signals i RxJS.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -const projectRoot = existsSync(join(process.cwd(), 'json')) - ? process.cwd() - : join(process.cwd(), '..'); - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - projectRoot, - 'json', - 'quizzes', - 'angular', - 'beginner_medium', - `angular-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seeds/seed-quiz-angular.ts '); - console.log('Example: npx tsx db/seeds/seed-quiz-angular.ts 1'); - console.log('Or upload all: npx tsx db/seeds/seed-quiz-angular.ts all'); - process.exit(1); - } - - console.log('Starting Angular quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-css-advanced.ts b/frontend/db/seed-quiz-css-advanced.ts deleted file mode 100644 index 51da4e3e..00000000 --- a/frontend/db/seed-quiz-css-advanced.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'css'; - -const QUIZ_METADATA = { - slug: 'css-advanced', - questionsCount: 40, - timeLimitSeconds: 2400, - translations: { - uk: { - title: 'CSS Advanced', - description: - 'Поглиблені концепції CSS: 3D-трансформації, оптимізація продуктивності, методології архітектури, препроцесори, CSS-in-JS та сучасні можливості.', - }, - en: { - title: 'CSS Advanced', - description: - 'Advanced CSS concepts: 3D transforms, performance optimization, architecture methodologies, preprocessors, CSS-in-JS, and modern features.', - }, - pl: { - title: 'CSS Zaawansowany', - description: - 'Zaawansowane koncepcje CSS: transformacje 3D, optymalizacja wydajności, metodologie architektury, preprocesory, CSS-in-JS i nowoczesne funkcje.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'css', - 'advanced', - `css-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 3, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 3, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-css-advanced.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-css-advanced.ts 1'); - console.log( - 'Or upload all: npx tsx db/seeds/seed-quiz-css-advanced.ts all' - ); - process.exit(1); - } - - console.log('Starting CSS Advanced quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-css.ts b/frontend/db/seed-quiz-css.ts deleted file mode 100644 index 32cbaf20..00000000 --- a/frontend/db/seed-quiz-css.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'css'; - -const QUIZ_METADATA = { - slug: 'css-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'Основи CSS', - description: - 'Перевірте свої знання базових концепцій CSS: селектори, блокова модель, Flexbox, Grid, позиціонування, анімації та адаптивний дизайн.', - }, - en: { - title: 'CSS Fundamentals', - description: - 'Test your knowledge of CSS basics: selectors, box model, Flexbox, Grid, positioning, animations, and responsive design.', - }, - pl: { - title: 'Podstawy CSS', - description: - 'Sprawdź swoją wiedzę o podstawach CSS: selektory, model pudełkowy, Flexbox, Grid, pozycjonowanie, animacje i responsywny design.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'css', - 'fundamentals', - `css-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seed-quiz-css.ts '); - console.log('Example: npx tsx db/seed-quiz-css.ts 1'); - console.log('Or upload all: npx tsx db/seed-quiz-css.ts all'); - process.exit(1); - } - - console.log('Starting CSS quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-from-json.ts b/frontend/db/seed-quiz-from-json.ts deleted file mode 100644 index 865fa29c..00000000 --- a/frontend/db/seed-quiz-from-json.ts +++ /dev/null @@ -1,202 +0,0 @@ -import 'dotenv/config'; - -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { dirname, join } from 'path'; -import { fileURLToPath } from 'url'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const CATEGORY_SLUG = 'react'; - -interface QuizMetadata { - quizId: string; - slug: string; - questionsCount: number; - timeLimitSeconds: number; - translations: Record; - questions: QuestionData[]; -} - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function seedQuizFromJson() { - console.log('Loading quiz data from JSON files...'); - - const part1Path = join(__dirname, '../data/react-quiz-data-part1.json'); - const part2Path = join(__dirname, '../data/react-quiz-data-part2.json'); - - const part1: QuizMetadata = JSON.parse(readFileSync(part1Path, 'utf-8')); - const part2: { questions: QuestionData[] } = JSON.parse( - readFileSync(part2Path, 'utf-8') - ); - - const allQuestions = [...part1.questions, ...part2.questions]; - console.log(`Loaded ${allQuestions.length} questions`); - - try { - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - console.log('Cleaning up old quiz...'); - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, part1.slug), - }); - let quizId: string; - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${part1.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db - .delete(quizQuestions) - .where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: part1.slug, - displayOrder: 1, - questionsCount: part1.questionsCount, - timeLimitSeconds: part1.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - quizId = existing.id; - } else { - console.log('Creating quiz...'); - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: part1.slug, - displayOrder: 1, - questionsCount: part1.questionsCount, - timeLimitSeconds: part1.timeLimitSeconds, - isActive: true, - }) - .returning(); - quizId = quiz.id; - } - console.log('Creating quiz translations...'); - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: part1.translations[locale].title, - description: part1.translations[locale].description, - }); - } - - console.log('Creating questions...'); - for (const question of allQuestions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - - if (question.order % 10 === 0) { - console.log(`Progress: ${question.order}/${allQuestions.length}`); - } - } - - console.log('Done'); - console.log(`Quiz ID: ${quizId}`); - console.log(`Questions: ${allQuestions.length}`); - console.log(`Answers: ${allQuestions.length * 4}`); - } catch (error) { - console.error('Error:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(e => { - console.error(e); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-git.ts b/frontend/db/seed-quiz-git.ts deleted file mode 100644 index 0fbe1893..00000000 --- a/frontend/db/seed-quiz-git.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'git'; - -const QUIZ_METADATA = { - slug: 'git-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'Основи Git', - description: - 'Перевірте свої знання системи контролю версій Git: команди, гілки, злиття, workflows та найкращі практики.', - }, - en: { - title: 'Git Fundamentals', - description: - 'Test your knowledge of Git version control: commands, branches, merging, workflows and best practices.', - }, - pl: { - title: 'Podstawy Git', - description: - 'Sprawdź swoją wiedzę o systemie kontroli wersji Git: polecenia, gałęzie, scalanie, workflows i najlepsze praktyki.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'git', - `git-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seeds/seed-quiz-git.ts '); - console.log('Example: npx tsx db/seeds/seed-quiz-git.ts 1'); - console.log('Or upload all: npx tsx db/seeds/seed-quiz-git.ts all'); - process.exit(1); - } - - console.log('Starting Git quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-html-advanced.ts b/frontend/db/seed-quiz-html-advanced.ts deleted file mode 100644 index a3d22456..00000000 --- a/frontend/db/seed-quiz-html-advanced.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'html'; - -const QUIZ_METADATA = { - slug: 'html-advanced', - questionsCount: 40, - timeLimitSeconds: 2400, - translations: { - uk: { - title: 'HTML Поглиблений', - description: - 'Перевірте поглиблені знання HTML: Web Components, Shadow DOM, API браузера, доступність, SEO, продуктивність та сучасні можливості.', - }, - en: { - title: 'HTML Advanced', - description: - 'Test your advanced HTML knowledge: Web Components, Shadow DOM, browser APIs, accessibility, SEO, performance and modern features.', - }, - pl: { - title: 'HTML Zaawansowany', - description: - 'Sprawdź zaawansowaną wiedzę o HTML: Web Components, Shadow DOM, API przeglądarki, dostępność, SEO, wydajność i nowoczesne funkcje.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'html', - 'advanced', - `html-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seed-quiz-html-advanced.ts '); - console.log('Example: npx tsx db/seed-quiz-html-advanced.ts 1'); - console.log('Or upload all: npx tsx db/seed-quiz-html-advanced.ts all'); - process.exit(1); - } - - console.log('Starting HTML Advanced quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-html.ts b/frontend/db/seed-quiz-html.ts deleted file mode 100644 index de29fc1d..00000000 --- a/frontend/db/seed-quiz-html.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'html'; - -const QUIZ_METADATA = { - slug: 'html-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'Основи HTML', - description: - 'Перевірте свої знання HTML: структура документа, теги, атрибути, форми, семантична розмітка та доступність.', - }, - en: { - title: 'HTML Fundamentals', - description: - 'Test your knowledge of HTML: document structure, tags, attributes, forms, semantic markup and accessibility.', - }, - pl: { - title: 'Podstawy HTML', - description: - 'Sprawdź swoją wiedzę o HTML: struktura dokumentu, tagi, atrybuty, formularze, semantyczne znaczniki i dostępność.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'html', - 'fundamentals', - `html-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seed-quiz-html.ts '); - console.log('Example: npx tsx db/seed-quiz-html.ts 1'); - console.log('Or upload all: npx tsx db/seed-quiz-html.ts all'); - process.exit(1); - } - - console.log('Starting HTML quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-javascript-advanced.ts b/frontend/db/seed-quiz-javascript-advanced.ts deleted file mode 100644 index 904521ee..00000000 --- a/frontend/db/seed-quiz-javascript-advanced.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'javascript'; - -const QUIZ_METADATA = { - slug: 'javascript-advanced', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'JavaScript Просунутий', - description: - 'Перевірте свої знання JavaScript на просунутому рівні: асинхронність, event loop, прототипи, ООП, модулі, продуктивність та можливості ES6+.', - }, - en: { - title: 'JavaScript Advanced', - description: - 'Test your advanced JavaScript knowledge: async patterns, event loop, prototypes, OOP, modules, performance and ES6+ features.', - }, - pl: { - title: 'JavaScript Zaawansowany', - description: - 'Sprawdź zaawansowaną wiedzę z JavaScript: asynchroniczność, event loop, prototypy, OOP, moduły, wydajność i możliwości ES6+.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'js', - 'advanced', - `javascript-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seed-quiz-javascript.ts '); - console.log('Example: npx tsx db/seed-quiz-javascript.ts 1'); - console.log('Or upload all: npx tsx db/seed-quiz-javascript.ts all'); - process.exit(1); - } - - console.log('Starting JavaScript quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-javascript.ts b/frontend/db/seed-quiz-javascript.ts deleted file mode 100644 index a622757d..00000000 --- a/frontend/db/seed-quiz-javascript.ts +++ /dev/null @@ -1,290 +0,0 @@ -import 'dotenv/config'; - -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'javascript'; - -const QUIZ_METADATA = { - slug: 'javascript-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'Основи JavaScript', - description: - 'Перевірте свої знання базових концепцій JavaScript: змінні, типи даних, функції, асинхронність, прототипи та ES6+ можливості.', - }, - en: { - title: 'JavaScript Fundamentals', - description: - 'Test your knowledge of JavaScript basics: variables, data types, functions, asynchronicity, prototypes and ES6+ features.', - }, - pl: { - title: 'Podstawy JavaScript', - description: - 'Sprawdź swoją wiedzę o podstawach JavaScript: zmienne, typy danych, funkcje, asynchroniczność, prototypy i funkcje ES6+.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'data', - `javascript-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(forceDelete: boolean): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - - if (existing) { - const existingAttempts = await db - .select() - .from(quizAttempts) - .where(eq(quizAttempts.quizId, existing.id)) - .limit(1); - - if (existingAttempts.length > 0) { - if (!forceDelete) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Use --force to delete them.` - ); - } - - console.log('Deleting existing attempts (--force flag used)...'); - await db.delete(quizAttempts).where(eq(quizAttempts.quizId, existing.id)); - console.log('Attempts deleted.'); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const forceDelete = args.includes('--force'); - const partArg = args.find(arg => arg !== '--force'); - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seed-quiz-javascript.ts [--force]'); - console.log('Example: npx tsx db/seed-quiz-javascript.ts 1'); - console.log(' npx tsx db/seed-quiz-javascript.ts all --force'); - process.exit(1); - } - - console.log('Starting JavaScript quiz seed...\n'); - if (forceDelete) { - console.log('WARNING: --force flag used. Existing attempts will be deleted.\n'); - } - - try { - const quizId = await ensureQuizExists(forceDelete); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-nodejs-advanced.ts b/frontend/db/seed-quiz-nodejs-advanced.ts deleted file mode 100644 index b562b648..00000000 --- a/frontend/db/seed-quiz-nodejs-advanced.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'node'; - -const QUIZ_METADATA = { - slug: 'node-advanced', - questionsCount: 40, - timeLimitSeconds: 1200, - translations: { - uk: { - title: 'Node.js Advanced', - description: - 'Поглиблені знання Node.js: мікросервіси, gRPC, GraphQL, NestJS, serverless, CI/CD, Kubernetes, безпека та масштабування.', - }, - en: { - title: 'Node.js Advanced', - description: - 'Advanced Node.js knowledge: microservices, gRPC, GraphQL, NestJS, serverless, CI/CD, Kubernetes, security, and scaling.', - }, - pl: { - title: 'Node.js Advanced', - description: - 'Zaawansowana wiedza o Node.js: mikroserwisy, gRPC, GraphQL, NestJS, serverless, CI/CD, Kubernetes, bezpieczeństwo i skalowanie.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -const projectRoot = existsSync(join(process.cwd(), 'json')) - ? process.cwd() - : join(process.cwd(), '..'); - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - projectRoot, - 'json', - 'quizzes', - 'node', - 'advanced', - `nodejs-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-nodejs-advanced.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-nodejs-advanced.ts 1'); - console.log( - 'Or upload all: npx tsx db/seeds/seed-quiz-nodejs-advanced.ts all' - ); - process.exit(1); - } - - console.log('Starting Node.js Advanced quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-nodejs.ts b/frontend/db/seed-quiz-nodejs.ts deleted file mode 100644 index de4fcdd1..00000000 --- a/frontend/db/seed-quiz-nodejs.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'node'; - -const QUIZ_METADATA = { - slug: 'node-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1200, - translations: { - uk: { - title: 'Основи Node.js', - description: - 'Перевірте свої знання базових концепцій Node.js: event loop, модулі, асинхронність, Express.js, тестування та деплой.', - }, - en: { - title: 'Node.js Fundamentals', - description: - 'Test your knowledge of Node.js basics: event loop, modules, asynchronicity, Express.js, testing, and deployment.', - }, - pl: { - title: 'Podstawy Node.js', - description: - 'Sprawdź swoją wiedzę o podstawach Node.js: event loop, moduły, asynchroniczność, Express.js, testowanie i deployment.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -const projectRoot = existsSync(join(process.cwd(), 'json')) - ? process.cwd() - : join(process.cwd(), '..'); - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - projectRoot, - 'json', - 'quizzes', - 'node', - 'beginner_medium', - `nodejs-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-nodejs-fundamentals.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-nodejs-fundamentals.ts 1'); - console.log( - 'Or upload all: npx tsx db/seeds/seed-quiz-nodejs-fundamentals.ts all' - ); - process.exit(1); - } - - console.log('Starting Node.js Fundamentals quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-react.ts b/frontend/db/seed-quiz-react.ts deleted file mode 100644 index 9782bc26..00000000 --- a/frontend/db/seed-quiz-react.ts +++ /dev/null @@ -1,1010 +0,0 @@ -import 'dotenv/config'; - -import { randomUUID } from 'crypto'; -import { eq } from 'drizzle-orm'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; - -interface AnswerBlock { - type: 'paragraph' | 'numberedList' | 'bulletList' | 'code'; - language?: string; - children: AnswerBlockChild[]; -} - -interface AnswerBlockChild { - type?: 'listItem'; - text?: string; - bold?: boolean; - italic?: boolean; - code?: boolean; - children?: AnswerBlockChild[]; -} - -interface QuizQuestionSeed { - displayOrder: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - sourceQuestionId?: string; - content: Record< - Locale, - { - questionText: string; - explanation: AnswerBlock[]; - } - >; - answers: { - displayOrder: number; - isCorrect: boolean; - translations: Record; - }[]; -} - -const QUIZ_ID = randomUUID(); -const CATEGORY_SLUG = 'react'; - -const quizData = { - id: QUIZ_ID, - slug: 'react-fundamentals', - displayOrder: 1, - questionsCount: 5, - timeLimitSeconds: 300, - isActive: true, -}; - -const quizTranslationsData: Record< - Locale, - { title: string; description: string } -> = { - uk: { - title: 'Основи React', - description: - 'Перевірте свої знання базових концепцій React: компоненти,Virtual DOM, JSX, props та state.', - }, - en: { - title: 'React Fundamentals', - description: - 'Test your knowledge of React basics: components, Virtual DOM,JSX, props and state.', - }, - pl: { - title: 'Podstawy React', - description: - 'Sprawdź swoją wiedzę o podstawach React: komponenty, Virtual DOM,JSX, props i state.', - }, -}; - -const questionsData: QuizQuestionSeed[] = [ - { - displayOrder: 1, - difficulty: 'beginner', - content: { - uk: { - questionText: 'Що найточніше описує React?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'React — це саме ', bold: false }, - { text: 'бібліотека', bold: true }, - { - text: ', а не фреймворк. Він фокусується на одній задачі: побудоваUI.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: 'На відміну від фреймворків (Angular, Vue), React не диктуєструктуру проєкту і дозволяє обирати інші інструменти для роутингу, стану тощо.', - }, - ], - }, - ], - }, - en: { - questionText: 'What best describes React?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'React is a ', bold: false }, - { text: 'library', bold: true }, - { - text: ', not a framework. It focuses on one task: building UI.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: "Unlike frameworks (Angular, Vue), React doesn't dictate project structure and lets you choose other tools for routing, state management,etc.", - }, - ], - }, - ], - }, - pl: { - questionText: 'Co najlepiej opisuje React?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'React to ', bold: false }, - { text: 'biblioteka', bold: true }, - { - text: ', a nie framework. Skupia się na jednym zadaniu: budowaniuUI.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: 'W przeciwieństwie do frameworków (Angular, Vue), React nienarzuca struktury projektu i pozwala wybierać inne narzędzia do routingu,zarządzania stanem itp.', - }, - ], - }, - ], - }, - }, - answers: [ - { - displayOrder: 1, - isCorrect: false, - translations: { - uk: 'Повноцінний фреймворк для створення веб-додатків', - en: 'A full-featured framework for building web applications', - pl: 'Pełnoprawny framework do tworzenia aplikacji webowych', - }, - }, - { - displayOrder: 2, - isCorrect: true, - translations: { - uk: 'JavaScript-бібліотека для створення користувацьких інтерфейсів', - en: 'A JavaScript library for building user interfaces', - pl: 'Biblioteka JavaScript do tworzenia interfejsów użytkownika', - }, - }, - { - displayOrder: 3, - isCorrect: false, - translations: { - uk: 'Мова програмування для веб-розробки', - en: 'A programming language for web development', - pl: 'Język programowania do tworzenia stron internetowych', - }, - }, - { - displayOrder: 4, - isCorrect: false, - translations: { - uk: 'База даних для зберігання даних додатків', - en: 'A database for storing application data', - pl: 'Baza danych do przechowywania danych aplikacji', - }, - }, - ], - }, - - { - displayOrder: 2, - difficulty: 'beginner', - content: { - uk: { - questionText: 'Навіщо React використовує Virtual DOM?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'Virtual DOM', bold: true }, - { text: " — це легка копія реального DOM в пам'яті." }, - ], - }, - { - type: 'numberedList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'Коли щось змінюється, React спочатку оновлюєвіртуальну версію', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Порівнює її з попередньою (це називається"diffing")', - }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'Вносить лише мінімальні зміни в реальний DOM' }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'Це набагато швидше, ніж перемальовувати всю сторінку.' }, - ], - }, - ], - }, - en: { - questionText: 'Why does React use Virtual DOM?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'Virtual DOM', bold: true }, - { text: ' is a lightweight copy of the real DOM in memory.' }, - ], - }, - { - type: 'numberedList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'When something changes, React first updates thevirtual version', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Compares it with the previous one (this iscalled "diffing")', - }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'Makes only minimal changes to the real DOM' }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'This is much faster than repainting the entire page.' }, - ], - }, - ], - }, - pl: { - questionText: 'Dlaczego React używa Virtual DOM?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'Virtual DOM', bold: true }, - { text: ' to lekka kopia prawdziwego DOM w pamięci.' }, - ], - }, - { - type: 'numberedList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'Gdy coś się zmienia, React najpierw aktualizuje wirtualną wersję', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Porównuje ją z poprzednią (nazywa się to"diffing")', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Wprowadza tylko minimalne zmiany do prawdziwego DOM', - }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'To znacznie szybsze niż przemalowywanie całej strony.' }, - ], - }, - ], - }, - }, - answers: [ - { - displayOrder: 1, - isCorrect: false, - translations: { - uk: 'Щоб зберігати дані користувача в браузері', - en: 'To store user data in the browser', - pl: 'Aby przechowywać dane użytkownika w przeglądarce', - }, - }, - { - displayOrder: 2, - isCorrect: true, - translations: { - uk: 'Для ефективного оновлення інтерфейсу без зайвих маніпуляцій з реальним DOM', - en: 'For efficient UI updates without unnecessary real DOM manipulations', - pl: 'Do efektywnych aktualizacji UI bez zbędnych manipulacji prawdziwymDOM', - }, - }, - { - displayOrder: 3, - isCorrect: false, - translations: { - uk: 'Для створення анімацій на сторінці', - en: 'To create animations on the page', - pl: 'Do tworzenia animacji na stronie', - }, - }, - { - displayOrder: 4, - isCorrect: false, - translations: { - uk: 'Щоб сторінка працювала без JavaScript', - en: 'So the page works without JavaScript', - pl: 'Aby strona działała bez JavaScript', - }, - }, - ], - }, - - { - displayOrder: 3, - difficulty: 'beginner', - content: { - uk: { - questionText: 'Що повертає хук useState?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [{ text: 'const [count, setCount] = useState(0);' }], - }, - { - type: 'paragraph', - children: [ - { text: 'Це ', bold: false }, - { text: 'деструктуризація масиву', bold: true }, - { - text: '. Перший елемент — поточне значення, другий —функція-сетер.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'Імена можна обирати будь-які, але конвенція: ' }, - { text: 'value', code: true }, - { text: ' і ' }, - { text: 'setValue', code: true }, - { text: '.' }, - ], - }, - ], - }, - en: { - questionText: 'What does the useState hook return?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [{ text: 'const [count, setCount] = useState(0);' }], - }, - { - type: 'paragraph', - children: [ - { text: 'This is ', bold: false }, - { text: 'array destructuring', bold: true }, - { - text: '. The first element is the current value, the second is thesetter function.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'You can choose any names, but the convention is: ' }, - { text: 'value', code: true }, - { text: ' and ' }, - { text: 'setValue', code: true }, - { text: '.' }, - ], - }, - ], - }, - pl: { - questionText: 'Co zwraca hook useState?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [{ text: 'const [count, setCount] = useState(0);' }], - }, - { - type: 'paragraph', - children: [ - { text: 'To ', bold: false }, - { text: 'destrukturyzacja tablicy', bold: true }, - { - text: '. Pierwszy element to aktualna wartość, drugi to funkcjasetter.', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'Możesz wybrać dowolne nazwy, ale konwencja to: ' }, - { text: 'value', code: true }, - { text: ' i ' }, - { text: 'setValue', code: true }, - { text: '.' }, - ], - }, - ], - }, - }, - answers: [ - { - displayOrder: 1, - isCorrect: false, - translations: { - uk: 'Тільки поточне значення стану', - en: 'Only the current state value', - pl: 'Tylko aktualną wartość stanu', - }, - }, - { - displayOrder: 2, - isCorrect: true, - translations: { - uk: 'Масив з двох елементів: поточне значення і функцію для його зміни', - en: 'An array of two elements: current value and a function to changeit', - pl: 'Tablicę dwóch elementów: aktualną wartość i funkcję do jej zmiany', - }, - }, - { - displayOrder: 3, - isCorrect: false, - translations: { - uk: "Об'єкт з методами get і set", - en: 'An object with get and set methods', - pl: 'Obiekt z metodami get i set', - }, - }, - { - displayOrder: 4, - isCorrect: false, - translations: { - uk: 'Promise з новим значенням', - en: 'A Promise with the new value', - pl: 'Promise z nową wartością', - }, - }, - ], - }, - - { - displayOrder: 4, - difficulty: 'medium', - content: { - uk: { - questionText: 'Що означає порожній масив залежностей [] у useEffect?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [ - { - text: 'useEffect(() => {\n console.log("Виконається один раз");\n}, []);', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'Це аналог ' }, - { text: 'componentDidMount', code: true }, - { text: ' з класових компонентів.' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'Порожній масив каже React: "цей ефект незалежить від жодних значень"', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Без масиву — ефект запускається після кожногорендеру', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'З залежностями [a, b] — ефект запускається при зміні a або b', - }, - ], - }, - ], - }, - ], - }, - en: { - questionText: - 'What does an empty dependency array [] in useEffect mean?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [ - { - text: 'useEffect(() => {\n console.log("Runs once");\n}, []);', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'This is equivalent to ' }, - { text: 'componentDidMount', code: true }, - { text: ' from class components.' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'Empty array tells React: "this effect doesn\'tdepend on any values"', - }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'Without array — effect runs after every render' }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'With dependencies [a, b] — effect runs when aor b changes', - }, - ], - }, - ], - }, - ], - }, - pl: { - questionText: 'Co oznacza pusta tablica zależności [] w useEffect?', - explanation: [ - { - type: 'code', - language: 'javascript', - children: [ - { - text: 'useEffect(() => {\n console.log("Uruchomi się raz");\n},[]);', - }, - ], - }, - { - type: 'paragraph', - children: [ - { text: 'To odpowiednik ' }, - { text: 'componentDidMount', code: true }, - { text: ' z komponentów klasowych.' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { - text: 'Pusta tablica mówi React: "ten efekt nie zależy od żadnych wartości"', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Bez tablicy — efekt uruchamia się po każdymrenderze', - }, - ], - }, - { - type: 'listItem', - children: [ - { - text: 'Z zależnościami [a, b] — efekt uruchamia sięgdy zmienia się a lub b', - }, - ], - }, - ], - }, - ], - }, - }, - answers: [ - { - displayOrder: 1, - isCorrect: false, - translations: { - uk: 'Ефект ніколи не виконається', - en: 'The effect will never run', - pl: 'Efekt nigdy się nie uruchomi', - }, - }, - { - displayOrder: 2, - isCorrect: true, - translations: { - uk: 'Ефект виконається лише один раз після першого рендеру', - en: 'The effect will run only once after the first render', - pl: 'Efekt uruchomi się tylko raz po pierwszym renderze', - }, - }, - { - displayOrder: 3, - isCorrect: false, - translations: { - uk: 'Ефект виконуватиметься на кожному рендері', - en: 'The effect will run on every render', - pl: 'Efekt będzie uruchamiany przy każdym renderze', - }, - }, - { - displayOrder: 4, - isCorrect: false, - translations: { - uk: 'Це синтаксична помилка', - en: 'This is a syntax error', - pl: 'To błąd składniowy', - }, - }, - ], - }, - - { - displayOrder: 5, - difficulty: 'beginner', - content: { - uk: { - questionText: 'Чим state відрізняється від props?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'State', bold: true }, - { text: ' — це "пам\'ять" компонента. На відміну від props:' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { text: 'Props', bold: true }, - { - text: ' передаються від батьківського компонента (read-only)', - }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'State', bold: true }, - { text: ' належить самому компоненту і може змінюватися' }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: 'Коли state змінюється — компонент перерендерюєтьсяавтоматично.', - }, - ], - }, - ], - }, - en: { - questionText: 'How does state differ from props?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'State', bold: true }, - { text: ' is the component\'s "memory". Unlike props:' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { text: 'Props', bold: true }, - { text: ' are passed from the parent component (read-only)' }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'State', bold: true }, - { - text: ' belongs to the component itself and can be changed', - }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: 'When state changes — the component re-renders automatically.', - }, - ], - }, - ], - }, - pl: { - questionText: 'Czym state różni się od props?', - explanation: [ - { - type: 'paragraph', - children: [ - { text: 'State', bold: true }, - { text: ' to "pamięć" komponentu. W przeciwieństwie do props:' }, - ], - }, - { - type: 'bulletList', - children: [ - { - type: 'listItem', - children: [ - { text: 'Props', bold: true }, - { - text: ' są przekazywane od komponentu nadrzędnego (tylko doodczytu)', - }, - ], - }, - { - type: 'listItem', - children: [ - { text: 'State', bold: true }, - { text: ' należy do samego komponentu i może być zmieniany' }, - ], - }, - ], - }, - { - type: 'paragraph', - children: [ - { - text: 'Gdy state się zmienia — komponent renderuje się automatycznie ponownie.', - }, - ], - }, - ], - }, - }, - answers: [ - { - displayOrder: 1, - isCorrect: false, - translations: { - uk: 'State передається від батьківського компонента', - en: 'State is passed from the parent component', - pl: 'State jest przekazywany od komponentu nadrzędnego', - }, - }, - { - displayOrder: 2, - isCorrect: true, - translations: { - uk: 'State — це внутрішні дані компонента, які він може змінювати', - en: "State is the component's internal data that it can modify", - pl: 'State to wewnętrzne dane komponentu, które może modyfikować', - }, - }, - { - displayOrder: 3, - isCorrect: false, - translations: { - uk: 'State не впливає на рендеринг компонента', - en: 'State does not affect component rendering', - pl: 'State nie wpływa na renderowanie komponentu', - }, - }, - { - displayOrder: 4, - isCorrect: false, - translations: { - uk: 'State доступний тільки в класових компонентах', - en: 'State is only available in class components', - pl: 'State jest dostępny tylko w komponentach klasowych', - }, - }, - ], - }, -]; - -async function seedReactQuiz() { - console.log('Starting React quiz seed...'); - - const locales: Locale[] = ['uk', 'en', 'pl']; - - try { - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - console.log('Creating quiz...'); - await db - .insert(quizzes) - .values({ - ...quizData, - categoryId: category.id, - }) - .onConflictDoNothing(); - - console.log('Creating quiz translations...'); - for (const locale of locales) { - await db - .insert(quizTranslations) - .values({ - quizId: QUIZ_ID, - locale, - title: quizTranslationsData[locale].title, - description: quizTranslationsData[locale].description, - }) - .onConflictDoNothing(); - } - - console.log('Creating questions...'); - for (const question of questionsData) { - const questionId = randomUUID(); - - await db - .insert(quizQuestions) - .values({ - id: questionId, - quizId: QUIZ_ID, - displayOrder: question.displayOrder, - difficulty: question.difficulty, - sourceQuestionId: question.sourceQuestionId ?? null, - }) - .onConflictDoNothing(); - - for (const locale of locales) { - await db - .insert(quizQuestionContent) - .values({ - quizQuestionId: questionId, - locale, - questionText: question.content[locale].questionText, - explanation: question.content[locale].explanation, - }) - .onConflictDoNothing(); - } - - for (const answer of question.answers) { - const answerId = randomUUID(); - - await db - .insert(quizAnswers) - .values({ - id: answerId, - quizQuestionId: questionId, - displayOrder: answer.displayOrder, - isCorrect: answer.isCorrect, - }) - .onConflictDoNothing(); - - for (const locale of locales) { - await db - .insert(quizAnswerTranslations) - .values({ - quizAnswerId: answerId, - locale, - answerText: answer.translations[locale], - }) - .onConflictDoNothing(); - } - } - } - - console.log('React quiz seeded successfully!'); - console.log(` - 1 quiz with ${locales.length} translations`); - console.log( - ` - ${questionsData.length} questions with ${locales.length} translations each` - ); - console.log( - ` - ${questionsData.length * 4} answers with ${ - locales.length - } translations each` - ); - } catch (error) { - console.error('Error seeding quiz:', error); - throw error; - } -} - -async function _cleanupReactQuiz() { - console.log('🧹 Cleaning up React quiz...'); - - await db.delete(quizAnswerTranslations); - await db.delete(quizAnswers); - await db.delete(quizQuestionContent); - await db.delete(quizQuestions); - await db.delete(quizTranslations); - await db.delete(quizzes); - - console.log('Cleanup complete!'); -} - -seedReactQuiz() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-types.ts b/frontend/db/seed-quiz-types.ts deleted file mode 100644 index 7e8ade39..00000000 --- a/frontend/db/seed-quiz-types.ts +++ /dev/null @@ -1,70 +0,0 @@ -export type Locale = 'uk' | 'en' | 'pl'; - -export interface AnswerBlock { - type: 'paragraph' | 'numberedList' | 'bulletList' | 'code'; - language?: string; - children: AnswerBlockChild[]; -} - -export interface AnswerBlockChild { - type?: 'listItem'; - text?: string; - bold?: boolean; - italic?: boolean; - code?: boolean; - children?: AnswerBlockChild[]; -} - -export interface QuizQuestionSeed { - id: string; - displayOrder: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - content: Record< - Locale, - { - questionText: string; - explanation: AnswerBlock[]; - } - >; - answers: { - id: string; - displayOrder: number; - isCorrect: boolean; - translations: Record; - }[]; -} - -export const p = (children: AnswerBlockChild[]): AnswerBlock => ({ - type: 'paragraph', - children, -}); - -export const t = ( - text: string, - opts?: { bold?: boolean; italic?: boolean; code?: boolean } -): AnswerBlockChild => ({ - text, - ...opts, -}); - -export const code = (text: string, language = 'javascript'): AnswerBlock => ({ - type: 'code', - language, - children: [{ text }], -}); - -export const ul = (items: string[]): AnswerBlock => ({ - type: 'bulletList', - children: items.map(item => ({ - type: 'listItem' as const, - children: [{ text: item }], - })), -}); - -export const ol = (items: string[]): AnswerBlock => ({ - type: 'numberedList', - children: items.map(item => ({ - type: 'listItem' as const, - children: [{ text: item }], - })), -}); diff --git a/frontend/db/seed-quiz-typescript-advanced.ts b/frontend/db/seed-quiz-typescript-advanced.ts deleted file mode 100644 index 0c7573d9..00000000 --- a/frontend/db/seed-quiz-typescript-advanced.ts +++ /dev/null @@ -1,280 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'typescript'; - -const QUIZ_METADATA = { - slug: 'typescript-advanced', - questionsCount: 40, - timeLimitSeconds: 2400, - translations: { - uk: { - title: 'TypeScript Advanced', - description: - 'Поглиблений квіз з TypeScript: декоратори, рефлексія, утилітні типи, infer, template literal types, патерни проектування та інтеграція з фреймворками.', - }, - en: { - title: 'TypeScript Advanced', - description: - 'Advanced TypeScript quiz: decorators, reflection, utility types, infer, template literal types, design patterns and framework integration.', - }, - pl: { - title: 'TypeScript Advanced', - description: - 'Zaawansowany quiz z TypeScript: dekoratory, refleksja, typy narzędziowe, infer, template literal types, wzorce projektowe i integracja z frameworkami.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'typescript', - 'advanced', - `typescript-advanced-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 2, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-typescript-advanced.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-typescript-advanced.ts 1'); - console.log( - 'Or upload all: npx tsx db/seeds/seed-quiz-typescript-advanced.ts all' - ); - process.exit(1); - } - - console.log('Starting TypeScript Advanced quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-typescript.ts b/frontend/db/seed-quiz-typescript.ts deleted file mode 100644 index 614ebeee..00000000 --- a/frontend/db/seed-quiz-typescript.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'typescript'; - -const QUIZ_METADATA = { - slug: 'typescript-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1800, - translations: { - uk: { - title: 'Основи TypeScript', - description: - 'Перевірте свої знання базових концепцій TypeScript: типи даних, інтерфейси, generics, класи, модулі та утилітні типи.', - }, - en: { - title: 'TypeScript Fundamentals', - description: - 'Test your knowledge of TypeScript basics: data types, interfaces, generics, classes, modules and utility types.', - }, - pl: { - title: 'Podstawy TypeScript', - description: - 'Sprawdź swoją wiedzę o podstawach TypeScript: typy danych, interfejsy, generics, klasy, moduły i typy narzędziowe.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - process.cwd(), - 'parse', - 'typescript', - 'fundamentals', - `typescript-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log( - 'Usage: npx tsx db/seeds/seed-quiz-typescript.ts ' - ); - console.log('Example: npx tsx db/seeds/seed-quiz-typescript.ts 1'); - console.log('Or upload all: npx tsx db/seeds/seed-quiz-typescript.ts all'); - process.exit(1); - } - - console.log('Starting TypeScript quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-verify.ts b/frontend/db/seed-quiz-verify.ts deleted file mode 100644 index bd24e457..00000000 --- a/frontend/db/seed-quiz-verify.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { eq, sql } from 'drizzle-orm'; - -import { - quizAnswers, - quizAnswerTranslations, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from '@/db/schema/quiz'; - -import { db } from './index'; - -async function verifyQuizSeed() { - console.log('Verifying quiz seed data...\n'); - - console.log('Record counts:'); - - const quizCount = await db - .select({ count: sql`count(*)` }) - .from(quizzes); - console.log(` quizzes: ${quizCount[0].count}`); - - const quizTransCount = await db - .select({ count: sql`count(*)` }) - .from(quizTranslations); - console.log(` quiz_translations: ${quizTransCount[0].count}`); - - const questionCount = await db - .select({ count: sql`count(*)` }) - .from(quizQuestions); - console.log(` quiz_questions: ${questionCount[0].count}`); - - const questionContentCount = await db - .select({ count: sql`count(*)` }) - .from(quizQuestionContent); - console.log(` quiz_question_content: ${questionContentCount[0].count}`); - - const answerCount = await db - .select({ count: sql`count(*)` }) - .from(quizAnswers); - console.log(` quiz_answers: ${answerCount[0].count}`); - - const answerTransCount = await db - .select({ count: sql`count(*)` }) - .from(quizAnswerTranslations); - console.log(` quiz_answer_translations: ${answerTransCount[0].count}`); - - console.log('\nQuiz data:'); - const quizData = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, 'react-fundamentals'), - with: { - translations: true, - }, - }); - - if (quizData) { - console.log(` ID: ${quizData.id}`); - console.log(` Slug: ${quizData.slug}`); - console.log(` Questions: ${quizData.questionsCount}`); - console.log(` Time limit: ${quizData.timeLimitSeconds}s`); - console.log(' Translations:'); - quizData.translations?.forEach(t => { - console.log(` [${t.locale}] ${t.title}`); - }); - } - - console.log('\nSample question (first one):'); - const sampleQuestion = await db.query.quizQuestions.findFirst({ - where: eq(quizQuestions.quizId, 'quiz-react-fundamentals'), - orderBy: (q, { asc }) => asc(q.displayOrder), - with: { - content: true, - answers: { - with: { - translations: true, - }, - }, - }, - }); - - if (sampleQuestion) { - console.log(` ID: ${sampleQuestion.id}`); - console.log(` Difficulty: ${sampleQuestion.difficulty}`); - console.log(' Question text per locale:'); - sampleQuestion.content?.forEach(c => { - console.log(` [${c.locale}] ${c.questionText}`); - }); - - console.log(' Answers:'); - sampleQuestion.answers?.forEach(a => { - const correct = a.isCorrect ? '✅' : '❌'; - console.log( - ` ${correct} ${a.translations?.[0]?.answerText?.slice(0, 50)}...` - ); - }); - - console.log('\nExplanation JSON structure (UK):'); - const ukContent = sampleQuestion.content?.find(c => c.locale === 'uk'); - if (ukContent?.explanation) { - console.log(JSON.stringify(ukContent.explanation, null, 2)); - } - } - - console.log('\nLocale coverage check:'); - const locales = ['uk', 'en', 'pl']; - - for (const locale of locales) { - const contentCount = await db - .select({ count: sql`count(*)` }) - .from(quizQuestionContent) - .where(eq(quizQuestionContent.locale, locale)); - - const answerTransCount = await db - .select({ count: sql`count(*)` }) - .from(quizAnswerTranslations) - .where(eq(quizAnswerTranslations.locale, locale)); - - console.log( - ` [${locale}] questions: ${contentCount[0].count}, answers: ${answerTransCount[0].count}` - ); - } - - console.log('\nVerification complete!'); -} - -async function getQuizForLocale(slug: string, locale: string) { - console.log(`\nFetching quiz "${slug}" for locale "${locale}":\n`); - - const quiz = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, slug), - with: { - translations: { - where: eq(quizTranslations.locale, locale), - }, - questions: { - orderBy: (q, { asc }) => asc(q.displayOrder), - with: { - content: { - where: eq(quizQuestionContent.locale, locale), - }, - answers: { - orderBy: (a, { asc }) => asc(a.displayOrder), - with: { - translations: { - where: eq(quizAnswerTranslations.locale, locale), - }, - }, - }, - }, - }, - }, - }); - - if (!quiz) { - console.log('Quiz not found!'); - return; - } - - const result = { - id: quiz.id, - slug: quiz.slug, - title: quiz.translations?.[0]?.title, - description: quiz.translations?.[0]?.description, - questionsCount: quiz.questionsCount, - timeLimitSeconds: quiz.timeLimitSeconds, - questions: quiz.questions?.map((q, idx) => ({ - id: q.id, - number: `${idx + 1}.`, - text: q.content?.[0]?.questionText, - difficulty: q.difficulty, - explanation: q.content?.[0]?.explanation, - answers: q.answers?.map(a => ({ - id: a.id, - text: a.translations?.[0]?.answerText, - isCorrect: a.isCorrect, - })), - })), - }; - - console.log(JSON.stringify(result, null, 2)); - - return result; -} - -async function main() { - await verifyQuizSeed(); - await getQuizForLocale('react-fundamentals', 'uk'); - await getQuizForLocale('react-fundamentals', 'en'); - await getQuizForLocale('react-fundamentals', 'pl'); -} - -main() - .then(() => process.exit(0)) - .catch(error => { - console.error('Error:', error); - process.exit(1); - }); diff --git a/frontend/db/seed-quiz-vue.ts b/frontend/db/seed-quiz-vue.ts deleted file mode 100644 index 96f2fd08..00000000 --- a/frontend/db/seed-quiz-vue.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { eq } from 'drizzle-orm'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; - -import { db } from './index'; -import { categories } from './schema/categories'; -import { - quizAnswers, - quizAnswerTranslations, - quizAttempts, - quizQuestionContent, - quizQuestions, - quizTranslations, - quizzes, -} from './schema/quiz'; - -type Locale = 'uk' | 'en' | 'pl'; -const LOCALES: Locale[] = ['uk', 'en', 'pl']; - -interface QuestionData { - id: string; - order: number; - difficulty: 'beginner' | 'medium' | 'advanced'; - uk: { q: string; exp: string }; - en: { q: string; exp: string }; - pl: { q: string; exp: string }; - answers: { - uk: string; - en: string; - pl: string; - correct: boolean; - }[]; -} - -interface QuizPartData { - questions: QuestionData[]; -} - -const CATEGORY_SLUG = 'vue'; - -const QUIZ_METADATA = { - slug: 'vue-fundamentals', - questionsCount: 40, - timeLimitSeconds: 1200, - translations: { - uk: { - title: 'Основи Vue.js', - description: - 'Перевірте свої знання базових концепцій Vue.js: компоненти, реактивність, директиви, Composition API, Vue Router, Pinia та життєвий цикл.', - }, - en: { - title: 'Vue.js Fundamentals', - description: - 'Test your knowledge of Vue.js basics: components, reactivity, directives, Composition API, Vue Router, Pinia, and lifecycle.', - }, - pl: { - title: 'Podstawy Vue.js', - description: - 'Sprawdź swoją wiedzę o podstawach Vue.js: komponenty, reaktywność, dyrektywy, Composition API, Vue Router, Pinia i cykl życia.', - }, - }, -}; - -function createExplanation(text: string) { - return [{ type: 'paragraph' as const, children: [{ text }] }]; -} - -const projectRoot = existsSync(join(process.cwd(), 'json')) - ? process.cwd() - : join(process.cwd(), '..'); - -async function loadQuestions(partNumber: number): Promise { - const partPath = join( - projectRoot, - 'json', - 'quizzes', - 'vue', - 'beginner_medium', - `vue-quiz-part${partNumber}.json` - ); - const partData: QuizPartData = JSON.parse(readFileSync(partPath, 'utf-8')); - return partData.questions; -} - -async function ensureQuizExists(): Promise { - console.log('Ensuring quiz exists...'); - - const [category] = await db - .select() - .from(categories) - .where(eq(categories.slug, CATEGORY_SLUG)) - .limit(1); - - if (!category) { - throw new Error( - `Category "${CATEGORY_SLUG}" not found. Run seed:categories first.` - ); - } - - const existing = await db.query.quizzes.findFirst({ - where: eq(quizzes.slug, QUIZ_METADATA.slug), - }); - - if (existing) { - const existingAttempt = await db.query.quizAttempts.findFirst({ - where: eq(quizAttempts.quizId, existing.id), - }); - if (existingAttempt) { - throw new Error( - `Quiz ${QUIZ_METADATA.slug} has existing attempts. Aborting to avoid data loss.` - ); - } - - await db.delete(quizQuestions).where(eq(quizQuestions.quizId, existing.id)); - await db - .delete(quizTranslations) - .where(eq(quizTranslations.quizId, existing.id)); - await db - .update(quizzes) - .set({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .where(eq(quizzes.id, existing.id)); - - const quizId = existing.id; - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quizId; - } - - const [quiz] = await db - .insert(quizzes) - .values({ - categoryId: category.id, - slug: QUIZ_METADATA.slug, - displayOrder: 1, - questionsCount: QUIZ_METADATA.questionsCount, - timeLimitSeconds: QUIZ_METADATA.timeLimitSeconds, - isActive: true, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizTranslations).values({ - quizId: quiz.id, - locale, - title: QUIZ_METADATA.translations[locale].title, - description: QUIZ_METADATA.translations[locale].description, - }); - } - - return quiz.id; -} - -async function seedQuestions( - questions: QuestionData[], - quizId: string, - partNumber: number -) { - console.log( - `Seeding ${questions.length} questions from part ${partNumber}...` - ); - - for (const question of questions) { - const [q] = await db - .insert(quizQuestions) - .values({ - quizId, - displayOrder: question.order, - difficulty: question.difficulty, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizQuestionContent).values({ - quizQuestionId: q.id, - locale, - questionText: question[locale].q, - explanation: createExplanation(question[locale].exp), - }); - } - - for (let i = 0; i < question.answers.length; i++) { - const answer = question.answers[i]; - - const [a] = await db - .insert(quizAnswers) - .values({ - quizQuestionId: q.id, - displayOrder: i + 1, - isCorrect: answer.correct, - }) - .returning(); - - for (const locale of LOCALES) { - await db.insert(quizAnswerTranslations).values({ - quizAnswerId: a.id, - locale, - answerText: answer[locale], - }); - } - } - } - - console.log(`Part ${partNumber} completed (${questions.length} questions)`); -} - -async function seedQuizFromJson() { - const args = process.argv.slice(2); - const partArg = args[0]; - - if (!partArg) { - console.error('Error: Please specify which part to upload'); - console.log('Usage: npx tsx db/seeds/seed-quiz-vue.ts '); - console.log('Example: npx tsx db/seeds/seed-quiz-vue.ts 1'); - console.log('Or upload all: npx tsx db/seeds/seed-quiz-vue.ts all'); - process.exit(1); - } - - console.log('Starting Vue.js quiz seed...\n'); - - try { - const quizId = await ensureQuizExists(); - - if (partArg.toLowerCase() === 'all') { - console.log('Uploading all parts...\n'); - let totalQuestions = 0; - - for (let i = 1; i <= 4; i++) { - const questions = await loadQuestions(i); - await seedQuestions(questions, quizId, i); - totalQuestions += questions.length; - } - - console.log('\nAll parts seeded successfully!'); - console.log(` - 1 quiz with ${LOCALES.length} translations`); - console.log(` - ${totalQuestions} questions total`); - console.log( - ` - ${totalQuestions * 4} answers with ${LOCALES.length} translations each` - ); - } else { - const partNumber = parseInt(partArg, 10); - - if (isNaN(partNumber) || partNumber < 1 || partNumber > 4) { - console.error('Error: Part number must be between 1 and 4'); - process.exit(1); - } - - const questions = await loadQuestions(partNumber); - await seedQuestions(questions, quizId, partNumber); - - console.log('\nPart seeded successfully!'); - console.log(` - Quiz: ${QUIZ_METADATA.translations.en.title}`); - console.log(` - Part ${partNumber}: ${questions.length} questions`); - console.log( - ` - ${questions.length * 4} answers with ${LOCALES.length} translations each` - ); - } - } catch (error) { - console.error('\nError seeding quiz:', error); - throw error; - } -} - -seedQuizFromJson() - .then(() => process.exit(0)) - .catch(error => { - console.error(error); - process.exit(1); - }); diff --git a/frontend/db/seed-users.ts b/frontend/db/seed-users.ts deleted file mode 100644 index 5dfd7dc5..00000000 --- a/frontend/db/seed-users.ts +++ /dev/null @@ -1,66 +0,0 @@ -import 'dotenv/config'; - -import bcrypt from 'bcryptjs'; -import { eq } from 'drizzle-orm'; - -import { db } from './index'; -import { users } from './schema'; - -if (!process.env.DATABASE_URL) { - throw new Error('DATABASE_URL is missing'); -} - -async function main() { - console.log('[seed] Seeding users...'); - - const passwordHash = await bcrypt.hash('password123', 10); - - const seedUsers = [ - { - name: 'Admin User', - email: 'admin@example.com', - passwordHash, - emailVerified: new Date(), - role: 'admin', - }, - { - name: 'Test User', - email: 'user@example.com', - passwordHash, - emailVerified: null, - role: 'user', - }, - { - name: 'Google User', - email: 'google@example.com', - passwordHash: null, - emailVerified: new Date(), - role: 'user', - }, - ] as const; - - for (const user of seedUsers) { - const existing = await db - .select({ id: users.id }) - .from(users) - .where(eq(users.email, user.email)) - .limit(1); - - if (existing.length > 0) { - console.log(`[seed] Skipping existing user: ${user.email}`); - continue; - } - - await db.insert(users).values(user); - console.log(`[seed] Inserted user: ${user.email}`); - } - - console.log('[seed] Users seeding completed'); -} - -main() - .then(() => process.exit(0)) - .catch(err => { - console.error('[seed] Failed:', err); - process.exit(1); - }); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 02df7aab..1c761d31 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -135,7 +135,9 @@ "next": "Next", "previousPage": "Previous page", "nextPage": "Next page", - "page": "Page {page}" + "page": "Page {page}", + "itemsPerPage": "Per page", + "itemsPerPageAria": "Questions per page" } }, "quiz": { diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 4fcb8fd6..624b76be 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -135,7 +135,9 @@ "next": "Następna", "previousPage": "Poprzednia strona", "nextPage": "Następna strona", - "page": "Strona {page}" + "page": "Strona {page}", + "itemsPerPage": "Na stronę", + "itemsPerPageAria": "Liczba pytań na stronie" } }, "quiz": { diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 05fb3485..1a379caf 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -135,7 +135,9 @@ "next": "Вперед", "previousPage": "Попередня сторінка", "nextPage": "Наступна сторінка", - "page": "Сторінка {page}" + "page": "Сторінка {page}", + "itemsPerPage": "На сторінці", + "itemsPerPageAria": "Кількість питань на сторінці" } }, "quiz": { From 058b76968c5cc4c8a1a7165a7cea989eec478fd0 Mon Sep 17 00:00:00 2001 From: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com> Date: Sat, 14 Feb 2026 01:40:33 +0200 Subject: [PATCH 09/10] (SP: 1) [Frontend] Align quiz result messages with status badges, fix locale switch on result page (#325) * feat(quiz): add guest warning before start and bot protection Guest warning: show login/signup/continue buttons for unauthenticated users on quiz rules screen before starting. Bot protection: multi-attempt verification via Redis - each question can only be verified once per user per attempt. Keys use dynamic TTL matching quiz time limit and are cleared on retake. Additional fixes: - Footer flash on quiz navigation (added loading.tsx, eliminated redirect) - Renamed QaLoader to Loader for reuse across pages - React compiler purity errors (crypto.getRandomValues in handlers) - Start button disabled after retake (isStarting not reset) * refactor(quiz): PR review feedback - Extract shared resolveRequestIdentifier() helper to eliminate duplicated auth/IP resolution logic in route.ts and actions/quiz.ts - Return null instead of 'unknown' when identifier unresolvable, skip verification tracking for unidentifiable users - Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied timeLimitSeconds from persisting keys indefinitely - Add locale prefix to returnTo paths in guest warning links - Replace nested Button inside Link with styled Link to fix invalid HTML (interactive element nesting) * fix(quiz): fall through to IP when auth cookie is expired/invalid * feat(quiz): add quiz results dashboard and review page - Add quiz history section to dashboard with last attempt per quiz - Add review page showing incorrect questions with explanations - Add collapsible cards with expand/collapse all toggle - Add "Review Mistakes" button on quiz result screen - Add category icons to quiz page and review page headers - Add BookOpen icon to explanation block in QuizQuestion - Update guest message to mention error review benefit - Add i18n translations (en/uk/pl) for all new features * fix(quiz): scroll to next button on answer reveal, scope review cache by userId * fix(quiz): restore type imports and userId cache key after merge conflict * fix: restore type imports, sync @swc/helpers, fix indentation after merge * feat(quiz): add violations counter UI, fix disqualification threshold - Add ViolationsCounter component with color escalation (green/yellow/red) - Sticky top bar keeps counter visible on scroll (mobile/tablet) - Add i18n counter keys for en/uk/pl with ICU plural forms - Fix threshold bug: violations warning now triggers at 4+ (was 3+) to match actual integrity score calculation (100 - violations * 10 < 70) * fix(quiz): fix points mismatch between leaderboard and dashboard Dashboard showed raw pointsEarned from last quiz_attempt, while leaderboard summed improvement deltas from point_transactions. Additionally, orphaned transactions from re-seeded quizzes inflated leaderboard totals (12 rows, 83 ghost points cleaned up in DB). - Dashboard query now joins point_transactions to show actual awarded points per quiz instead of raw attempt score - Leaderboard query filters out orphaned transactions where the source attempt no longer exists in quiz_attempts * OBfix(quiz): fix points mismatch, consistent status badges, mobile UX Dashboard showed raw pointsEarned from last attempt while leaderboard summed improvement deltas from point_transactions. Orphaned transactions from re-seeded quizzes inflated leaderboard totals (cleaned up in DB). - Dashboard query joins point_transactions for actual awarded points - Leaderboard query filters orphaned transactions (source_id not in quiz_attempts) - Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard - Mobile quiz results show dash for zero points, added chevron indicator * fix(quiz): add breadcrumbs to review page, fix recommendation tautology * fix(quiz): align result messages with status badges, persist result on locale switch --- frontend/components/quiz/QuizContainer.tsx | 7 ++--- frontend/components/quiz/QuizResult.tsx | 30 +++++++++++----------- frontend/hooks/useQuizSession.ts | 8 +++++- frontend/lib/quiz/quiz-session.ts | 3 +++ frontend/messages/en.json | 16 ++++++------ frontend/messages/pl.json | 16 ++++++------ frontend/messages/uk.json | 16 ++++++------ 7 files changed, 53 insertions(+), 43 deletions(-) diff --git a/frontend/components/quiz/QuizContainer.tsx b/frontend/components/quiz/QuizContainer.tsx index ff1eb16f..13dcab1e 100644 --- a/frontend/components/quiz/QuizContainer.tsx +++ b/frontend/components/quiz/QuizContainer.tsx @@ -123,6 +123,9 @@ function quizReducer(state: QuizState, action: QuizAction): QuizState { startedAt: action.payload.startedAt ? new Date(action.payload.startedAt) : null, + pointsAwarded: action.payload.pointsAwarded ?? null, + attemptId: action.payload.attemptId ?? null, + isIncomplete: action.payload.isIncomplete ?? false, }; case 'RESTART': @@ -320,9 +323,6 @@ export function QuizContainer({ const handleSubmit = () => { const isIncomplete = state.answers.length < totalQuestions; - if (!isGuest) { - clearQuizSession(quizId); - } const correctAnswers = state.answers.filter(a => a.isCorrect).length; const percentage = (correctAnswers / totalQuestions) * 100; const timeSpentSeconds = state.startedAt @@ -403,6 +403,7 @@ export function QuizContainer({ }; const handleBackToTopicsClick = () => { + clearQuizSession(quizId); if (onBackToTopics) { onBackToTopics(); } else { diff --git a/frontend/components/quiz/QuizResult.tsx b/frontend/components/quiz/QuizResult.tsx index f0bd06d3..5caf8581 100644 --- a/frontend/components/quiz/QuizResult.tsx +++ b/frontend/components/quiz/QuizResult.tsx @@ -69,26 +69,26 @@ export function QuizResult({ }; } - if (percentage < 50) { + if (percentage < 70) { return { icon: , - title: t('needPractice.title'), - message: t('needPractice.message'), + title: t('study.title'), + message: t('study.message'), color: 'text-red-600 dark:text-red-400', }; - } else if (percentage < 80) { + } else if (percentage < 100) { return { - icon: , - title: t('goodJob.title'), - message: t('goodJob.message'), - color: 'text-orange-600 dark:text-orange-400', + icon: , + title: t('review.title'), + message: t('review.message'), + color: 'text-amber-600 dark:text-amber-400', }; } else { return { - icon: , - title: t('excellent.title'), - message: t('excellent.message'), - color: 'text-green-600 dark:text-green-400', + icon: , + title: t('mastered.title'), + message: t('mastered.message'), + color: 'text-emerald-600 dark:text-emerald-400', }; } }; @@ -113,9 +113,9 @@ export function QuizResult({
= 50 && percentage < 80 && 'bg-orange-500', - percentage >= 80 && 'bg-green-500' + percentage < 70 && 'bg-red-500', + percentage >= 70 && percentage < 100 && 'bg-amber-500', + percentage >= 100 && 'bg-emerald-500' )} style={{ width: `${percentage}%` }} /> diff --git a/frontend/hooks/useQuizSession.ts b/frontend/hooks/useQuizSession.ts index aa417699..12c0b084 100644 --- a/frontend/hooks/useQuizSession.ts +++ b/frontend/hooks/useQuizSession.ts @@ -25,6 +25,9 @@ type QuizState = { questionStatus: 'answering' | 'revealed'; selectedAnswerId: string | null; startedAt: Date | null; + pointsAwarded: number | null; + attemptId: string | null; + isIncomplete: boolean; }; type UseQuizSessionParams = { @@ -62,7 +65,7 @@ export function useQuizSession({ }, [quizId, reloadKey, onRestore]); useEffect(() => { - if (state.status !== 'in_progress') return; + if (state.status === 'rules') return; const sessionData: QuizSessionData = { status: state.status, @@ -77,6 +80,9 @@ export function useQuizSession({ selectedAnswerId: state.selectedAnswerId, startedAt: state.startedAt?.getTime() ?? null, savedAt: Date.now(), + pointsAwarded: state.pointsAwarded, + attemptId: state.attemptId, + isIncomplete: state.isIncomplete, }; saveQuizSession(quizId, sessionData); diff --git a/frontend/lib/quiz/quiz-session.ts b/frontend/lib/quiz/quiz-session.ts index a615e7ee..9f0a3070 100644 --- a/frontend/lib/quiz/quiz-session.ts +++ b/frontend/lib/quiz/quiz-session.ts @@ -14,6 +14,9 @@ export interface QuizSessionData { selectedAnswerId: string | null; startedAt: number | null; savedAt: number; + pointsAwarded?: number | null; + attemptId?: string | null; + isIncomplete?: boolean; } export function saveQuizSession(quizId: string, state: QuizSessionData): void { diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1c761d31..4e3785fe 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -212,17 +212,17 @@ "title": "All Answers Incorrect", "message": "We recommend thoroughly studying the material before your next attempt" }, - "needPractice": { + "study": { "title": "Need More Practice", - "message": "We recommend spending more time on theory and practice" + "message": "Focus on the theory and keep practicing — you'll get there" }, - "goodJob": { - "title": "Good Job!", - "message": "Review the difficult topics and try again" + "review": { + "title": "Almost There!", + "message": "You know the material well. Review the tricky parts and aim for a perfect score" }, - "excellent": { - "title": "Excellent Work!", - "message": "You have mastered the material well" + "mastered": { + "title": "Perfect Score!", + "message": "You have fully mastered this topic" }, "incomplete": { "title": "Time's up", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index 624b76be..eaf75d4d 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -212,17 +212,17 @@ "title": "Wszystkie Odpowiedzi Niepoprawne", "message": "Zalecamy dokładne przestudiowanie materiału przed kolejną próbą" }, - "needPractice": { + "study": { "title": "Potrzebujesz Więcej Praktyki", - "message": "Zalecamy poświęcenie więcej czasu na teorię i praktykę" + "message": "Skup się na teorii i ćwicz dalej — dasz radę" }, - "goodJob": { - "title": "Dobra Robota!", - "message": "Przejrzyj trudne tematy i spróbuj ponownie" + "review": { + "title": "Prawie Idealnie!", + "message": "Dobrze znasz materiał. Powtórz trudne tematy i celuj w perfekcyjny wynik" }, - "excellent": { - "title": "Doskonała Praca!", - "message": "Świetnie opanowałeś materiał" + "mastered": { + "title": "Perfekcyjny Wynik!", + "message": "W pełni opanowałeś ten temat" }, "incomplete": { "title": "Czas minął", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 1a379caf..3eb2b460 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -212,17 +212,17 @@ "title": "Всі відповіді неправильні", "message": "Рекомендуємо ретельно вивчити матеріал перед наступною спробою" }, - "needPractice": { + "study": { "title": "Потрібно більше практики", - "message": "Рекомендуємо приділити більше часу теорії та практиці" + "message": "Зосередьтеся на теорії та продовжуйте практикуватися — у вас все вийде" }, - "goodJob": { - "title": "Непоганий результат!", - "message": "Повторіть складні теми та спробуйте ще раз" + "review": { + "title": "Майже ідеально!", + "message": "Ви добре знаєте матеріал. Повторіть складні моменти та прагніть до ідеального результату" }, - "excellent": { - "title": "Чудова робота!", - "message": "Ви добре засвоїли матеріал" + "mastered": { + "title": "Ідеальний результат!", + "message": "Ви повністю засвоїли цю тему" }, "incomplete": { "title": "Час вийшов", From f56413e7746029f04b4f8e6133866d92293920bd Mon Sep 17 00:00:00 2001 From: Viktor Svertoka Date: Sat, 14 Feb 2026 01:58:20 +0200 Subject: [PATCH 10/10] chore(release): v1.0.0 --- CHANGELOG.md | 69 +++++++++++++++ frontend/package-lock.json | 67 +++++++++------ frontend/package.json | 2 +- studio/package-lock.json | 171 +++++++++++++++++++++---------------- studio/package.json | 2 +- 5 files changed, 211 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71b5e887..bc2580b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -510,3 +510,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reduced database load for quiz pages via Redis caching - Improved frontend loading experience during navigation + +## [1.0.0] - 2026-02-14 + +### Added + +- Complete production-ready DevLovers platform: + - Interactive Home with redesigned Hero and Features sections + - Full Quiz learning cycle: play, results, review, and dashboard history + - Quiz integrity system with violations counter and status badges + - Breadcrumb navigation for quiz review flow +- Dashboard improvements: + - Quiz Results history with scores, percentages, and mastery status + - User avatars with automatic fallback generation +- Q&A improvements: + - Next.js category enhancements and optimized loader behavior + - Configurable pagination size (10–100 items) with URL persistence +- Leaderboard enhancements: + - User avatars in podium and table rows + - Improved layout consistency across devices +- Platform transparency: + - Public `/humans.txt` with team and project information +- Observability & monitoring: + - Full Sentry integration for production error and performance tracking +- CI & supply-chain security: + - Safe-chain dependency protection in GitHub Actions +- Shop & payments: + - Monobank acquiring flow (UAH-only, feature-gated) + - Webhook verification, idempotent processing, and admin operations + - Internal Monobank janitor jobs for payment state consistency + +### Changed + +- Home page redesigned with improved UX, animations, and full localization +- Header and navigation refined with contextual behavior and loading states +- Quiz UX improvements: + - Unified result thresholds (Study / Review / Mastered) + - Locale switch now preserves result screen + - Auto-scroll to Next action after answering +- Mobile experience significantly improved across: + - Dashboard + - Leaderboard + - AI Word Helper +- SEO & social sharing: + - Localized Open Graph and Twitter metadata + - MetadataBase configuration for correct preview URLs +- Product experience: + - Multi-language product descriptions + - Improved rendering and formatting + +### Fixed + +- Points mismatch between dashboard and leaderboard +- Locale redirect issues for dashboard and quiz result pages +- Review cache isolation to prevent cross-user data leakage +- Quiz result locale switch losing state +- Multiple Tailwind v4 canonical class warnings +- Various mobile layout and interaction issues + +### Security + +- CI dependency scanning with Safe-chain +- OAuth and environment configuration stabilization +- Internal operational endpoints protected with secret-based access + +### Infrastructure + +- Redis caching for quiz questions and review data +- Environment configuration cleanup and standardization +- Improved build stability and dependency management diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 264d8b0f..8a4ca4ad 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.5.7", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.5.7", + "version": "1.0.0", "dependencies": { "@neondatabase/serverless": "^1.0.2", "@portabletext/react": "^5.0.0", @@ -440,9 +440,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.1.tgz", - "integrity": "sha512-bsDKIP6f4ta2DO9t+rAbSSwv4EMESXy5ZIvzQl1afmD6Z1XHkVu9ijcG9QR/qSgQS1dVa+RaQ/MfQ7FIB/Dn1Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.0.tgz", + "integrity": "sha512-JWouqB5za07FUA2iXZWq4gPXNGWXjRwlfwEXNr7cSsGr7OKgzhDVwkJjlsrbqSyFmDGSi1Rt7zs8ln87jX9yRg==", "dev": true, "funding": [ { @@ -2137,9 +2137,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.0.tgz", - "integrity": "sha512-uOXpVX0ZjO7heSVjhheW2XEPrhQAWr2BScDPoZ9UDycl5iuHG+Usyc3AIfG6kZeC1GyLpMInpQ6X5+9n69yOFw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", + "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -2149,9 +2149,9 @@ } }, "node_modules/@opentelemetry/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", - "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", + "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2328,6 +2328,21 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", + "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, "node_modules/@opentelemetry/instrumentation-ioredis": { "version": "0.59.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.59.0.tgz", @@ -2568,12 +2583,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", - "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz", + "integrity": "sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", + "@opentelemetry/core": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -2584,13 +2599,13 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", - "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", + "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.0", - "@opentelemetry/resources": "2.5.0", + "@opentelemetry/core": "2.5.1", + "@opentelemetry/resources": "2.5.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -7239,9 +7254,9 @@ "license": "MIT" }, "node_modules/dotenv": { - "version": "17.2.4", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", - "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -14563,9 +14578,9 @@ } }, "node_modules/webpack": { - "version": "5.105.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.1.tgz", - "integrity": "sha512-Gdj3X74CLJJ8zy4URmK42W7wTZUJrqL+z8nyGEr4dTN0kb3nVs+ZvjbTOqRYPD7qX4tUmwyHL9Q9K6T1seW6Yw==", + "version": "5.105.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", + "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "license": "MIT", "peer": true, "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index f6fa222a..e36c1114 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.5.7", + "version": "1.0.0", "private": true, "scripts": { "dev": "next dev", diff --git a/studio/package-lock.json b/studio/package-lock.json index 24969a9f..8fbf5745 100644 --- a/studio/package-lock.json +++ b/studio/package-lock.json @@ -1,12 +1,12 @@ { "name": "devlovers", - "version": "0.5.7", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devlovers", - "version": "0.5.7", + "version": "1.0.0", "license": "UNLICENSED", "dependencies": { "@sanity/orderable-document-list": "^1.4.2", @@ -2008,9 +2008,9 @@ } }, "node_modules/@codemirror/lint": { - "version": "6.9.3", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.3.tgz", - "integrity": "sha512-y3YkYhdnhjDBAe0VIA0c4wVoFOvnp8CnAvfLqi0TqotIv92wIlAAP7HELOpLBsKwjAX6W92rSflA6an/2zBvXw==", + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.4.tgz", + "integrity": "sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", @@ -2051,9 +2051,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.39.13", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.13.tgz", - "integrity": "sha512-QBO8ZsgJLCbI28KdY0/oDy5NQLqOQVZCozBknxc2/7L98V+TVYFHnfaCsnGh1U+alpd2LOkStVwYY7nW2R1xbw==", + "version": "6.39.14", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.14.tgz", + "integrity": "sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -3267,27 +3267,6 @@ } } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3453,34 +3432,34 @@ "license": "MIT" }, "node_modules/@mux/mux-data-google-ima": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@mux/mux-data-google-ima/-/mux-data-google-ima-0.2.8.tgz", - "integrity": "sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@mux/mux-data-google-ima/-/mux-data-google-ima-0.3.4.tgz", + "integrity": "sha512-j8IOD5kw1qIOkbpipEQRGQ7vXB6+CArrhIAvtvj8YFqy0PHi7JcHk4WR3ZBVy5+5yaRCH+nzHkmJmGsg8g6O5g==", "license": "MIT", "dependencies": { - "mux-embed": "5.9.0" + "mux-embed": "5.16.1" } }, "node_modules/@mux/mux-player": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/@mux/mux-player/-/mux-player-3.10.2.tgz", - "integrity": "sha512-iLFOCUgIqXum7bErn2cPb+f/DHCXC8i2rbFl6+SIf6r/oHRp0djkoLaeaX30hsA/SUdCLQze7oL4IM2QHRmONg==", + "version": "3.11.4", + "resolved": "https://registry.npmjs.org/@mux/mux-player/-/mux-player-3.11.4.tgz", + "integrity": "sha512-E7A6GIx3ECXjDIFuhkmuU0PRnnkTluKoi+jCuVtZWDnBLmeM9WxNTVIDyAJQX2Ro/BkTuZChMspObj9OgngLTg==", "license": "MIT", "dependencies": { - "@mux/mux-video": "0.29.2", - "@mux/playback-core": "0.32.2", + "@mux/mux-video": "0.30.2", + "@mux/playback-core": "0.33.1", "media-chrome": "~4.17.2", "player.style": "^0.3.0" } }, "node_modules/@mux/mux-player-react": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/@mux/mux-player-react/-/mux-player-react-3.10.2.tgz", - "integrity": "sha512-Grg9us93llxESHAPDxWZJKZiknTi0AtpqPLuyiqiLc7DYcJkgnUHG8pSHQ8i7A/gKC5tOhCyCoKm3p2Ei8vIrQ==", + "version": "3.11.4", + "resolved": "https://registry.npmjs.org/@mux/mux-player-react/-/mux-player-react-3.11.4.tgz", + "integrity": "sha512-bW0k7eC1d2hmttdWrmRNjiVa77lQ30V8Lz199GnyhwQhBbMa3unEiO5NbcYBuYNZTuXqxgVRiehMktuzbvY7VA==", "license": "MIT", "dependencies": { - "@mux/mux-player": "3.10.2", - "@mux/playback-core": "0.32.2", + "@mux/mux-player": "3.11.4", + "@mux/playback-core": "0.33.1", "prop-types": "^15.8.1" }, "peerDependencies": { @@ -3523,26 +3502,26 @@ } }, "node_modules/@mux/mux-video": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/@mux/mux-video/-/mux-video-0.29.2.tgz", - "integrity": "sha512-qKnbMoPI50oJnH89d8UJjWPx6yrtyAmm6wysr1biZI561f257b7P8VE8fnXb9Ak1Gs3rBiNLiw/vCXwBdCkl+A==", + "version": "0.30.2", + "resolved": "https://registry.npmjs.org/@mux/mux-video/-/mux-video-0.30.2.tgz", + "integrity": "sha512-JwNH7FwLSmPGdU355LvCL1MWyrMFl2dy6mSzyqiHp+14eBaoRu1Os4Dg2khw5dECJSIm7Z4ZMeDnHQc/rj7/YQ==", "license": "MIT", "dependencies": { - "@mux/mux-data-google-ima": "0.2.8", - "@mux/playback-core": "0.32.2", + "@mux/mux-data-google-ima": "^0.3.4", + "@mux/playback-core": "0.33.1", "castable-video": "~1.1.11", "custom-media-element": "~1.4.5", "media-tracks": "~0.3.4" } }, "node_modules/@mux/playback-core": { - "version": "0.32.2", - "resolved": "https://registry.npmjs.org/@mux/playback-core/-/playback-core-0.32.2.tgz", - "integrity": "sha512-cmaZN0hRIrEFcSVsj+quBDOeztg5oxug02WwuAiweWkMt1vSBM8nlzuF03coRnD/sKhgnQawdJOrng42R8I0Cg==", + "version": "0.33.1", + "resolved": "https://registry.npmjs.org/@mux/playback-core/-/playback-core-0.33.1.tgz", + "integrity": "sha512-lyfvZQuc5UebfJBqjZ+TiFHD3B08otJrNG+pYU2klv6ywYjpvnSCbWBif7YtaC4gIPUJjkErspXWND3w34ctIg==", "license": "MIT", "dependencies": { "hls.js": "~1.6.15", - "mux-embed": "^5.8.3" + "mux-embed": "^5.16.1" } }, "node_modules/@noble/ed25519": { @@ -4844,6 +4823,15 @@ "node": ">=18" } }, + "node_modules/@sanity/import/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@sanity/import/node_modules/@sanity/mutator": { "version": "3.99.0", "resolved": "https://registry.npmjs.org/@sanity/mutator/-/mutator-3.99.0.tgz", @@ -4870,13 +4858,37 @@ "@types/react": "18 || 19" } }, + "node_modules/@sanity/import/node_modules/balanced-match": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", + "integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", + "license": "MIT", + "dependencies": { + "jackspeak": "^4.2.3" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@sanity/import/node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@sanity/import/node_modules/glob": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.2.tgz", - "integrity": "sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==", + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.3.tgz", + "integrity": "sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.2", + "minimatch": "^10.2.0", "minipass": "^7.1.2", "path-scurry": "^2.0.0" }, @@ -4887,6 +4899,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@sanity/import/node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@sanity/import/node_modules/lru-cache": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", @@ -4897,12 +4924,12 @@ } }, "node_modules/@sanity/import/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", + "integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": "20 || >=22" @@ -10571,9 +10598,9 @@ } }, "node_modules/groq-js": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/groq-js/-/groq-js-1.26.0.tgz", - "integrity": "sha512-1WxWfmeownBbB2UhvFGyLT3yl/NFGF2qUoev+650Or2qDnoyXjvL83lwspUFuG4piWdDRh2iETljXKoDxacH+w==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/groq-js/-/groq-js-1.27.0.tgz", + "integrity": "sha512-EMRqw8e82X3CITXXDXpFBH8iC0kMgwsJ6YQzOEueyksWuf7MTDEuxc95o/0npMHo8/BvQHOFPXu2XehdPmBQ+Q==", "license": "MIT", "dependencies": { "debug": "^4.3.4" @@ -12671,9 +12698,9 @@ } }, "node_modules/markdown-it": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", - "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", + "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -13032,9 +13059,9 @@ } }, "node_modules/mux-embed": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.9.0.tgz", - "integrity": "sha512-wmunL3uoPhma/tWy8PrDPZkvJpXvSFBwbD3KkC4PG8Ztjfb1X3hRJwGUAQyRz7z99b/ovLm2UTTitrkvStjH4w==", + "version": "5.16.1", + "resolved": "https://registry.npmjs.org/mux-embed/-/mux-embed-5.16.1.tgz", + "integrity": "sha512-XWeOG/nzrfRjdyDajW9VNiP0FuKZhEIHMh8Ybpo8uudQByRSOKH8qOaL+MEGdm7KAHzJunwoyYNodD059Spw/A==", "license": "MIT" }, "node_modules/nano-pubsub": { @@ -17989,9 +18016,9 @@ } }, "node_modules/xstate": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.26.0.tgz", - "integrity": "sha512-Fvi9VBoqHgsGYLU2NTag8xDTWtKqUC0+ue7EAhBNBb06wf620QEy05upBaEI1VLMzIn63zugLV8nHb69ZUWYAA==", + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/xstate/-/xstate-5.28.0.tgz", + "integrity": "sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==", "license": "MIT", "funding": { "type": "opencollective", diff --git a/studio/package.json b/studio/package.json index 187c5df0..04dba50e 100644 --- a/studio/package.json +++ b/studio/package.json @@ -1,7 +1,7 @@ { "name": "devlovers", "private": true, - "version": "0.5.7", + "version": "1.0.0", "main": "package.json", "license": "UNLICENSED", "scripts": {