diff --git a/frontend/src/components/EditorPanel.tsx b/frontend/src/components/EditorPanel.tsx new file mode 100644 index 0000000..1765934 --- /dev/null +++ b/frontend/src/components/EditorPanel.tsx @@ -0,0 +1,857 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { useTranslation } from 'react-i18next' +import { IconX, IconSparklesFilled, IconWorldFilled, IconTrashFilled, + IconArrowBack, IconPlus, IconPencilFilled, IconPointerFilled, IconAlertTriangleFilled, + IconPlayerPlayFilled, IconListFilled, IconDownloadFilled, IconFileUploadFilled, + IconChartAreaFilled } from '@tabler/icons-react' +import * as api from '../api' +import type { ConstructorAnalytics } from '../api' +import Select from './ui/Select' +import NumberInput from './ui/NumberInput' +import Tooltip from './ui/Tooltip' + +type CellType = 'black' | 'white' +type Symmetry = 'rotational180' | 'mirror' | 'none' +type EditorMode = 'design' | 'fill' | 'clues' | 'test' + +const SIZE_BUTTONS: [number, string][] = [[5, '5x5'], [9, '9x9'], [11, '11x11'], [15, '15x15'], [21, '21x21']] + +type EmptySlotResult = { cellNumbers: Record; twoLetterWarnings: string[] } + +const EMPTY_SLOT_RESULT: EmptySlotResult = { + cellNumbers: {}, + twoLetterWarnings: [], +} +type Dir = 'across' | 'down' + +interface EditorClue { + number: number + direction: string + text: string + answer: string + startRow: number + startCol: number +} + +interface SavedPuzzle { + id: string; title: string; width: number; height: number; status: string; updated_at: string +} + +// compute word slots from grid cells +function computeSlots(cells: CellType[][], letters: (string | null)[][], width: number, height: number) { + // first compute numbers (3+ letter runs only) + const cellNumbers: Record = {} + const twoLetterWarnings: string[] = [] + let num = 1 + + const runLen = (r: number, c: number, dr: number, dc: number) => { + let len = 0 + while (r >= 0 && r < height && c >= 0 && c < width && cells[r]?.[c] === 'white') { + len++; r += dr; c += dc + } + return len + } + + for (let r = 0; r < height; r++) { + for (let c = 0; c < width; c++) { + if (cells[r]?.[c] !== 'white') continue + const acrossStart = c === 0 || cells[r][c - 1] === 'black' + const downStart = r === 0 || cells[r - 1]?.[c] === 'black' + const acrossLen = acrossStart ? runLen(r, c, 0, 1) : 0 + const downLen = downStart ? runLen(r, c, 1, 0) : 0 + if (acrossLen === 2) twoLetterWarnings.push(`row ${r + 1}, col ${c + 1} across`) + if (downLen === 2) twoLetterWarnings.push(`row ${r + 1}, col ${c + 1} down`) + if (acrossLen >= 3 || downLen >= 3) cellNumbers[`${r},${c}`] = num++ + } + } + + // extract words + const slots: EditorClue[] = [] + // across + for (let r = 0; r < height; r++) { + let word = '', startC = 0 + for (let c = 0; c <= width; c++) { + if (c < width && cells[r][c] === 'white') { + if (word.length === 0) startC = c + word += letters[r]?.[c] || '?' + } else { + if (word.length >= 3) { + const n = cellNumbers[`${r},${startC}`] + if (n) slots.push({ number: n, direction: 'across', answer: word.replace(/\?/g, ''), text: '', startRow: r, startCol: startC }) + } + word = '' + } + } + } + // down + for (let c = 0; c < width; c++) { + let word = '', startR = 0 + for (let r = 0; r <= height; r++) { + if (r < height && cells[r][c] === 'white') { + if (word.length === 0) startR = r + word += letters[r]?.[c] || '?' + } else { + if (word.length >= 3) { + const n = cellNumbers[`${startR},${c}`] + if (n) slots.push({ number: n, direction: 'down', answer: word.replace(/\?/g, ''), text: '', startRow: startR, startCol: c }) + } + word = '' + } + } + } + + return { cellNumbers, slots, twoLetterWarnings } +} + +// get all cells belonging to a word +function getWordCells(cells: CellType[][], r: number, c: number, dir: Dir, width: number, height: number): [number, number][] { + if (!cells[r]) return [] + if (dir === 'across') { + while (c > 0 && cells[r]?.[c - 1] === 'white') c-- + const result: [number, number][] = [] + while (c < width && cells[r]?.[c] === 'white') { result.push([r, c]); c++ } + return result + } else { + while (r > 0 && cells[r - 1]?.[c] === 'white') r-- + const result: [number, number][] = [] + while (r < height && cells[r]?.[c] === 'white') { result.push([r, c]); r++ } + return result + } +} + +export default function EditorPanel({ onClose }: { onClose: () => void }) { + const { t } = useTranslation() + const [view, setView] = useState<'list' | 'edit'>('list') + const [puzzleId, setPuzzleId] = useState(null) + const [title, setTitle] = useState('Untitled') + const [width, setWidth] = useState(15) + const [height, setHeight] = useState(15) + const [cells, setCells] = useState([]) + const [letters, setLetters] = useState<(string | null)[][]>([]) + const [clues, setClues] = useState([]) + const [symmetry, setSymmetry] = useState('rotational180') + const [mode, setMode] = useState('design') + const [selectedCell, setSelectedCell] = useState<{ row: number; col: number } | null>(null) + const [editorDirection, setEditorDirection] = useState('across') + const [activeClueKey, setActiveClueKey] = useState(null) + const [highlightedCells, setHighlightedCells] = useState>(new Set()) + const [filling, setFilling] = useState(false) + const [saving, setSaving] = useState(false) + const [savedPuzzles, setSavedPuzzles] = useState([]) + const [editingClueIdx, setEditingClueIdx] = useState(null) + const [suggestions, setSuggestions] = useState>({}) + const [warnings, setWarnings] = useState([]) + const [undoStack, setUndoStack] = useState<{ cells: CellType[][]; letters: (string | null)[][]; clues: EditorClue[] }[]>([]) + const [lastSaved, setLastSaved] = useState('') + const autosaveTimer = useRef | null>(null) + const [showPublishConfirm, setShowPublishConfirm] = useState(false) + const [skipPublishConfirm, setSkipPublishConfirm] = useState(false) + const [dontShowAgain, setDontShowAgain] = useState(false) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const [customW, setCustomW] = useState(13) + const [customH, setCustomH] = useState(13) + const [isThemed, setIsThemed] = useState(false) + const [themeName, setThemeName] = useState('') + const [analyticsId, setAnalyticsId] = useState(null) + const [analyticsData, setAnalyticsData] = useState(null) + const [analyticsLoading, setAnalyticsLoading] = useState(false) + + // load publish confirm preference + useEffect(() => { + api.fetchSettings().then(s => { if (s.skipPublishConfirm) setSkipPublishConfirm(true) }) + }, []) + const [testEntries, setTestEntries] = useState([]) + const [testSelected, setTestSelected] = useState<{ row: number; col: number } | null>(null) + + useEffect(() => { api.editorList().then(setSavedPuzzles) }, []) + + // push current state to undo stack + const pushUndo = useCallback(() => { + if (cells.length === 0) return + setUndoStack(prev => [...prev.slice(-19), { cells: cells.map(r => [...r]), letters: letters.map(r => [...r]), clues: [...clues] }]) + }, [cells, letters, clues]) + + const undo = () => { + if (undoStack.length === 0) return + const prev = undoStack[undoStack.length - 1] + setUndoStack(s => s.slice(0, -1)) + setCells(prev.cells) + setLetters(prev.letters) + setClues(prev.clues) + } + + // Ctrl+Z for undo + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'z' && view === 'edit') { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + e.preventDefault() + undo() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [undoStack, view]) + + const clearGrid = () => { + pushUndo() + setCells(Array.from({ length: height }, () => Array(width).fill('white'))) + setLetters(Array.from({ length: height }, () => Array(width).fill(null))) + setClues([]); setSuggestions({}) + } + + const clearLetters = () => { + pushUndo() + setLetters(Array.from({ length: height }, () => Array(width).fill(null))) + setClues([]); setSuggestions({}) + } + + const unpublish = async () => { + if (!puzzleId) return + try { + await api.editorSave({ id: puzzleId, title, width, height, grid: cells, clues: clues.length > 0 ? clues : undefined, puzzle: { grid: { cells: letters.map(row => row.map(l => l ? { Letter: l } : null)) } }, status: 'draft' }) + api.editorList().then(setSavedPuzzles) + setWarnings([t('editor.unpublished')]) + } catch { /* ignored */ } + } + + const toggleAnalytics = async (id: string) => { + if (analyticsId === id) { setAnalyticsId(null); return } + setAnalyticsId(id) + setAnalyticsLoading(true) + try { + const data = await api.fetchPuzzleAnalytics(id) + setAnalyticsData(data) + } catch (_e) { setAnalyticsData(null) } + setAnalyticsLoading(false) + } + + const confirmDelete = (id: string) => setDeleteConfirmId(id) + const doDelete = async () => { + if (!deleteConfirmId) return + await api.editorDelete(deleteConfirmId) + setSavedPuzzles(prev => prev.filter(p => p.id !== deleteConfirmId)) + if (puzzleId === deleteConfirmId) { setView('list'); setPuzzleId(null) } + setDeleteConfirmId(null) + } + + // autosave: debounce 2s after any change + useEffect(() => { + if (view !== 'edit' || cells.length === 0) return + if (autosaveTimer.current) clearTimeout(autosaveTimer.current) + autosaveTimer.current = setTimeout(async () => { + const id = puzzleId || undefined + try { + const res = await api.editorSave({ id, title, width, height, grid: cells, clues: clues.length > 0 ? clues : undefined, puzzle: { grid: { cells: letters.map(row => row.map(l => l ? { Letter: l } : null)) } }, status: 'draft' }) + if (!puzzleId) setPuzzleId(res.id) + setLastSaved(new Date().toLocaleTimeString()) + } catch { /* ignored */ } + }, 2000) + return () => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current) } + }, [cells, letters, clues, title, view]) + + // scroll to active clue + useEffect(() => { + if (!activeClueKey) return + const el = document.getElementById(`ec-${activeClueKey}`) + if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) + }, [activeClueKey]) + + // keyboard for fill/test + useEffect(() => { + const sel = mode === 'test' ? testSelected : mode === 'fill' ? selectedCell : null + if (!sel) return + const onKey = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + if (e.key.length === 1 && /^[a-zA-Z]$/.test(e.key)) { + e.preventDefault() + if (mode === 'fill') { + pushUndo() + const next = letters.map(row => [...row]) + next[sel.row][sel.col] = e.key.toUpperCase() + setLetters(next) + advanceCellInDirection(sel, editorDirection, setSelectedCell) + } else if (mode === 'test') { + const next = testEntries.map(row => [...row]) + next[sel.row][sel.col] = e.key.toUpperCase() + setTestEntries(next) + advanceCell(sel, setTestSelected) + } + } else if (e.key === 'Backspace') { + e.preventDefault() + if (mode === 'fill') { + pushUndo() + const next = letters.map(row => [...row]) + next[sel.row][sel.col] = null + setLetters(next) + } else if (mode === 'test') { + const next = testEntries.map(row => [...row]) + next[sel.row][sel.col] = '' + setTestEntries(next) + } + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [mode, selectedCell, testSelected, letters, testEntries, cells, width, height]) + + const advanceCellInDirection = (from: { row: number; col: number }, dir: Dir, setter: (v: { row: number; col: number }) => void) => { + let { row, col } = from + if (dir === 'across') { + col++ + while (col < width && cells[row]?.[col] === 'white') { setter({ row, col }); return } + } else { + row++ + while (row < height && cells[row]?.[col] === 'white') { setter({ row, col }); return } + } + } + + const advanceCell = (from: { row: number; col: number }, setter: (v: { row: number; col: number }) => void) => { + let { row, col } = from; col++ + while (row < height) { while (col < width) { if (cells[row]?.[col] === 'white') { setter({ row, col }); return }; col++ }; col = 0; row++ } + } + + const initGrid = useCallback((w: number, h: number) => { + setCells(Array.from({ length: h }, () => Array(w).fill('white'))) + setLetters(Array.from({ length: h }, () => Array(w).fill(null))) + setClues([]); setWarnings([]); setSelectedCell(null); setSuggestions({}); setHighlightedCells(new Set()) + }, []) + + const newPuzzle = (w: number, h: number) => { setWidth(w); setHeight(h); setPuzzleId(null); setTitle('Untitled'); setMode('design'); initGrid(w, h); setView('edit') } + + const loadPuzzle = async (id: string) => { + const data = await api.editorLoad(id) + setPuzzleId(data.id); setTitle(data.title); setWidth(data.width); setHeight(data.height) + const grid = data.grid || [] + const cellBlack = (r: number, c: number) => grid[r]?.[c] === 'black' + setCells(Array.from({ length: data.height }, (_, r) => Array.from({ length: data.width }, (_, c) => cellBlack(r, c) ? 'black' : 'white'))) + // letters come from the stored puzzle payload when present; otherwise + // rebuild them from the clue answers so the fill survives a reload + const rebuilt = Array.from({ length: data.height }, () => Array(data.width).fill(null)) + const p = data.puzzle + if (p?.grid?.cells) { + for (let r = 0; r < data.height; r++) { + for (let c = 0; c < data.width; c++) { + const cell = p.grid.cells[r]?.[c] + if (cell?.Letter) rebuilt[r][c] = cell.Letter + } + } + } else { + for (const clue of data.clues || []) { + const answer = clue.answer || '' + if (!answer) continue + const sr = clue.startRow ?? 0 + const sc = clue.startCol ?? 0 + if (clue.direction === 'across') { + for (let i = 0; i < answer.length && sc + i < data.width; i++) { + if (cellBlack(sr, sc + i)) break + rebuilt[sr][sc + i] = answer[i] + } + } else { + for (let i = 0; i < answer.length && sr + i < data.height; i++) { + if (cellBlack(sr + i, sc)) break + rebuilt[sr + i][sc] = answer[i] + } + } + } + } + setLetters(rebuilt) + setClues((data.clues || []).map(c => ({ number: c.number, direction: c.direction, text: c.text, answer: c.answer, startRow: c.startRow ?? 0, startCol: c.startCol ?? 0 }))); setMode('design'); setView('edit') + } + + const hasOffensivePattern = (grid: CellType[][]): boolean => { + const isBlack = (r: number, c: number) => r >= 0 && c >= 0 && r < height && c < width && grid[r][c] === 'black' + for (let arm = 2; arm <= 5; arm++) { + for (let cr = 0; cr < height; cr++) for (let cc = 0; cc < width; cc++) for (const dir of [1, -1]) { + if (!isBlack(cr, cc)) continue + if (Array.from({ length: arm }, (_, i) => isBlack(cr - i - 1, cc)).every(Boolean) + && isBlack(cr - arm, cc + dir) && Array.from({ length: arm }, (_, i) => isBlack(cr, cc + i + 1)).every(Boolean) + && isBlack(cr + dir, cc + arm) && Array.from({ length: arm }, (_, i) => isBlack(cr + i + 1, cc)).every(Boolean) + && isBlack(cr + arm, cc - dir) && Array.from({ length: arm }, (_, i) => isBlack(cr, cc - i - 1)).every(Boolean) + && isBlack(cr - dir, cc - arm)) return true + } + } + for (let arm = 2; arm <= 4; arm++) { + for (let cr = 0; cr < height - arm * 2; cr++) for (let cc = 0; cc < width - arm - 2; cc++) for (const dir of [1, -1]) { + const bolt = (sc: number) => Array.from({ length: arm }, (_, i) => isBlack(cr + i, sc)).every(Boolean) + && Array.from({ length: arm }, (_, i) => isBlack(cr + arm + i, sc + dir * (i + 1))).every(Boolean) + && Array.from({ length: arm }, (_, i) => isBlack(cr + arm * 2 - 1 + i, sc + dir * arm)).every(Boolean) + if (bolt(cc) && bolt(cc + arm + 1)) return true + } + } + return false + } + + const mirrorCoords = (r: number, c: number): [number, number][] => { + const coords: [number, number][] = [[r, c]] + if (symmetry === 'rotational180') coords.push([height - 1 - r, width - 1 - c]) + else if (symmetry === 'mirror') coords.push([r, width - 1 - c]) + return coords + } + + const selectWordInGrid = (r: number, c: number, dir: Dir) => { + const wordCells = getWordCells(cells, r, c, dir, width, height) + setHighlightedCells(new Set(wordCells.map(([wr, wc]) => `${wr},${wc}`))) + setSelectedCell({ row: r, col: c }) + + // find matching clue + const startCell = wordCells[0] + if (startCell) { + const clue = clues.find(cl => cl.startRow === startCell[0] && cl.startCol === startCell[1] && cl.direction === dir) + if (clue) { + setActiveClueKey(`${clue.number}-${clue.direction}`) + const idx = clues.indexOf(clue) + if (idx >= 0) setEditingClueIdx(idx) + } + } + } + + const onCellClick = (r: number, c: number) => { + if (mode === 'design') { + const next = cells.map(row => [...row]) + const nextLetters = letters.map(row => [...row]) + const newType = cells[r][c] === 'black' ? 'white' : 'black' + for (const [mr, mc] of mirrorCoords(r, c)) { + if (mr >= 0 && mr < height && mc >= 0 && mc < width) { next[mr][mc] = newType; if (newType === 'black') nextLetters[mr][mc] = null } + } + if (hasOffensivePattern(next)) return + pushUndo() + setCells(next); setLetters(nextLetters); setClues([]) + } else if (mode === 'fill') { + if (cells[r][c] !== 'white') return + const sameCell = selectedCell?.row === r && selectedCell?.col === c + const dir = sameCell ? (editorDirection === 'across' ? 'down' : 'across') : editorDirection + if (sameCell) setEditorDirection(dir) + setSelectedCell({ row: r, col: c }) + const wordCells = getWordCells(cells, r, c, dir, width, height) + setHighlightedCells(new Set(wordCells.map(([wr, wc]) => `${wr},${wc}`))) + } else if (mode === 'test') { + if (cells[r][c] === 'white') setTestSelected({ row: r, col: c }) + } else if (mode === 'clues') { + if (cells[r][c] !== 'white') return + const sameCell = selectedCell?.row === r && selectedCell?.col === c + const dir = sameCell ? (editorDirection === 'across' ? 'down' : 'across') : editorDirection + if (sameCell) setEditorDirection(dir) + selectWordInGrid(r, c, dir) + } + } + + const onClueClick = (clue: EditorClue, idx: number) => { + const dir: Dir = clue.direction === 'across' ? 'across' : 'down' + setEditorDirection(dir) + setActiveClueKey(`${clue.number}-${clue.direction}`) + setEditingClueIdx(idx) + // highlight the word in the grid + const wordCells = getWordCells(cells, clue.startRow, clue.startCol, dir, width, height) + setHighlightedCells(new Set(wordCells.map(([wr, wc]) => `${wr},${wc}`))) + setSelectedCell({ row: clue.startRow, col: clue.startCol }) + } + + const generateClues = async () => { + const { slots } = computeSlots(cells, letters, width, height) + // merge with existing clues: keep text for unchanged words + const merged = slots.map(slot => { + // find existing clue at same position with same answer + const existing = clues.find(cl => + cl.startRow === slot.startRow && cl.startCol === slot.startCol + && cl.direction === slot.direction && cl.answer === slot.answer + ) + return { ...slot, text: existing?.text || '' } + }) + setClues(merged) + // fetch suggestions for words that don't have them yet + const answers = [...new Set(merged.map(s => s.answer).filter(a => a.length >= 3 && !a.includes('?')))] + const needFetch = answers.filter(w => !suggestions[w]) + if (needFetch.length > 0) { + const newSuggestions = { ...suggestions } + await Promise.all(needFetch.map(async (word) => { newSuggestions[word] = await api.editorClueSuggestions(word) })) + setSuggestions(newSuggestions) + } + setMode('clues') + } + + const autofill = async () => { + setFilling(true); setWarnings([]) + try { + const locked: { row: number; col: number; letter: string }[] = [] + letters.forEach((row, r) => row.forEach((l, c) => { + if (l && cells[r][c] === 'white') locked.push({ row: r, col: c, letter: l }) + })) + const res = await api.editorAutofill(width, height, cells, locked.length > 0 ? locked : undefined) + if (res.ok) { + const newLetters = res.letters.map(row => row.map(l => l || null)) + setLetters(newLetters) + // build clues with position info + const { slots } = computeSlots(cells, newLetters, width, height) + // SAFETY: the autofill endpoint shapes its clues list as editor clue entries + const serverClues = (res.clues || []) as { number: number; direction: string; text?: string }[] + const merged = slots.map(slot => { + const sc = serverClues.find(c => c.number === slot.number && c.direction === slot.direction) + return { ...slot, text: sc?.text || '' } + }) + setClues(merged) + // fetch suggestions + const answerSet = new Set() + for (const c of merged) { if (c.answer.length >= 3) answerSet.add(c.answer) } + const newSuggestions = { ...suggestions } + await Promise.all([...answerSet].filter(w => !newSuggestions[w]).map(async (word) => { + newSuggestions[word] = await api.editorClueSuggestions(word) + })) + setSuggestions(newSuggestions) + setMode('clues') + } else { setWarnings([res.error || t('editor.autoFillFailed')]) } + } catch (e) { setWarnings([e instanceof Error ? e.message : t('editor.autoFillFailed')]) } + setFilling(false) + } + + const startTest = () => { setTestEntries(Array.from({ length: height }, () => Array(width).fill(''))); setTestSelected(null); setMode('test') } + const exitTest = () => { setMode('clues'); setTestSelected(null) } + const checkTest = () => { + let correct = true + for (let r = 0; r < height; r++) for (let c = 0; c < width; c++) { + if (cells[r][c] === 'black') continue + if ((testEntries[r]?.[c] || '').toUpperCase() !== (letters[r]?.[c] || '').toUpperCase()) correct = false + } + setWarnings(correct ? [t('editor.testPassed')] : [t('editor.testFailed')]) + } + + const updateClueText = (idx: number, text: string) => { const next = [...clues]; next[idx] = { ...next[idx], text }; setClues(next) } + const pickSuggestion = (idx: number, text: string) => { updateClueText(idx, text); setEditingClueIdx(null) } + + const tryPublish = () => { + setWarnings([]) + if (!title.trim() || title.trim() === 'Untitled') { + setWarnings([t('editor.titleRequired')]); return + } + if (clues.length === 0 || clues.some(c => !c.text.trim())) { + setWarnings([t('editor.cluesRequired')]); return + } + if (skipPublishConfirm) { doPublish(); return } + setShowPublishConfirm(true) + } + + const doPublish = async () => { + setShowPublishConfirm(false) + if (dontShowAgain) { + setSkipPublishConfirm(true) + api.fetchSettings().then(s => api.saveSettings({ ...s, skipPublishConfirm: true })) + } + setSaving(true); setWarnings([]) + const words = [...new Set(clues.map(c => c.answer).filter(a => a.length >= 3))] + if (words.length > 0) { + try { const res = await api.editorCheckWords(words); if (res.blocked?.length > 0) { setWarnings(res.blocked.map((w: string) => t('editor.offensiveContent', { word: w }))); setSaving(false); return } } catch { /* ignored */ } + } + try { + const res = await api.editorSave({ id: puzzleId ?? undefined, title, width, height, grid: cells, clues, puzzle: { grid: { cells: letters.map(row => row.map(l => l ? { Letter: l } : null)) } }, status: 'published' }) + setPuzzleId(res.id) + const pubRes = await api.editorPublish(res.id) + // save themed word pack if marked as themed + if (isThemed && themeName.trim()) { + try { + const packWords = clues + .filter(c => c.answer.length >= 3 && c.text.trim()) + .map(c => ({ word: c.answer.toUpperCase(), clues: [{ text: c.text, difficulty: 1 }] })) + if (packWords.length > 0) { + await api.createPack({ + name: themeName.trim(), + description: `Words and clues from "${title}"`, + tags: [themeName.trim().toLowerCase()], + words: packWords, + }) + } + } catch (_e) { /* pack save is best-effort */ } + } + if (pubRes.ok) setWarnings([t('editor.published')]) + api.editorList().then(setSavedPuzzles) + } catch (e) { setWarnings([e instanceof Error ? e.message : 'Publish failed']) } + setSaving(false) + } + + const { cellNumbers, twoLetterWarnings } = cells.length === height && cells[0]?.length === width + ? computeSlots(cells, letters, width, height) + : EMPTY_SLOT_RESULT + const cellSize = width <= 7 ? 44 : width <= 11 ? 36 : width <= 15 ? 28 : 22 + const isTest = mode === 'test' + const displayLetters = isTest ? testEntries : letters + const displaySelected = isTest ? testSelected : selectedCell + + if (view === 'list') { + return ( +
+
+
+
+ +
+ {SIZE_BUTTONS.map(([s, label]) => ( + + ))} +
+
+ +
+ + x + + +
+
+
+ {savedPuzzles.length > 0 && ( +
+
{savedPuzzles.map(p => ( +
+
+
loadPuzzle(p.id)}>{p.title} ({p.width}x{p.height})
+
+ {p.status} + {p.status === 'published' && ( + + + + )} + {p.status === 'published' ? ( + {t('editor.unpublishToDelete')} + ) : deleteConfirmId === p.id ? ( + <> + + + + ) : ( + + )} +
+
+ {analyticsId === p.id && ( +
+ {analyticsLoading ? ( + {t('common.loading')} + ) : !analyticsData || analyticsData.total_solvers === 0 ? ( + {t('constructorAnalytics.noData')} + ) : ( + <> +
+
+
{t('constructorAnalytics.totalSolvers')}
+
{analyticsData.total_solvers}
+
+
+
{t('constructorAnalytics.avgTime')}
+
{Math.floor(analyticsData.avg_time / 60)}:{String(analyticsData.avg_time % 60).padStart(2, '0')}
+
+
+
{t('constructorAnalytics.completionRate')}
+
{Math.round(analyticsData.completion_rate * 100)}%
+
+
+ {analyticsData.hint_heatmap && analyticsData.hint_heatmap.length > 0 && ( +
+
{t('constructorAnalytics.hintHeatmap')}
+
+ {analyticsData.hint_heatmap.flat().map((val: number, i: number) => ( +
+ ))} +
+
{t('constructorAnalytics.heatmapDesc')}
+
+ )} + + )} +
+ )} +
+ ))}
+
+ )} + +
+ +
+ + {savedPuzzles.length > 0 && ( + + )} +
+
+
+
+ ) + } + + return ( +
+
+
+ + + setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} /> +
+
+
+ + + + {clues.length > 0 && } +
+ + + { const s = String(v); if (s === 'rotational180' || s === 'mirror' || s === 'none') setSymmetry(s) }} className="btn btn-sm" style={{ marginTop: 8 }} options={[ + { value: 'rotational180', label: t('editor.rotationalSymmetry') }, + { value: 'mirror', label: t('editor.mirrorSymmetry') }, + { value: 'none', label: t('editor.noSymmetry') }, + ]} /> + )} + {isTest && } +
+ +
+ {(mode === 'clues' || mode === 'test' || clues.length > 0) && clues.length > 0 ? ( + <> + +
+ {clues.map((clue, idx) => ( +
!isTest && onClueClick(clue, idx)}> + {clue.number}{clue.direction === 'across' ? 'a' : 'd'} + {!isTest && {clue.answer}} + {!isTest && editingClueIdx === idx ? ( +
+ updateClueText(idx, e.target.value)} + onBlur={() => setTimeout(() => setEditingClueIdx(null), 200)} + onKeyDown={e => e.key === 'Enter' && setEditingClueIdx(null)} placeholder={t('editor.writeClue')} autoFocus /> + {suggestions[clue.answer]?.length > 0 && ( +
+ {suggestions[clue.answer].map((s, si) => ( + + ))} +
+ )} +
+ ) : ( + + {clue.text || (isTest ? '' : t('editor.clickToAddClue'))} + + )} +
+ ))} +
+ + ) : ( +
+

+

+

+ )} +
+
+ + {showPublishConfirm && ( +
setShowPublishConfirm(false)}> +
e.stopPropagation()}> +

{t('editor.publishTitle')}

+

{t('editor.publishDesc')}

+

{t('editor.publishNote')}

+ + {isThemed && ( + setThemeName(e.target.value)} + style={{ width: '100%', marginTop: 6, marginBottom: 6, padding: '0.375rem 0.5rem', + fontFamily: 'var(--font-body)', fontSize: '0.75rem', + border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)' }} /> + )} + +
+ + +
+
+
+ )} +
+ ) +}