puzzle editor
This commit is contained in:
@@ -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<string, number>; 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<string, number> = {}
|
||||||
|
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<string | null>(null)
|
||||||
|
const [title, setTitle] = useState('Untitled')
|
||||||
|
const [width, setWidth] = useState(15)
|
||||||
|
const [height, setHeight] = useState(15)
|
||||||
|
const [cells, setCells] = useState<CellType[][]>([])
|
||||||
|
const [letters, setLetters] = useState<(string | null)[][]>([])
|
||||||
|
const [clues, setClues] = useState<EditorClue[]>([])
|
||||||
|
const [symmetry, setSymmetry] = useState<Symmetry>('rotational180')
|
||||||
|
const [mode, setMode] = useState<EditorMode>('design')
|
||||||
|
const [selectedCell, setSelectedCell] = useState<{ row: number; col: number } | null>(null)
|
||||||
|
const [editorDirection, setEditorDirection] = useState<Dir>('across')
|
||||||
|
const [activeClueKey, setActiveClueKey] = useState<string | null>(null)
|
||||||
|
const [highlightedCells, setHighlightedCells] = useState<Set<string>>(new Set())
|
||||||
|
const [filling, setFilling] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [savedPuzzles, setSavedPuzzles] = useState<SavedPuzzle[]>([])
|
||||||
|
const [editingClueIdx, setEditingClueIdx] = useState<number | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<Record<string, string[]>>({})
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([])
|
||||||
|
const [undoStack, setUndoStack] = useState<{ cells: CellType[][]; letters: (string | null)[][]; clues: EditorClue[] }[]>([])
|
||||||
|
const [lastSaved, setLastSaved] = useState('')
|
||||||
|
const autosaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const [showPublishConfirm, setShowPublishConfirm] = useState(false)
|
||||||
|
const [skipPublishConfirm, setSkipPublishConfirm] = useState(false)
|
||||||
|
const [dontShowAgain, setDontShowAgain] = useState(false)
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null)
|
||||||
|
const [customW, setCustomW] = useState(13)
|
||||||
|
const [customH, setCustomH] = useState(13)
|
||||||
|
const [isThemed, setIsThemed] = useState(false)
|
||||||
|
const [themeName, setThemeName] = useState('')
|
||||||
|
const [analyticsId, setAnalyticsId] = useState<string | null>(null)
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<ConstructorAnalytics | null>(null)
|
||||||
|
const [analyticsLoading, setAnalyticsLoading] = useState(false)
|
||||||
|
|
||||||
|
// load publish confirm preference
|
||||||
|
useEffect(() => {
|
||||||
|
api.fetchSettings().then(s => { if (s.skipPublishConfirm) setSkipPublishConfirm(true) })
|
||||||
|
}, [])
|
||||||
|
const [testEntries, setTestEntries] = useState<string[][]>([])
|
||||||
|
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<string | null>(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<string>()
|
||||||
|
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 (
|
||||||
|
<div className="editor-fullscreen" role="main" aria-label={t('editor.title')}>
|
||||||
|
<div className="editor-header"><div className="header-left"><button type="button" className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button><button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button></div></div>
|
||||||
|
<div className="editor-list-body">
|
||||||
|
<div className="editor-new-section">
|
||||||
|
<label className="settings-label">{t('editor.createNew')}</label>
|
||||||
|
<div className="settings-row" style={{ flexWrap: 'wrap' }}>
|
||||||
|
{SIZE_BUTTONS.map(([s, label]) => (
|
||||||
|
<button key={label} className="btn btn-sm" onClick={() => newPuzzle(s, s)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} />{label}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="editor-custom-size">
|
||||||
|
<label className="settings-label" style={{ marginTop: 8 }}>{t('editor.customSize')}</label>
|
||||||
|
<div className="settings-row">
|
||||||
|
<NumberInput value={customW} min={3} max={25} onChange={setCustomW} className="editor-size-input" />
|
||||||
|
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem', display: 'flex', alignItems: 'center' }}>x</span>
|
||||||
|
<NumberInput value={customH} min={3} max={25} onChange={setCustomH} className="editor-size-input" />
|
||||||
|
<button className="btn btn-sm" onClick={() => newPuzzle(customW, customH)}><IconPlus size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.create')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{savedPuzzles.length > 0 && (
|
||||||
|
<div className="editor-saved"><label className="settings-label">{t('editor.yourPuzzles')}</label>
|
||||||
|
<div className="cc-list">{savedPuzzles.map(p => (
|
||||||
|
<div key={p.id}>
|
||||||
|
<div className="cc-clue" style={{ cursor: 'pointer' }}>
|
||||||
|
<div className="cc-clue-text" onClick={() => loadPuzzle(p.id)}>{p.title} ({p.width}x{p.height})</div>
|
||||||
|
<div className="cc-clue-actions">
|
||||||
|
<span className={`cc-status cc-status-${p.status}`}>{p.status}</span>
|
||||||
|
{p.status === 'published' && (
|
||||||
|
<Tooltip text={t('constructorAnalytics.title')}>
|
||||||
|
<button className="btn btn-sm btn-ghost" onClick={(e) => { e.stopPropagation(); toggleAnalytics(p.id) }} style={{ padding: '0.125rem 0.25rem' }}>
|
||||||
|
<IconChartAreaFilled size={11} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{p.status === 'published' ? (
|
||||||
|
<span className="editor-hint-text">{t('editor.unpublishToDelete')}</span>
|
||||||
|
) : deleteConfirmId === p.id ? (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-sm" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={doDelete}>{t('editor.yesDelete')}</button>
|
||||||
|
<button className="btn btn-sm btn-ghost" style={{ fontSize: '0.5rem', padding: '0.125rem 0.375rem' }} onClick={() => setDeleteConfirmId(null)}>{t('common.cancel')}</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button className="cc-delete" onClick={() => confirmDelete(p.id)}><IconTrashFilled size={11} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analyticsId === p.id && (
|
||||||
|
<div style={{ padding: '8px 12px', background: 'var(--surface-alt)', borderRadius: 6, margin: '4px 0 8px', fontSize: '0.75rem' }}>
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{t('common.loading')}</span>
|
||||||
|
) : !analyticsData || analyticsData.total_solvers === 0 ? (
|
||||||
|
<span style={{ color: 'var(--text-tertiary)' }}>{t('constructorAnalytics.noData')}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8, marginBottom: 8 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.65rem' }}>{t('constructorAnalytics.totalSolvers')}</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{analyticsData.total_solvers}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.65rem' }}>{t('constructorAnalytics.avgTime')}</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{Math.floor(analyticsData.avg_time / 60)}:{String(analyticsData.avg_time % 60).padStart(2, '0')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.65rem' }}>{t('constructorAnalytics.completionRate')}</div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{Math.round(analyticsData.completion_rate * 100)}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{analyticsData.hint_heatmap && analyticsData.hint_heatmap.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.65rem', marginBottom: 4 }}>{t('constructorAnalytics.hintHeatmap')}</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${analyticsData.width || p.width}, 1fr)`,
|
||||||
|
gap: 1, maxWidth: 200
|
||||||
|
}}>
|
||||||
|
{analyticsData.hint_heatmap.flat().map((val: number, i: number) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: '100%', aspectRatio: '1', borderRadius: 2,
|
||||||
|
background: val < 0 ? 'var(--cell-black)' : `rgba(239, 68, 68, ${Math.min(val / (analyticsData.max_hints || 1), 1) * 0.8 + 0.1})`,
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.6rem', marginTop: 2 }}>{t('constructorAnalytics.heatmapDesc')}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="editor-import-export">
|
||||||
|
<label className="settings-label" style={{ marginTop: 16 }}>{t('editor.importExport')}</label>
|
||||||
|
<div className="settings-row">
|
||||||
|
<button className="btn btn-sm" onClick={() => {
|
||||||
|
const input = document.createElement('input')
|
||||||
|
input.type = 'file'; input.accept = '.ipuz,.json'
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (!input.files?.length) return
|
||||||
|
try {
|
||||||
|
const text = await input.files[0].text()
|
||||||
|
const data = JSON.parse(text)
|
||||||
|
const res = await api.editorImport(data)
|
||||||
|
api.editorList().then(setSavedPuzzles)
|
||||||
|
setWarnings([`Imported ${res.imported} puzzle${res.imported !== 1 ? 's' : ''}`])
|
||||||
|
} catch (e) { setWarnings([e instanceof Error ? e.message : 'Import failed']) }
|
||||||
|
}
|
||||||
|
input.click()
|
||||||
|
}}>
|
||||||
|
<IconFileUploadFilled size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('common.import')}
|
||||||
|
</button>
|
||||||
|
{savedPuzzles.length > 0 && (
|
||||||
|
<button className="btn btn-sm" onClick={() => api.editorExportAll()}>
|
||||||
|
<IconDownloadFilled size={11} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.exportAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="editor-fullscreen" role="main" aria-label={t('editor.title')}>
|
||||||
|
<div className="editor-header">
|
||||||
|
<div className="editor-header-left">
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setView('list')} aria-label={t('common.back')}><IconArrowBack size={16} aria-hidden="true" /></button>
|
||||||
|
<button type="button" className="header-brand" onClick={onClose}>{t('app.name')}</button>
|
||||||
|
<input className="editor-title-input" value={title} onChange={e => setTitle(e.target.value)} placeholder={t('editor.puzzleTitle')} />
|
||||||
|
</div>
|
||||||
|
<div className="editor-header-actions">
|
||||||
|
<div className="editor-mode-toggle">
|
||||||
|
<button className={`btn btn-sm ${mode === 'design' ? 'btn-primary' : ''}`} onClick={() => { setMode('design'); setHighlightedCells(new Set()) }}><IconPointerFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.design')}</button>
|
||||||
|
<button className={`btn btn-sm ${mode === 'fill' ? 'btn-primary' : ''}`} onClick={() => { setMode('fill'); setHighlightedCells(new Set()) }}><IconPencilFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.fill')}</button>
|
||||||
|
<button className={`btn btn-sm ${mode === 'clues' ? 'btn-primary' : ''}`} onClick={generateClues}><IconListFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('editor.clues')}</button>
|
||||||
|
{clues.length > 0 && <button className={`btn btn-sm ${mode === 'test' ? 'btn-primary' : ''}`} onClick={isTest ? exitTest : startTest}><IconPlayerPlayFilled size={12} style={{ verticalAlign: -1, marginRight: 3 }} />{isTest ? t('editor.exitTest') : t('common.test')}</button>}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={autofill} disabled={filling}><IconSparklesFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} />{filling ? t('editor.filling') : t('editor.autoFill')}</button>
|
||||||
|
<Tooltip text={t('editor.undoCtrlZ')}><button className="btn btn-sm" onClick={undo} disabled={undoStack.length === 0}><IconArrowBack size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.undo')}</button></Tooltip>
|
||||||
|
<Select value="" onChange={v => {
|
||||||
|
if (v === 'clear-all') clearGrid()
|
||||||
|
else if (v === 'clear-letters') clearLetters()
|
||||||
|
else if (v === 'unpublish') unpublish()
|
||||||
|
else if (v === 'export' && puzzleId) api.editorExport(puzzleId)
|
||||||
|
}} className="btn btn-sm" options={[
|
||||||
|
{ value: '', label: t('common.more') },
|
||||||
|
{ value: 'clear-letters', label: t('editor.clearLetters') },
|
||||||
|
{ value: 'clear-all', label: t('editor.clearEverything') },
|
||||||
|
...(puzzleId ? [{ value: 'export', label: t('editor.exportIpuz') }] : []),
|
||||||
|
...(savedPuzzles.find(p => p.id === puzzleId)?.status === 'published' ? [{ value: 'unpublish', label: t('editor.unpublish') }] : []),
|
||||||
|
]} />
|
||||||
|
{clues.length > 0 && <button className="btn btn-sm" onClick={tryPublish} disabled={saving}><IconWorldFilled size={13} style={{ verticalAlign: -2, marginRight: 3 }} /> {t('common.publish')}</button>}
|
||||||
|
{lastSaved && <span className="editor-autosave-indicator">saved {lastSaved}</span>}
|
||||||
|
<button className="btn btn-sm btn-ghost" onClick={onClose}><IconX size={16} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(warnings.length > 0 || twoLetterWarnings.length > 0) && (
|
||||||
|
<div className="editor-warnings" style={{ justifyContent: 'center' }}>
|
||||||
|
<IconAlertTriangleFilled size={14} />
|
||||||
|
{warnings.map((w, i) => <span key={`w${i}`}>{w}</span>)}
|
||||||
|
{twoLetterWarnings.length > 0 && <span>{twoLetterWarnings.length} two-letter slot{twoLetterWarnings.length > 1 ? 's' : ''} (min 3 letters)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="editor-workspace">
|
||||||
|
<div className="editor-grid-area">
|
||||||
|
<svg viewBox={`0 0 ${width * cellSize} ${height * cellSize}`} style={{ maxWidth: width * cellSize, width: '100%' }}>
|
||||||
|
{cells.map((row, r) => row.map((cell, c) => {
|
||||||
|
const isSel = displaySelected?.row === r && displaySelected?.col === c
|
||||||
|
const isHighlighted = highlightedCells.has(`${r},${c}`)
|
||||||
|
const val = displayLetters[r]?.[c]
|
||||||
|
return (
|
||||||
|
<g key={`${r}-${c}`} onClick={() => onCellClick(r, c)} style={{ cursor: 'pointer' }}>
|
||||||
|
<rect x={c * cellSize} y={r * cellSize} width={cellSize} height={cellSize}
|
||||||
|
fill={cell === 'black' ? 'var(--text)' : isSel ? 'var(--cell-selected, #bfdbfe)' : isHighlighted ? 'var(--accent-soft, #e0e7ff)' : 'var(--surface)'}
|
||||||
|
stroke="var(--border)" strokeWidth={0.5} />
|
||||||
|
{cellNumbers[`${r},${c}`] && <text x={c * cellSize + 2} y={r * cellSize + cellSize * 0.3} fontSize={cellSize * 0.25} fill="var(--text-tertiary)" fontFamily="var(--font-mono)">{cellNumbers[`${r},${c}`]}</text>}
|
||||||
|
{val && <text x={c * cellSize + cellSize / 2} y={r * cellSize + cellSize * 0.75} fontSize={cellSize * 0.5} fill="var(--text)" fontFamily="var(--font-display)" fontWeight="700" textAnchor="middle">{val}</text>}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}))}
|
||||||
|
</svg>
|
||||||
|
<div className="editor-grid-hint">
|
||||||
|
{mode === 'design' ? t('editor.designHint')
|
||||||
|
: mode === 'fill' ? t('editor.fillHint', { dir: editorDirection })
|
||||||
|
: mode === 'test' ? t('editor.testHint')
|
||||||
|
: t('editor.clueHint')}
|
||||||
|
</div>
|
||||||
|
{mode === 'design' && (
|
||||||
|
<Select value={symmetry} onChange={v => { 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 && <button className="btn btn-sm" onClick={checkTest} style={{ marginTop: 8 }}>{t('editor.checkAnswers')}</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-clue-area">
|
||||||
|
{(mode === 'clues' || mode === 'test' || clues.length > 0) && clues.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<label className="settings-label">{isTest ? t('editor.clues') : t('editor.cluesClickToEdit')}</label>
|
||||||
|
<div className="editor-clue-list">
|
||||||
|
{clues.map((clue, idx) => (
|
||||||
|
<div key={`${clue.number}-${clue.direction}`} id={`ec-${clue.number}-${clue.direction}`}
|
||||||
|
className={`editor-clue-item ${activeClueKey === `${clue.number}-${clue.direction}` ? 'editor-clue-active' : ''}`}
|
||||||
|
onClick={() => !isTest && onClueClick(clue, idx)}>
|
||||||
|
<span className="editor-clue-num">{clue.number}{clue.direction === 'across' ? 'a' : 'd'}</span>
|
||||||
|
{!isTest && <span className="editor-clue-answer">{clue.answer}</span>}
|
||||||
|
{!isTest && editingClueIdx === idx ? (
|
||||||
|
<div className="editor-clue-edit-area">
|
||||||
|
<input className="editor-clue-input" value={clue.text} onChange={e => 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 && (
|
||||||
|
<div className="editor-suggestions">
|
||||||
|
{suggestions[clue.answer].map((s, si) => (
|
||||||
|
<button key={si} className="editor-suggestion" onClick={() => pickSuggestion(idx, s)}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className={`editor-clue-text ${!clue.text ? 'editor-clue-empty-text' : ''}`}>
|
||||||
|
{clue.text || (isTest ? '' : t('editor.clickToAddClue'))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="editor-clue-empty">
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: t('editor.emptyHint1') }} />
|
||||||
|
<p dangerouslySetInnerHTML={{ __html: t('editor.emptyHint2') }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPublishConfirm && (
|
||||||
|
<div className="settings-overlay" onClick={() => setShowPublishConfirm(false)}>
|
||||||
|
<div className="editor-publish-dialog" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>{t('editor.publishTitle')}</h3>
|
||||||
|
<p>{t('editor.publishDesc')}</p>
|
||||||
|
<p>{t('editor.publishNote')}</p>
|
||||||
|
<label className="editor-publish-check">
|
||||||
|
<input type="checkbox" checked={isThemed} onChange={e => setIsThemed(e.target.checked)} />
|
||||||
|
{t('editor.isThemed')}
|
||||||
|
</label>
|
||||||
|
{isThemed && (
|
||||||
|
<input type="text" placeholder={t('editor.themePlaceholder')}
|
||||||
|
value={themeName} onChange={e => 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)' }} />
|
||||||
|
)}
|
||||||
|
<label className="editor-publish-check">
|
||||||
|
<input type="checkbox" checked={dontShowAgain} onChange={e => setDontShowAgain(e.target.checked)} />
|
||||||
|
{t('editor.dontShowAgain')}
|
||||||
|
</label>
|
||||||
|
<div className="editor-publish-actions">
|
||||||
|
<button className="btn btn-sm" onClick={() => setShowPublishConfirm(false)}>{t('common.cancel')}</button>
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={doPublish}>{t('common.publish')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user