share text and modal
This commit is contained in:
@@ -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 = `<iframe src="${embedUrl}" width="600" height="700" style="border: none;"></iframe>`
|
||||||
|
|
||||||
|
const copyEmbed = () => {
|
||||||
|
navigator.clipboard.writeText(embedCode)
|
||||||
|
setCopiedEmbed(true)
|
||||||
|
setTimeout(() => setCopiedEmbed(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div className="settings-overlay" onClick={onClose}
|
||||||
|
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }} transition={{ duration: 0.15 }}>
|
||||||
|
<motion.div ref={trapRef} className="settings-panel" onClick={e => 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 }}>
|
||||||
|
<div className="settings-header">
|
||||||
|
<h3><IconQrcode size={18} style={{ verticalAlign: -3, marginRight: 6 }} />{t('share.title')}</h3>
|
||||||
|
<button className="btn btn-sm btn-ghost" onClick={onClose} aria-label={t('common.close')}><IconX size={16} aria-hidden="true" /></button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '20px 0' }}>
|
||||||
|
<QRCodeSVG value={url} size={180} level="M" aria-label={t('share.qrLabel')} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.75rem', color: 'var(--text-secondary)', marginBottom: 16, wordBreak: 'break-all' }}>
|
||||||
|
{url}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginBottom: 16 }}>
|
||||||
|
<button className="btn btn-primary" onClick={copy}>
|
||||||
|
<IconCopy size={14} style={{ verticalAlign: -2, marginRight: 4 }} aria-hidden="true" /> {copied ? <span role="status">{t('common.copied')}</span> : t('common.copyLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '0.563rem', textTransform: 'uppercase', letterSpacing: '0.15em', color: 'var(--text-tertiary)', marginBottom: 8 }}>
|
||||||
|
{t('common.export')}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button className="btn btn-sm" onClick={() => download('puz')}>
|
||||||
|
<IconDownload size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> .puz
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => download('ipuz')}>
|
||||||
|
<IconDownload size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> .ipuz
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => download('pdf?variant=puzzle')}>
|
||||||
|
<IconFileTypePdf size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('share.puzzle')}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-sm" onClick={() => download('pdf?variant=solution')}>
|
||||||
|
<IconFileTypePdf size={12} style={{ verticalAlign: -1, marginRight: 3 }} /> {t('share.solution')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ borderTop: '1px solid var(--border)', marginTop: 12, paddingTop: 12 }}>
|
||||||
|
<button className="share-embed-toggle" onClick={() => setShowEmbed(!showEmbed)}>
|
||||||
|
<IconCodeCircleFilled size={12} />
|
||||||
|
<span>{t('share.embedOnWebsite')}</span>
|
||||||
|
<IconChevronDownFilled size={10} style={{ transform: showEmbed ? 'rotate(180deg)' : 'none', transition: 'transform 200ms' }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{showEmbed && (
|
||||||
|
<motion.div style={{ marginTop: 10, textAlign: 'left', overflow: 'hidden' }}
|
||||||
|
initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8, justifyContent: 'center' }}>
|
||||||
|
{(['auto', 'light', 'dark'] as const).map(t => (
|
||||||
|
<button key={t} className={`theme-chip ${embedTheme === t ? 'theme-chip-active' : ''}`}
|
||||||
|
onClick={() => setEmbedTheme(t)}>{t}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 10, fontSize: '0.688rem', fontFamily: 'var(--font-body)' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={embedTimer} onChange={e => setEmbedTimer(e.target.checked)} /> {t('share.timer')}
|
||||||
|
</label>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
|
<input type="checkbox" checked={embedHints} onChange={e => setEmbedHints(e.target.checked)} /> {t('share.hintsLabel')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||||
|
padding: '0.5rem', fontFamily: 'var(--font-mono)', fontSize: '0.563rem',
|
||||||
|
color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.5,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}>
|
||||||
|
{embedCode}
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-sm" onClick={copyEmbed} style={{ width: '100%' }}>
|
||||||
|
<IconCopy size={12} style={{ verticalAlign: -1, marginRight: 4 }} aria-hidden="true" /> {copiedEmbed ? <span role="status">{t('common.copied')}</span> : t('share.copyEmbed')}
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user