Skip to content

Commit 832017e

Browse files
committed
More polish
1 parent d43139d commit 832017e

3 files changed

Lines changed: 176 additions & 14 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./parser/xdparser2"
2+
export * from "./parser/xdparser2.compat"
23
export * from "./types"
34
export * from "./utils"

website/src/Homepage.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { PanelGroup, Panel, PanelResizer } from "@window-splitter/react"
2222
import { JsonView, allExpanded, defaultStyles } from "react-json-view-lite"
2323
import "react-json-view-lite/dist/index.css"
2424
import { exampleXDs } from "./exampleXDs"
25+
import { CDNBrowser } from "./components/CDNBrowser"
2526
import Crossword from "@jaredreisinger/react-crossword"
2627
import { convertToCrosswordFormat } from "./utils/convertToCrosswordFormat"
2728
import { CrosswordBarPreview } from "./components/CrosswordPreview"
@@ -33,7 +34,6 @@ import { resolvePuzzleMeUrl } from "./utils/resolvePuzzleMeUrl"
3334
const PRINT_SERVICE_BASE = "https://games-u7ii.onrender.com"
3435

3536
interface PrintOptions {
36-
includeSolution: boolean
3737
includeClues: boolean
3838
includeGrid: boolean
3939
grid: {
@@ -48,7 +48,6 @@ const PrintTab: React.FC<{ xd: string; crosswordJSON: CrosswordJSON }> = ({ xd,
4848
const [isGenerating, setIsGenerating] = useState(false)
4949
const [error, setError] = useState<string | null>(null)
5050
const [options, setOptions] = useState<PrintOptions>({
51-
includeSolution: false,
5251
includeClues: true,
5352
includeGrid: true,
5453
grid: {
@@ -119,14 +118,6 @@ const PrintTab: React.FC<{ xd: string; crosswordJSON: CrosswordJSON }> = ({ xd,
119118
checked={options.includeClues}
120119
onChange={(e) => setOptions({ ...options, includeClues: e.target.checked })}
121120
/>
122-
<Form.Check
123-
type="checkbox"
124-
id="includeSolution"
125-
label="Include Solution"
126-
checked={options.includeSolution}
127-
onChange={(e) => setOptions({ ...options, includeSolution: e.target.checked })}
128-
/>
129-
130121
<h6 className="mt-3">Grid Options</h6>
131122
<Form.Check
132123
type="checkbox"
@@ -164,9 +155,6 @@ const PrintTab: React.FC<{ xd: string; crosswordJSON: CrosswordJSON }> = ({ xd,
164155
<Button variant="primary" onClick={() => handleOpenPrint(false)} disabled={isGenerating}>
165156
{isGenerating ? "Generating..." : "Open Print View"}
166157
</Button>
167-
<Button variant="outline-primary" onClick={() => handleOpenPrint(true)} disabled={isGenerating}>
168-
{isGenerating ? "Generating..." : "Download PDF"}
169-
</Button>
170158
</div>
171159

172160
<div className="crossword-note mt-3">
@@ -363,7 +351,7 @@ function App() {
363351
<Tab eventKey="examples" title="Examples">
364352
<Card className="modern-card">
365353
<Card.Header className="card-header">
366-
<Card.Title className="mb-0">Sample Puzzles</Card.Title>
354+
<Card.Title className="mb-0">Sample Puzzles from Puzzmo</Card.Title>
367355
</Card.Header>
368356
<Card.Body>
369357
<div className="examples-grid">
@@ -374,6 +362,9 @@ function App() {
374362
</button>
375363
))}
376364
</div>
365+
<hr />
366+
<h6 className="mb-2">Browse gxd</h6>
367+
<CDNBrowser onSelect={setXD} />
377368
</Card.Body>
378369
</Card>
379370
</Tab>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, { useState, useEffect } from "react"
2+
import Form from "react-bootstrap/esm/Form"
3+
import { convertImplicitOrderedXDToExplicitHeaders, shouldConvertToExplicitHeaders } from "xd-crossword-tools"
4+
5+
const CDN_BASE = "https://puzmo.blob.core.windows.net/xdg-mirror"
6+
7+
type DecodedIndex = Record<string, { name: string; years: Record<string, string[]> }>
8+
9+
let cachedIndex: DecodedIndex | null = null
10+
let cachedPub = ""
11+
let cachedYear = ""
12+
13+
function decodeIndex(index: Record<string, [string, string, Record<string, string[]>]>): DecodedIndex {
14+
const tree: DecodedIndex = {}
15+
for (const [pub, [prefix, name, yearMap]] of Object.entries(index)) {
16+
const years: Record<string, string[]> = {}
17+
for (const [year, entries] of Object.entries(yearMap)) {
18+
years[year] =
19+
year === "_"
20+
? entries // flat pub, filenames are complete
21+
: entries.map((e) => `${prefix}${year}-${e}`) // restore stripped prefix+year
22+
}
23+
tree[pub] = { name, years }
24+
}
25+
return tree
26+
}
27+
28+
interface CDNBrowserProps {
29+
onSelect: (xd: string) => void
30+
}
31+
32+
export function CDNBrowser({ onSelect }: CDNBrowserProps) {
33+
const [index, setIndex] = useState<DecodedIndex | null>(null)
34+
const [loading, setLoading] = useState(false)
35+
const [error, setError] = useState<string | null>(null)
36+
const [selectedPub, setSelectedPub] = useState(cachedPub)
37+
const [selectedYear, setSelectedYear] = useState(cachedYear)
38+
const [loadingFile, setLoadingFile] = useState<string | null>(null)
39+
40+
useEffect(() => {
41+
if (cachedIndex) {
42+
setIndex(cachedIndex)
43+
return
44+
}
45+
setLoading(true)
46+
fetch(`${CDN_BASE}/index.json`)
47+
.then((r) => r.json())
48+
.then((raw) => {
49+
const decoded = decodeIndex(raw)
50+
cachedIndex = decoded
51+
setIndex(decoded)
52+
})
53+
.catch((e) => setError(e.message))
54+
.finally(() => setLoading(false))
55+
}, [])
56+
57+
useEffect(() => {
58+
if (!index || selectedPub) return
59+
const firstPub = Object.keys(index)[0]
60+
if (firstPub) {
61+
cachedPub = firstPub
62+
setSelectedPub(firstPub)
63+
const firstYear = Object.keys(index[firstPub].years)[0]
64+
if (firstYear) { cachedYear = firstYear; setSelectedYear(firstYear) }
65+
}
66+
}, [index])
67+
68+
const handlePubChange = (pub: string) => {
69+
cachedPub = pub
70+
setSelectedPub(pub)
71+
if (index) {
72+
const firstYear = Object.keys(index[pub].years)[0]
73+
cachedYear = firstYear || ""
74+
setSelectedYear(cachedYear)
75+
}
76+
}
77+
78+
const handleFileClick = async (pubKey: string, year: string, filename: string) => {
79+
setLoadingFile(filename)
80+
try {
81+
const path = year === "_" ? `${pubKey}/${filename}.xd` : `${pubKey}/${year}/${filename}.xd`
82+
const response = await fetch(`${CDN_BASE}/${path}`)
83+
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
84+
let text = await response.text()
85+
if (shouldConvertToExplicitHeaders(text)) text = convertImplicitOrderedXDToExplicitHeaders(text)
86+
onSelect(text)
87+
} catch (e) {
88+
setError(e instanceof Error ? e.message : "Failed to load puzzle")
89+
} finally {
90+
setLoadingFile(null)
91+
}
92+
}
93+
94+
if (loading) return <div className="text-muted">Loading index...</div>
95+
if (error) return <div className="text-danger">{error}</div>
96+
if (!index) return null
97+
98+
const pubEntries = Object.entries(index)
99+
const years = selectedPub ? Object.keys(index[selectedPub].years).sort().reverse() : []
100+
const files = selectedPub && selectedYear ? index[selectedPub].years[selectedYear] : []
101+
const totalFiles = files.length
102+
103+
return (
104+
<div>
105+
<p className="text-muted small mb-2">
106+
Over 6,000 pre-1965 crosswords from the{" "}
107+
<a href="https://xd.saul.pw/data" target="_blank" rel="noreferrer">
108+
gxd
109+
</a>{" "}
110+
dataset, mirrored by Puzzmo in March 2026.
111+
</p>
112+
<div className="d-flex gap-2 mb-2 flex-wrap align-items-center">
113+
<Form.Select
114+
size="sm"
115+
value={selectedPub}
116+
onChange={(e) => handlePubChange(e.target.value)}
117+
style={{ maxWidth: "220px" }}
118+
>
119+
{pubEntries.map(([key, { name }]) => (
120+
<option key={key} value={key}>
121+
{name}
122+
</option>
123+
))}
124+
</Form.Select>
125+
<Form.Select
126+
size="sm"
127+
value={selectedYear}
128+
onChange={(e) => { cachedYear = e.target.value; setSelectedYear(e.target.value) }}
129+
style={{ maxWidth: "120px" }}
130+
disabled={!selectedPub}
131+
>
132+
{years.map((year) => (
133+
<option key={year} value={year}>
134+
{year === "_" ? "All" : year}
135+
</option>
136+
))}
137+
</Form.Select>
138+
<span className="text-muted small">{totalFiles} puzzle{totalFiles !== 1 ? "s" : ""}</span>
139+
</div>
140+
<div style={{ maxHeight: "300px", overflowY: "auto", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "4px" }}>
141+
{files.length === 0 ? (
142+
<div className="text-muted">No puzzles found</div>
143+
) : (
144+
files.map((filename) => {
145+
const dateMatch = filename.match(/(\d{4})-(\d{2})-(\d{2})/)
146+
const label = dateMatch
147+
? new Date(`${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`).toLocaleDateString(undefined, {
148+
year: "numeric",
149+
month: "long",
150+
day: "numeric",
151+
timeZone: "UTC",
152+
})
153+
: filename
154+
return (
155+
<button
156+
key={filename}
157+
className="example-button"
158+
style={{ textAlign: "left" }}
159+
onClick={() => handleFileClick(selectedPub, selectedYear, filename)}
160+
disabled={loadingFile !== null}
161+
>
162+
<div className="example-title">{loadingFile === filename ? "Loading..." : label}</div>
163+
</button>
164+
)
165+
})
166+
)}
167+
</div>
168+
</div>
169+
)
170+
}

0 commit comments

Comments
 (0)