From 2f685f29e3ea09935ad1b6bef57a648c27346540 Mon Sep 17 00:00:00 2001 From: lashman Date: Tue, 30 Dec 2025 13:04:34 +0200 Subject: [PATCH] share text and modal --- frontend/src/components/ShareModal.tsx | 140 +++++++++++++++++++++++++ frontend/src/share.ts | 78 ++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 frontend/src/components/ShareModal.tsx create mode 100644 frontend/src/share.ts diff --git a/frontend/src/components/ShareModal.tsx b/frontend/src/components/ShareModal.tsx new file mode 100644 index 0000000..9a485d9 --- /dev/null +++ b/frontend/src/components/ShareModal.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { motion, AnimatePresence } from 'motion/react' +import { QRCodeSVG } from 'qrcode.react' +import { IconCopy, IconX, IconQrcode, IconDownload, IconFileTypePdf, + IconCodeCircleFilled, IconChevronDownFilled } from '@tabler/icons-react' +import { useStore } from '../store' +import { useFocusTrap } from '../hooks/useFocusTrap' + +export default function ShareModal({ onClose }: { onClose: () => void }) { + const trapRef = useFocusTrap(true) + const { t } = useTranslation() + const shortId = useStore((s) => s.shortId) + const puzzleId = useStore((s) => s.puzzleId) + const [showEmbed, setShowEmbed] = useState(false) + const [embedTheme, setEmbedTheme] = useState('auto') + const [embedTimer, setEmbedTimer] = useState(true) + const [embedHints, setEmbedHints] = useState(true) + const [copied, setCopied] = useState(false) + const [copiedEmbed, setCopiedEmbed] = useState(false) + + if (!shortId || !puzzleId) return null + + const url = `${window.location.origin}/p/${shortId}` + + const copy = () => { + navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const download = (format: string) => { + window.open(`/api/puzzles/${puzzleId}/export/${format}`, '_blank') + } + + const embedParams = [ + embedTheme !== 'auto' ? `theme=${embedTheme}` : '', + !embedTimer ? 'timer=false' : '', + !embedHints ? 'hints=false' : '', + ].filter(Boolean).join('&') + const embedUrl = `${window.location.origin}/e/${shortId}${embedParams ? '?' + embedParams : ''}` + const embedCode = `` + + const copyEmbed = () => { + navigator.clipboard.writeText(embedCode) + setCopiedEmbed(true) + setTimeout(() => setCopiedEmbed(false), 2000) + } + + return ( + + e.stopPropagation()} style={{ textAlign: 'center' }} + role="dialog" aria-modal="true" aria-label={t('share.title')} + initial={{ opacity: 0, scale: 0.96, y: 10 }} + animate={{ opacity: 1, scale: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.96, y: 10 }} + transition={{ duration: 0.2 }}> +
+

{t('share.title')}

+ +
+
+ +
+
+ {url} +
+
+ +
+ +
+
+ {t('common.export')} +
+
+ + + + +
+
+ +
+ + + + {showEmbed && ( + +
+ {(['auto', 'light', 'dark'] as const).map(t => ( + + ))} +
+
+ + +
+
+ {embedCode} +
+ +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/share.ts b/frontend/src/share.ts new file mode 100644 index 0000000..db695d3 --- /dev/null +++ b/frontend/src/share.ts @@ -0,0 +1,78 @@ +import type { ClientPuzzle, CellStatus } from './types' + +const GREEN = '\u{1F7E9}' +const YELLOW = '\u{1F7E8}' +const RED = '\u{1F7E5}' +const BLACK = '\u{2B1B}' +const WHITE = '\u{2B1C}' + +function formatTime(secs: number): string { + const m = Math.floor(secs / 60) + const s = secs % 60 + return `${m}:${s.toString().padStart(2, '0')}` +} + +export function generateShareText( + puzzle: ClientPuzzle, + entries: string[][], + cellStatus: CellStatus[][], + elapsed: number, + hintsUsed: number, + shortId: string | null, + preset: string | null, + dailyDate?: string | null, + cellEverWrong?: boolean[][], +): string { + const date = dailyDate || new Date().toISOString().split('T')[0] + const name = preset ? preset.charAt(0).toUpperCase() + preset.slice(1) : 'Custom' + const lines: string[] = [] + + lines.push(`cruciverb - ${name} - ${date}`) + lines.push(`${puzzle.width}x${puzzle.height} - Time: ${formatTime(elapsed)} - Hints: ${hintsUsed}`) + lines.push('') + + // only show emoji grid for small puzzles (up to 9x9) + if (puzzle.width <= 9 && puzzle.height <= 9) { + for (let r = 0; r < puzzle.height; r++) { + let row = '' + for (let c = 0; c < puzzle.width; c++) { + if (puzzle.cells[r][c] === 'black') { + row += BLACK + } else if (!entries[r]?.[c]) { + row += WHITE + } else if (cellStatus[r]?.[c] === 'revealed') { + row += YELLOW + } else if (cellEverWrong?.[r]?.[c]) { + row += RED + } else { + row += GREEN + } + } + lines.push(row) + } + } else { + // for larger grids, show a summary instead + let clean = 0, hinted = 0, wrong = 0, blacks = 0 + for (let r = 0; r < puzzle.height; r++) { + for (let c = 0; c < puzzle.width; c++) { + if (puzzle.cells[r][c] === 'black') { blacks++ } + else if (cellStatus[r]?.[c] === 'revealed') { hinted++ } + else if (cellEverWrong?.[r]?.[c]) { wrong++ } + else { clean++ } + } + } + const cells = puzzle.width * puzzle.height - blacks + lines.push(`${GREEN} ${clean}/${cells} cells clean`) + if (wrong > 0) lines.push(`${RED} ${wrong} needed correction`) + if (hinted > 0) lines.push(`${YELLOW} ${hinted} hints used`) + } + + lines.push('') + if (dailyDate && preset) { + lines.push(`${window.location.origin}/daily/${preset}`) + } else if (shortId) { + lines.push(`${window.location.origin}/p/${shortId}`) + } + + return lines.join('\n') +}