diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx new file mode 100644 index 0000000..3848222 --- /dev/null +++ b/frontend/src/components/Welcome.tsx @@ -0,0 +1,408 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' +import type { Icon as TablerIcon } from '@tabler/icons-react' +import { IconStarFilled, IconHeartFilled, + IconBoltFilled, IconZoomQuestionFilled, IconPlayerTrackNextFilled, + IconChevronDownFilled, IconFileUploadFilled, + IconCheckFilled, IconBookFilled, IconFeatherFilled, IconDiamondFilled, + IconCompassFilled, IconFlameFilled, + IconAutomaticGearboxFilled, IconGlobeFilled, + IconBulbFilled, IconHomeFilled, IconTrophyFilled } from '@tabler/icons-react' +import { motion, AnimatePresence } from 'motion/react' +import { useStore } from '../store' +import type { GameMode } from '../types' +import * as api from '../api' +import type { CommunityPuzzleItem, ThemeInfo } from '../api' +import { PRESETS, speedrunDescription } from '../presets' +import ReportButton from './ui/ReportButton' + +interface RichOption { + value: string + label: string + desc?: string + icon?: TablerIcon +} + +function RichSelect({ value, options, onChange, label }: { + value: string; options: RichOption[]; onChange: (v: string) => void; label: string +}) { + const [open, setOpen] = useState(false) + const triggerRef = useRef(null) + const dropRef = useRef(null) + const [pos, setPos] = useState({ top: 0, left: 0, width: 0 }) + const selected = options.find(o => o.value === value) + + const updatePos = useCallback(() => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + const container = triggerRef.current.closest('.welcome') + const containerRect = container?.getBoundingClientRect() + const maxRight = containerRect ? containerRect.right : window.innerWidth - 16 + const maxLeft = containerRect ? containerRect.left : 16 + let left = rect.left + const width = Math.max(rect.width, 280) + if (left + width > maxRight) left = maxRight - width + if (left < maxLeft) left = maxLeft + setPos({ top: rect.top, left, width }) + }, []) + + useEffect(() => { + if (!open) return + updatePos() + const onDown = (e: MouseEvent) => { + const target = e.target + if (target instanceof Node && dropRef.current && !dropRef.current.contains(target) && + triggerRef.current && !triggerRef.current.contains(target)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + window.addEventListener('scroll', updatePos, true) + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('scroll', updatePos, true) + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open, updatePos]) + + return ( +
+ + + {createPortal( + + {open && ( + + {options.map(o => ( + + ))} + + )} + , + document.body, + )} +
+ ) +} + +const DIFFICULTIES = PRESETS.map(p => ({ preset: p.id, name: p.name, desc: p.desc, tag: p.tag, Icon: p.icon })) + +export default function Welcome() { + const generatePuzzle = useStore((s) => s.generatePuzzle) + const loadDaily = useStore((s) => s.loadDaily) + const loading = useStore((s) => s.loading) + const streak = useStore((s) => s.streak) + const preferredPreset = useStore((s) => s.preferredPreset) + const setGameMode = useStore((s) => s.setGameMode) + const gameMode = useStore((s) => s.gameMode) + const loadCommunityPuzzle = useStore((s) => s.loadCommunityPuzzle) + const importPuzzle = useStore((s) => s.importPuzzle) + const { t } = useTranslation() + const fileRef = useRef(null) + const [communityPuzzles, setCommunityPuzzles] = useState([]) + const [federatedPuzzles, setFederatedPuzzles] = useState([]) + const [ratings, setRatings] = useState>({}) + const [themes, setThemes] = useState<{ id: string; name: string; word_count: number }[]>([]) + const [selectedTheme, setSelectedTheme] = useState('') + const [packs, setPacks] = useState([]) + const [selectedPack, setSelectedPack] = useState('') + const [cryptic, setCryptic] = useState(false) + const [clueDiff, setClueDiff] = useState(undefined) + const [showCustomPanel, setShowCustomPanel] = useState(false) + const [showCommunity, setShowCommunity] = useState(false) + const today = new Date().toISOString().split('T')[0] + + const [completedChallenges, setCompletedChallenges] = useState>({}) + const [wotd, setWotd] = useState<{ word: string; definition: string; clue: string; length: number } | null>(null) + const [countdown, setCountdown] = useState('') + useEffect(() => { + const update = () => { + const now = new Date() + const midnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)) + const diff = midnight.getTime() - now.getTime() + const h = Math.floor(diff / 3600000) + const m = Math.floor((diff % 3600000) / 60000) + const s = Math.floor((diff % 60000) / 1000) + setCountdown(`${h}h ${m}m ${s}s`) + } + update() + const id = setInterval(update, 1000) + return () => clearInterval(id) + }, []) + + useEffect(() => { + api.fetchDailyRatings(today).then(setRatings) + api.fetchThemes().then(setThemes) + api.fetchPacks().then(data => setPacks(data.packs || [])) + api.fetchCommunityPuzzles().then(setCommunityPuzzles) + api.fetchFederatedPuzzles().then(data => setFederatedPuzzles(data.puzzles || [])).catch(() => {}) + api.fetchWordOfTheDay().then(data => { if (data.word) setWotd(data) }) + api.fetchTodayChallenges().then(setCompletedChallenges).catch(() => {}) + }, []) + + + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) importPuzzle(file) + e.target.value = '' + } + + const allCommunity = [...communityPuzzles, ...federatedPuzzles.map(p => ({ ...p, federated: true }))] + + return ( +
+ {/* masthead */} +
+
+

{t('app.name')}

+

+ {new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })} +

+

+ {streak && streak.current > 0 + ? t('app.streakKeepGoing', { count: streak.current }) + : t('app.tagline') + } +

+
+
+ + {/* the daily five - cards */} +
+
+ +

{t('welcome.todaysPuzzles')}

+

+ {t('welcome.newPuzzlesIn', { countdown: '' })}{countdown} +

+ +
+
+ {DIFFICULTIES.map((d) => { + const r = ratings[d.preset] + return ( + + ) + })} +
+
+ + {/* word of the day */} + {wotd && ( + + )} + + {/* accordion sections for secondary content */} +
+ + {/* custom puzzle */} +
+ + + {showCustomPanel && ( + +
+
+ setCryptic(v === 'cryptic')} + options={[ + { value: 'standard', label: t('welcome.standard'), icon: IconBookFilled, + desc: 'Traditional crossword clues. Each clue is a definition, a synonym, or a fill-in-the-blank that points directly to the answer. What you would find in any newspaper.' }, + { value: 'cryptic', label: t('welcome.cryptic'), icon: IconBulbFilled, + desc: 'British-style wordplay. Every clue contains both a straight definition and a second route to the answer through anagrams, hidden words, reversals, containers, or double definitions. Much harder, but the moment a cryptic clue clicks is unlike anything else in puzzling.' }, + ]} /> + setClueDiff(v === 'auto' ? undefined : Number(v))} + options={[ + { value: 'auto', label: t('welcome.auto'), icon: IconAutomaticGearboxFilled, + desc: 'Difficulty scales with puzzle size. Smaller grids get friendlier words and clearer clues. Larger grids allow more obscure vocabulary and trickier misdirection.' }, + { value: '0', label: t('welcome.veryEasy'), icon: IconFeatherFilled, + desc: 'Everyday words you use in conversation. Simple, direct clues with no tricks. Ideal for beginners, kids, or a quick relaxing solve.' }, + { value: '1', label: t('welcome.easy'), icon: IconDiamondFilled, + desc: 'Mostly familiar vocabulary with a few less common words at the crossings. Clues are fair and approachable.' }, + { value: '2', label: t('welcome.medium'), icon: IconCompassFilled, + desc: 'A real challenge. Expect multi-meaning clues, less obvious synonyms, and a handful of words that will make you think twice.' }, + { value: '3', label: t('welcome.hard'), icon: IconFlameFilled, + desc: 'No mercy. Obscure vocabulary, devious misdirection, and crossings that demand knowledge across many subjects. Saturday NYT energy.' }, + ]} /> + { + // SAFETY: the options below are the complete GameMode set, so v is always a GameMode value + setGameMode(v as GameMode) + }} + options={[ + { value: 'standard', label: t('welcome.standard'), icon: IconHomeFilled, + desc: 'The classic experience. Solve at whatever pace feels right. Hints are available when you are stuck, you can pause and come back later, and your time is tracked for personal bests without any pressure.' }, + { value: 'speedrun', label: t('welcome.speedRun'), icon: IconBoltFilled, + desc: 'You against the clock. Every preset has a target time - Glyph in 45 seconds, Grimoire in 45 minutes. Hints are completely disabled. Pausing is not allowed. The timer never stops. Finish before it runs out or the puzzle is lost.' }, + { value: 'blind', label: t('welcome.blind'), icon: IconZoomQuestionFilled, + desc: 'All clues start hidden. You can only read the clue for the word you are currently working on. Completed words reveal their clues permanently, but everything else stays dark. You are forced to build outward from crossings, letter by letter, never seeing the full picture until the end.' }, + { value: 'marathon', label: t('welcome.marathon'), icon: IconPlayerTrackNextFilled, + desc: 'An endless gauntlet of quick 5x5 puzzles. Each one you solve loads the next immediately. You have exactly 2 minutes per puzzle - if time runs out on any single one, the marathon is over. How far can you go?' }, + ]} /> + {themes.length > 0 && ( + setSelectedTheme(v)} + options={[ + { value: '', label: t('welcome.none'), icon: IconGlobeFilled, + desc: 'No theme restriction. Answers drawn freely from the full dictionary based on quality scores.' }, + ...themes.map(th => ({ value: th.id, label: th.name, desc: `${th.word_count.toLocaleString()} themed words available` })), + ]} /> + )} + {packs.length > 0 && ( + setSelectedPack(v)} + options={[ + { value: '', label: t('welcome.none'), icon: IconGlobeFilled, + desc: 'The full default dictionary, curated with quality scores to avoid junk words.' }, + ...packs.map(p => ({ value: p.id, label: p.name, desc: `${p.word_count.toLocaleString()} curated words` })), + ]} /> + )} +
+ + + {cryptic && ( + +

{t('welcome.crypticExplainer')}

+
+ )} +
+ + {gameMode !== 'standard' && ( + + + {gameMode === 'speedrun' && {speedrunDescription()}} + {gameMode === 'blind' && {t('welcome.blindExplainer')}} + {gameMode === 'marathon' && {t('welcome.marathonExplainer')}} + + + )} + + +
+ {gameMode === 'marathon' ? ( + + ) : ( + DIFFICULTIES.map((d) => ( + + )) + )} +
+ + + +
+
+ )} +
+
+ + {/* community puzzles */} + {allCommunity.length > 0 && ( +
+ + + {showCommunity && ( + +
+ {allCommunity.slice(0, 8).map(p => ( + + ))} +
+
+ )} +
+
+ )} + +
+
+ ) +}