diff --git a/.gitignore b/.gitignore index b455588..c8d5d05 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # production /build +/.compat-worktrees # misc .DS_Store @@ -29,6 +30,8 @@ yarn-error.log* .vscode scripts/* +!scripts/build-deploy.mjs +!scripts/compat-cut.mjs !scripts/generate-screenshots.mjs !scripts/encrypt-oracle.ts !scripts/decrypt-oracle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 23956c4..9a9ce9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## 2026-07-04 - Pinned versions by date +Older course material can now link to a frozen version of the validator at /compatibility//. Each dated version keeps its own question bank, data, and saved work, unaffected by later changes to the live site. + ## 2026-03-19 - Changelog added A changelog has been added to the app to keep you updated on new features and bug fixes. diff --git a/README.md b/README.md index ba09e07..15d5ea7 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,25 @@ SQL Validator is a fully client-side web application powered by sql.js. Designed ### Public Deployment A public instance of SQL Validator is available at [https://sql-validator.e-su.se](https://sql-validator.e-su.se), powered with Cloudflare Pages. +### Compatibility Dates +Course material is written against a specific version of the validator (its question bank, generated data, and behavior). To keep older material reproducible when the app changes, the site also serves immutable, dated builds at `/compatibility//`, e.g. [https://sql-validator.e-su.se/compatibility/2026-07-04/](https://sql-validator.e-su.se/compatibility/2026-07-04/). + +A dated build is frozen: its own code, question bank, and expected results, plus its own isolated `localStorage` (namespaced by the date via `src/storage.ts` so it never collides with the live app or other dates on the shared origin). + +Each date is an entry in `compatibility.json` (repo root) mapping the date to an immutable commit, e.g. `{ "2026-07-04": "" }`. On every deploy, the normal build (`npm run build`, which runs `scripts/build-deploy.mjs`) rebuilds each pinned commit under its own base path and assembles the live root plus every snapshot into one `build/` output. The single-app build is `npm run build:app`. The live root at `/` always tracks `master`. No git tags are involved. + +**Cutting a new compatibility date:** +1. With `master` checked out at the state to freeze, run: + ``` + npm run compat:cut # pins today's date to HEAD + npm run compat:cut -- 2026-07-04 master # explicit date / ref + ``` + This adds `"": ""` to `compatibility.json` (or edit the file by hand). Only pin commits that contain the compatibility machinery (`src/storage.ts`, the `APP_BASE` handling in `vite.config.ts`, and base-aware asset paths — this feature or later); the build skips a commit that predates it. +2. Commit `compatibility.json` and push to `master`. The next deploy publishes `/compatibility//` automatically. +3. Don't re-point a published date to a different commit — the dated URL is meant to be permanent (`compat:cut` refuses to move an existing date). + +**Cloudflare Pages settings:** unchanged — build command `npm run build`, output directory `build`. When snapshots are configured the build fetches the pinned commits, deepening a shallow clone automatically. A snapshot that fails to build is logged and skipped so the live site still deploys — watch the build log. + ### Running Locally 1. Clone the repository: `git clone https://github.com/Edwinexd/sql-validator.git` 2. Install dependencies: `npm install` diff --git a/compatibility.json b/compatibility.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/compatibility.json @@ -0,0 +1 @@ +{} diff --git a/package.json b/package.json index 74989cb..e8bec48 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "scripts": { "dev": "npm-run-all copy-sqljs start-vite", "start": "npm-run-all copy-sqljs start-vite", - "build": "npm-run-all copy-sqljs build-vite", + "build": "node scripts/build-deploy.mjs", + "build:app": "npm-run-all copy-sqljs build-vite", + "compat:cut": "node scripts/compat-cut.mjs", "preview": "vite preview", "copy-sqljs": "copyfiles -f node_modules/sql.js/dist/sql-wasm.wasm public/dist/sql.js/", "start-vite": "vite", diff --git a/scripts/build-deploy.mjs b/scripts/build-deploy.mjs new file mode 100644 index 0000000..8939c9a --- /dev/null +++ b/scripts/build-deploy.mjs @@ -0,0 +1,144 @@ +/* +Cloudflare Pages build command for the SQL Validator. + +Assembles a single deploy tree: + build/ -> live app (Vite base "/") + build/compatibility// -> immutable full-app build pinned by compatibility.json + +Compatibility dates are declared in compatibility.json at the repo root: + { "2026-07-04": "" } +Each entry maps a date to an immutable commit. This script rebuilds every pinned +commit under its own base path (/compatibility//) so the pinned build +fetches its own frozen questionpool/database and uses an isolated localStorage +namespace (see src/storage.ts). Pin a date with `npm run compat:cut`. + +Only pin commits that already contain the compatibility machinery (this feature +onward); older commits fetch assets from the site root and would not isolate. A +missing-machinery commit is detected and skipped. + +This IS the normal build (`npm run build`), so Cloudflare Pages needs no config +change. The single-app build lives in `npm run build:app`, which this script +invokes for the live root and for each snapshot. +*/ +import { execSync } from "node:child_process"; +import { + existsSync, mkdirSync, rmSync, cpSync, writeFileSync, readFileSync, symlinkSync, +} from "node:fs"; +import { join } from "node:path"; + +const ROOT = process.cwd(); +const OUT = join(ROOT, "build"); +const WORKTREES = join(ROOT, ".compat-worktrees"); +const CONFIG = join(ROOT, "compatibility.json"); +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const run = (cmd, opts = {}) => execSync(cmd, { stdio: "inherit", ...opts }); +const tryRun = (cmd, opts = {}) => { try { run(cmd, { stdio: "ignore", ...opts }); return true; } catch { return false; } }; +const capture = (cmd, opts = {}) => execSync(cmd, { encoding: "utf8", ...opts }).trim(); + +// Cloudflare Pages does a shallow clone; make full history available so any +// pinned commit can be checked out. +function ensureHistory() { + if (!tryRun("git fetch --tags --force --unshallow")) tryRun("git fetch --tags --force"); +} + +function readConfig() { + if (!existsSync(CONFIG)) { console.warn("! compatibility.json not found; deploying live root only."); return []; } + let raw; + try { raw = JSON.parse(readFileSync(CONFIG, "utf8")); } + catch (e) { console.error(`! compatibility.json is invalid JSON: ${e.message}`); return []; } + const entries = []; + for (const [date, ref] of Object.entries(raw)) { + if (!DATE_RE.test(date)) { console.warn(` skip "${date}": not YYYY-MM-DD`); continue; } + if (typeof ref !== "string" || !ref) { console.warn(` skip ${date}: missing commit`); continue; } + entries.push({ date, ref }); + } + entries.sort((a, b) => (a.date < b.date ? 1 : -1)); // newest first + return entries; +} + +function resolveCommit(ref) { + try { return capture(`git rev-parse --verify "${ref}^{commit}"`); } + catch { + // Commit may be absent in a shallow clone; try to fetch it, then retry. + tryRun(`git fetch --force origin "${ref}"`); + return capture(`git rev-parse --verify "${ref}^{commit}"`); + } +} + +function installDeps(worktree) { + // Reuse the root install when the lockfile is byte-identical (fast path on CF); + // otherwise do a clean install for that commit's pinned dependencies. + const rootLock = readFileSync(join(ROOT, "package-lock.json"), "utf8"); + const wtLock = readFileSync(join(worktree, "package-lock.json"), "utf8"); + if (wtLock === rootLock && existsSync(join(ROOT, "node_modules"))) { + symlinkSync(join(ROOT, "node_modules"), join(worktree, "node_modules"), "dir"); + } else { + run("npm ci", { cwd: worktree }); + } +} + +function buildSnapshot({ date, ref }) { + const sha = resolveCommit(ref); + // Guard: the pinned commit must contain the compatibility machinery. + if (!tryRun(`git cat-file -e ${sha}:src/storage.ts`)) { + throw new Error(`commit ${sha.slice(0, 10)} predates the compatibility machinery (no src/storage.ts)`); + } + const base = `/compatibility/${date}/`; + const worktree = join(WORKTREES, date); + console.log(`\n=== compatibility snapshot ${date} (${sha.slice(0, 10)}) base=${base} ===`); + run(`git worktree add --detach --force "${worktree}" "${sha}"`); + try { + installDeps(worktree); + run("npm run build:app", { cwd: worktree, env: { ...process.env, APP_BASE: base } }); + const dest = join(OUT, "compatibility", date); + mkdirSync(join(OUT, "compatibility"), { recursive: true }); + rmSync(dest, { recursive: true, force: true }); + cpSync(join(worktree, "build"), dest, { recursive: true }); + } finally { + tryRun(`git worktree remove --force "${worktree}"`); + } +} + +function writeRoutingFiles(dates) { + // SPA fallback: dated builds fall back to their own index.html, everything else to root. + const redirects = [ + ...dates.map(d => `/compatibility/${d}/* /compatibility/${d}/index.html 200`), + "/* /index.html 200", + ].join("\n") + "\n"; + writeFileSync(join(OUT, "_redirects"), redirects); + + // Snapshots are immutable -> cache them hard. + const headers = dates.map(d => + `/compatibility/${d}/*\n Cache-Control: public, max-age=31536000, immutable` + ).join("\n\n") + (dates.length ? "\n" : ""); + writeFileSync(join(OUT, "_headers"), headers); + + mkdirSync(join(OUT, "compatibility"), { recursive: true }); + writeFileSync(join(OUT, "compatibility", "index.json"), JSON.stringify(dates) + "\n"); +} + +// --- run --------------------------------------------------------------------- +const compat = readConfig(); +if (compat.length) ensureHistory(); + +console.log("=== live root build (base /) ==="); +run("npm run build:app"); + +console.log(`\nPinned compatibility date(s): ${compat.map(c => c.date).join(", ") || "none"}`); + +rmSync(WORKTREES, { recursive: true, force: true }); +mkdirSync(WORKTREES, { recursive: true }); + +const built = []; +const failed = []; +for (const c of compat) { + try { buildSnapshot(c); built.push(c.date); } + catch (e) { console.error(`! FAILED snapshot ${c.date}: ${e.message}`); failed.push(c.date); } +} +rmSync(WORKTREES, { recursive: true, force: true }); + +writeRoutingFiles(built); + +console.log(`\nDeploy assembled: live root + ${built.length} snapshot(s): ${built.join(", ") || "none"}`); +if (failed.length) console.error(`! ${failed.length} snapshot(s) FAILED and were left out: ${failed.join(", ")}`); diff --git a/scripts/compat-cut.mjs b/scripts/compat-cut.mjs new file mode 100644 index 0000000..2ebad4e --- /dev/null +++ b/scripts/compat-cut.mjs @@ -0,0 +1,44 @@ +/* +Pin a compatibility date to an immutable commit in compatibility.json. + + npm run compat:cut # pin today's date to HEAD + npm run compat:cut -- 2026-07-04 # explicit date, HEAD + npm run compat:cut -- 2026-07-04 master # explicit date and ref + +Writes compatibility.json; commit and push it to master. The next deploy freezes +that commit at /compatibility//. No git tags involved. Published dates are +immutable: re-pinning an existing date to a different commit is refused. +*/ +import { readFileSync, writeFileSync, existsSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; + +const CONFIG = join(process.cwd(), "compatibility.json"); +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const cap = (c) => execSync(c, { encoding: "utf8" }).trim(); + +const [dateArg, refArg] = process.argv.slice(2); +const date = dateArg || new Date().toISOString().slice(0, 10); +const ref = refArg || "HEAD"; + +if (!DATE_RE.test(date)) { console.error(`Date must be YYYY-MM-DD, got: ${date}`); process.exit(1); } + +let sha, subject; +try { + sha = cap(`git rev-parse --verify "${ref}^{commit}"`); + subject = cap(`git show -s --format=%s ${sha}`); +} catch { console.error(`Could not resolve ref: ${ref}`); process.exit(1); } + +const cfg = existsSync(CONFIG) ? JSON.parse(readFileSync(CONFIG, "utf8")) : {}; +if (cfg[date] && cfg[date] !== sha) { + console.error(`! ${date} is already pinned to ${cfg[date]}; published dates must stay immutable. Refusing to move it.`); + process.exit(1); +} +cfg[date] = sha; + +const sorted = Object.fromEntries(Object.keys(cfg).sort().reverse().map(k => [k, cfg[k]])); +writeFileSync(CONFIG, JSON.stringify(sorted, null, 2) + "\n"); + +console.log(`Pinned ${date} -> ${sha}`); +console.log(` ${subject}`); +console.log(`\nNext: commit compatibility.json and push to master. The next deploy publishes /compatibility/${date}/.`); diff --git a/src/App.tsx b/src/App.tsx index eb2170d..6dd9c33 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,6 +52,7 @@ import ExportSelectorModal, { ExportSelectorModalHandle } from "./ExportSelector import ImportDialog, { ImportDialogHandle } from "./ImportDialog"; import { ParsedSaveData, parseImportFile, getLocalData, detectConflicts } from "./mergeUtils"; import { useLanguage, langKey, getUrlParam, setUrlParam } from "./i18n/context"; +import { asset, storage } from "./storage"; import LanguageSelector from "./LanguageSelector"; import { getQuestion } from "./QuestionSelector"; @@ -87,50 +88,50 @@ function App() { // QuestionSelector needs writtenQuestions and correctQuestions to be able to display the correct state const [writtenQuestions, setWrittenQuestions] = useState( - localStorage.getItem(langKey(lang, "writtenQuestions")) ? JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions"))!) : [] + storage.getItem(langKey(lang, "writtenQuestions")) ? JSON.parse(storage.getItem(langKey(lang, "writtenQuestions"))!) : [] ); const [correctQuestions, setCorrectQuestions] = useState( - localStorage.getItem(langKey(lang, "correctQuestions")) ? JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) : [] + storage.getItem(langKey(lang, "correctQuestions")) ? JSON.parse(storage.getItem(langKey(lang, "correctQuestions"))!) : [] ); // One-time migration: copy old unnamespaced keys to sv: prefix useEffect(() => { - if (localStorage.getItem("i18n-migrated")) return; - const oldWritten = localStorage.getItem("writtenQuestions"); - if (oldWritten && !localStorage.getItem(langKey("sv", "writtenQuestions"))) { - localStorage.setItem(langKey("sv", "writtenQuestions"), oldWritten); + if (storage.getItem("i18n-migrated")) return; + const oldWritten = storage.getItem("writtenQuestions"); + if (oldWritten && !storage.getItem(langKey("sv", "writtenQuestions"))) { + storage.setItem(langKey("sv", "writtenQuestions"), oldWritten); const ids: number[] = JSON.parse(oldWritten); for (const id of ids) { - const q = localStorage.getItem(`questionId-${id}`); - if (q) localStorage.setItem(langKey("sv", `questionId-${id}`), q); + const q = storage.getItem(`questionId-${id}`); + if (q) storage.setItem(langKey("sv", `questionId-${id}`), q); } } - const oldCorrect = localStorage.getItem("correctQuestions"); - if (oldCorrect && !localStorage.getItem(langKey("sv", "correctQuestions"))) { - localStorage.setItem(langKey("sv", "correctQuestions"), oldCorrect); + const oldCorrect = storage.getItem("correctQuestions"); + if (oldCorrect && !storage.getItem(langKey("sv", "correctQuestions"))) { + storage.setItem(langKey("sv", "correctQuestions"), oldCorrect); const ids: number[] = JSON.parse(oldCorrect); for (const id of ids) { - const q = localStorage.getItem(`correctQuestionId-${id}`); - if (q) localStorage.setItem(langKey("sv", `correctQuestionId-${id}`), q); + const q = storage.getItem(`correctQuestionId-${id}`); + if (q) storage.setItem(langKey("sv", `correctQuestionId-${id}`), q); } } - const oldViews = localStorage.getItem("views"); - if (oldViews && !localStorage.getItem(langKey("sv", "views"))) { - localStorage.setItem(langKey("sv", "views"), oldViews); + const oldViews = storage.getItem("views"); + if (oldViews && !storage.getItem(langKey("sv", "views"))) { + storage.setItem(langKey("sv", "views"), oldViews); } - localStorage.setItem("i18n-migrated", "1"); + storage.setItem("i18n-migrated", "1"); }, []); // Reload written/correct questions when language changes useEffect(() => { setWrittenQuestions( - localStorage.getItem(langKey(lang, "writtenQuestions")) - ? JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions"))!) + storage.getItem(langKey(lang, "writtenQuestions")) + ? JSON.parse(storage.getItem(langKey(lang, "writtenQuestions"))!) : [] ); setCorrectQuestions( - localStorage.getItem(langKey(lang, "correctQuestions")) - ? JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) + storage.getItem(langKey(lang, "correctQuestions")) + ? JSON.parse(storage.getItem(langKey(lang, "correctQuestions"))!) : [] ); }, [lang]); @@ -148,7 +149,7 @@ function App() { if (!dbArrayBuffer) return; resetResult(); const SQL = await initSqlJs({ - locateFile: (file) => `/dist/sql.js/${file}`, + locateFile: (file) => asset(`dist/sql.js/${file}`), }); const db = new SQL.Database(new Uint8Array(dbArrayBuffer)); db.create_function("YEAR", (date: string) => new Date(date).getFullYear()); @@ -190,7 +191,7 @@ function App() { const resolved = getQuestion(q.id, questions); if (resolved) { setQuestion(resolved); - setQuery(localStorage.getItem(langKey(lang, "questionId-" + resolved.id)) || defaultQuery); + setQuery(storage.getItem(langKey(lang, "questionId-" + resolved.id)) || defaultQuery); } } } @@ -220,22 +221,22 @@ function App() { if (questionLangRef.current !== lang) { return; } - let wq = JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"); + let wq = JSON.parse(storage.getItem(langKey(lang, "writtenQuestions")) || "[]"); const initialLength = wq.length; if (query === defaultQuery || query === "") { - localStorage.removeItem(langKey(lang, "questionId-" + question.id)); + storage.removeItem(langKey(lang, "questionId-" + question.id)); // remove from writtenQuestions if it exists there as well const filtered = wq.filter((id: number) => id !== question.id); wq = filtered; } else { - localStorage.setItem(langKey(lang, "questionId-" + question.id), query); + storage.setItem(langKey(lang, "questionId-" + question.id), query); // ensure that questionid is in localstorage writtenQuestions if (!wq.includes(question.id)) { wq.push(question.id); } } if (wq.length !== initialLength) { - localStorage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(wq)); + storage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(wq)); setWrittenQuestions(wq); } @@ -275,13 +276,13 @@ function App() { } if (upsert) { - localStorage.setItem(langKey(lang, "views"), JSON.stringify(fetchedViews)); + storage.setItem(langKey(lang, "views"), JSON.stringify(fetchedViews)); } setViews(fetchedViews); // Recreate missing views from localStorage - const storedViews = localStorage.getItem(langKey(lang, "views")); + const storedViews = storage.getItem(langKey(lang, "views")); if (storedViews) { const savedViews: View[] = JSON.parse(storedViews); const missingViews = savedViews.filter( @@ -406,21 +407,21 @@ function App() { setIsCorrect(true); setMatchedResult(matchedAlt ?? question.evaluable_result); - localStorage.setItem(langKey(lang, `correctQuestionId-${question.id}`), query); + storage.setItem(langKey(lang, `correctQuestionId-${question.id}`), query); setCorrectQueryMismatch(false); setLoadedQuestionCorrect(true); - const cq = JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"); + const cq = JSON.parse(storage.getItem(langKey(lang, "correctQuestions")) || "[]"); if (!cq.includes(question.id)) { cq.push(question.id); - localStorage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(cq)); + storage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(cq)); setCorrectQuestions(cq); } }, [result, question, query, evaluatedQuery, exportingStatus, lang]); // Save query based on question const loadQuery = useCallback((_oldQuestion: Question | undefined, newQuestion: Question) => { - setQuery(localStorage.getItem(langKey(lang, "questionId-" + newQuestion.id)) || defaultQuery); + setQuery(storage.getItem(langKey(lang, "questionId-" + newQuestion.id)) || defaultQuery); // This prevents user from ctrl-z'ing to a different question if (editorRef.current) { editorRef.current!.session = {history: { stack: [], offset: 0 }}; @@ -433,7 +434,7 @@ function App() { return; } - const correctQuery = localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); + const correctQuery = storage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); if (!correctQuery) { setCorrectQueryMismatch(false); setLoadedQuestionCorrect(false); @@ -493,7 +494,7 @@ function App() { output += "/* --- BEGIN Validation --- */\n"; output += "/* --- BEGIN Submission Summary --- */\n"; - const writtenQueries = localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"; + const writtenQueries = storage.getItem(langKey(lang, "correctQuestions")) || "[]"; const parsed = JSON.parse(writtenQueries) as number[]; const questionsString = parsed.filter((id) => options === undefined || (options.include && options.include.includes(id))).map((id) => { const category = questions.find(c => c.questions.some(q => q.id === id))!; @@ -522,7 +523,7 @@ function App() { } output += "/* --- BEGIN Submission Queries --- */\n"; - const queriesStr = localStorage.getItem(langKey(lang, "correctQuestions")); + const queriesStr = storage.getItem(langKey(lang, "correctQuestions")); if (queriesStr) { const parsed = JSON.parse(queriesStr) as number[]; const sorted = parsed.filter((id) => options === undefined || (options.include && options.include.includes(id))).map((id) => { @@ -534,7 +535,7 @@ function App() { const questionQueries = sorted.map((id: number) => { const category = questions.find(c => c.questions.some(q => q.id === id))!; const q = category.questions.find(q => q.id === id)!; - const activeQuery = localStorage.getItem(langKey(lang, "correctQuestionId-" + id)); + const activeQuery = storage.getItem(langKey(lang, "correctQuestionId-" + id)); if (!activeQuery) { return ""; } @@ -556,7 +557,7 @@ function App() { output += "/* --- END Submission Queries --- */\n"; output += "/* --- BEGIN Save Summary --- */\n"; - const existingQueries = localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"; + const existingQueries = storage.getItem(langKey(lang, "writtenQuestions")) || "[]"; const existingParsed = JSON.parse(existingQueries) as number[]; const existingQuestions = existingParsed.map((id) => { const category = questions.find(c => c.questions.some(q => q.id === id))!; @@ -567,12 +568,12 @@ function App() { output += "/* --- END Save Summary --- */\n"; output += "/* --- BEGIN Raw Queries --- */\n"; output += "/*\n"; - const allQueries = localStorage.getItem(langKey(lang, "writtenQuestions")); + const allQueries = storage.getItem(langKey(lang, "writtenQuestions")); if (allQueries) { const parsed = JSON.parse(allQueries); const queries: { [key: number]: string } = {}; for (const id of parsed) { - const activeQuery = localStorage.getItem(langKey(lang, "questionId-" + id)); + const activeQuery = storage.getItem(langKey(lang, "questionId-" + id)); if (!activeQuery) { continue; } @@ -584,12 +585,12 @@ function App() { output += "/* --- END Raw Queries --- */\n"; output += "/* --- BEGIN Correct Raw Queries --- */\n"; output += "/*\n"; - const allCorrectQueries = localStorage.getItem(langKey(lang, "correctQuestions")); + const allCorrectQueries = storage.getItem(langKey(lang, "correctQuestions")); if (allCorrectQueries) { const parsed = JSON.parse(allCorrectQueries); const queries: { [key: number]: string } = {}; for (const id of parsed) { - const activeQuery = localStorage.getItem(langKey(lang, "correctQuestionId-" + id)); + const activeQuery = storage.getItem(langKey(lang, "correctQuestionId-" + id)); if (!activeQuery) { continue; } @@ -600,9 +601,9 @@ function App() { output += "\n*/\n"; output += "/* --- END Correct Raw Queries --- */\n"; output += "/* --- BEGIN Raw List Dumps --- */\n"; - output += "-- " + (localStorage.getItem(langKey(lang, "writtenQuestions")) === null ? "[]" : localStorage.getItem(langKey(lang, "writtenQuestions"))) + "\n"; - output += "-- " + (localStorage.getItem(langKey(lang, "correctQuestions")) === null ? "[]" : - JSON.stringify((JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions"))!) as number[]) + output += "-- " + (storage.getItem(langKey(lang, "writtenQuestions")) === null ? "[]" : storage.getItem(langKey(lang, "writtenQuestions"))) + "\n"; + output += "-- " + (storage.getItem(langKey(lang, "correctQuestions")) === null ? "[]" : + JSON.stringify((JSON.parse(storage.getItem(langKey(lang, "correctQuestions"))!) as number[]) .filter((id) => options === undefined || (options.include && options.include.includes(id)))) ) + "\n"; output += "/* --- END Raw List Dumps --- */\n"; @@ -629,28 +630,28 @@ function App() { const applyMergedData = useCallback((merged: ParsedSaveData) => { // Clear current data - const oldWritten: number[] = JSON.parse(localStorage.getItem(langKey(lang, "writtenQuestions")) || "[]"); - oldWritten.forEach(id => localStorage.removeItem(langKey(lang, `questionId-${id}`))); - const oldCorrect: number[] = JSON.parse(localStorage.getItem(langKey(lang, "correctQuestions")) || "[]"); - oldCorrect.forEach(id => localStorage.removeItem(langKey(lang, `correctQuestionId-${id}`))); - localStorage.removeItem(langKey(lang, "writtenQuestions")); - localStorage.removeItem(langKey(lang, "correctQuestions")); + const oldWritten: number[] = JSON.parse(storage.getItem(langKey(lang, "writtenQuestions")) || "[]"); + oldWritten.forEach(id => storage.removeItem(langKey(lang, `questionId-${id}`))); + const oldCorrect: number[] = JSON.parse(storage.getItem(langKey(lang, "correctQuestions")) || "[]"); + oldCorrect.forEach(id => storage.removeItem(langKey(lang, `correctQuestionId-${id}`))); + storage.removeItem(langKey(lang, "writtenQuestions")); + storage.removeItem(langKey(lang, "correctQuestions")); // Write merged data for (const [key, value] of Object.entries(merged.rawQueries)) { - localStorage.setItem(langKey(lang, `questionId-${key}`), value); + storage.setItem(langKey(lang, `questionId-${key}`), value); if (question !== undefined && Number(key) === question.id) { setQuery(value); } } for (const [key, value] of Object.entries(merged.correctQueries)) { - localStorage.setItem(langKey(lang, `correctQuestionId-${key}`), value); + storage.setItem(langKey(lang, `correctQuestionId-${key}`), value); } setWrittenQuestions(merged.writtenQuestionIds); setCorrectQuestions(merged.correctQuestionIds); - localStorage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(merged.writtenQuestionIds)); - localStorage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(merged.correctQuestionIds)); + storage.setItem(langKey(lang, "writtenQuestions"), JSON.stringify(merged.writtenQuestionIds)); + storage.setItem(langKey(lang, "correctQuestions"), JSON.stringify(merged.correctQuestionIds)); // Update views in database for (const view of views) { @@ -711,7 +712,7 @@ function App() { return; } - const toExportQuery = localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); + const toExportQuery = storage.getItem(langKey(lang, `correctQuestionId-${question.id}`)); if (!toExportQuery) { return; } @@ -894,7 +895,7 @@ function App() { variant="outline" onClick={() => { if (!question) return; - setQuery(localStorage.getItem(langKey(lang, `correctQuestionId-${question.id}`)) || defaultQuery); + setQuery(storage.getItem(langKey(lang, `correctQuestionId-${question.id}`)) || defaultQuery); }} className="border-yellow-500 text-yellow-600 dark:text-yellow-400 hover:bg-yellow-50 dark:hover:bg-yellow-900/20" > diff --git a/src/ChangelogDialog.tsx b/src/ChangelogDialog.tsx index 13e5bc1..b06d31b 100644 --- a/src/ChangelogDialog.tsx +++ b/src/ChangelogDialog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useLanguage } from "./i18n/context"; +import { storage } from "./storage"; import changelogRaw from "../CHANGELOG.md?raw"; interface ChangelogEntry { @@ -35,9 +36,9 @@ const ChangelogDialog = () => { useEffect(() => { if (entries.length === 0) return; - const lastSeen = localStorage.getItem(CHANGELOG_VERSION_KEY); + const lastSeen = storage.getItem(CHANGELOG_VERSION_KEY); if (!lastSeen) { - localStorage.setItem(CHANGELOG_VERSION_KEY, entries[0].date); + storage.setItem(CHANGELOG_VERSION_KEY, entries[0].date); } else if (lastSeen < entries[0].date) { setHasNew(true); } @@ -47,7 +48,7 @@ const ChangelogDialog = () => { setOpen(true); setHasNew(false); if (entries.length > 0) { - localStorage.setItem(CHANGELOG_VERSION_KEY, entries[0].date); + storage.setItem(CHANGELOG_VERSION_KEY, entries[0].date); } }; diff --git a/src/DatabaseLayoutDialog.tsx b/src/DatabaseLayoutDialog.tsx index e30ca61..1b4b6d4 100644 --- a/src/DatabaseLayoutDialog.tsx +++ b/src/DatabaseLayoutDialog.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Dialog, DialogContent } from "@/components/ui/dialog"; import { X } from "lucide-react"; import { useLanguage } from "./i18n/context"; +import { asset } from "./storage"; // Keep static imports as fallback for Swedish (backwards compat with existing SVGs) import dbLayoutDarkFallback from "./db_layout_dark.svg"; @@ -13,7 +14,7 @@ const DatabaseLayoutDialog = ({ isDarkMode }: { isDarkMode: () => boolean }) => const [open, setOpen] = useState(false); // Try language-specific ERD, fallback to static imports - const basePath = `/languages/${lang}`; + const basePath = asset(`languages/${lang}`); const darkSrc = `${basePath}/db_layout_dark.svg`; const lightSrc = `${basePath}/db_layout_light.svg`; const lightPngSrc = `${basePath}/db_layout_light_bg.png`; diff --git a/src/i18n/context.tsx b/src/i18n/context.tsx index a110792..cd8e047 100644 --- a/src/i18n/context.tsx +++ b/src/i18n/context.tsx @@ -1,6 +1,7 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; import { AVAILABLE_LANGUAGES, DEFAULT_LANGUAGE } from "./languages"; import { uiStrings } from "./ui-strings"; +import { asset, storage } from "../storage"; export interface QuestionCategory { category_id: number; @@ -45,7 +46,7 @@ function getInitialLanguage(): string { return urlLang; } // 2. localStorage - const stored = localStorage.getItem("language"); + const stored = storage.getItem("language"); if (stored && AVAILABLE_LANGUAGES.some(l => l.code === stored)) { return stored; } @@ -80,8 +81,8 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil const loadLanguageData = useCallback(async (langCode: string) => { try { const [qpResponse, dbResponse] = await Promise.all([ - fetch(`/languages/${langCode}/questionpool.json`), - fetch(`/languages/${langCode}/data.sqlite3`), + fetch(asset(`languages/${langCode}/questionpool.json`)), + fetch(asset(`languages/${langCode}/data.sqlite3`)), ]); if (!qpResponse.ok || !dbResponse.ok) { @@ -108,7 +109,7 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil const setLang = useCallback((newLang: string) => { if (!AVAILABLE_LANGUAGES.some(l => l.code === newLang)) return; - localStorage.setItem("language", newLang); + storage.setItem("language", newLang); updateUrlParam("lang", newLang); setLangState(newLang); }, []); diff --git a/src/mergeUtils.ts b/src/mergeUtils.ts index 78eaa99..14df07b 100644 --- a/src/mergeUtils.ts +++ b/src/mergeUtils.ts @@ -1,4 +1,5 @@ import { View } from "./ViewsTable"; +import { storage } from "./storage"; export interface ParsedSaveData { rawQueries: Record; @@ -84,25 +85,25 @@ export function parseImportFile(data: string): ParsedSaveData { export function getLocalData(langPrefix: string = "sv"): ParsedSaveData { const pfx = `${langPrefix}:`; const writtenQuestionIds: number[] = JSON.parse( - localStorage.getItem(`${pfx}writtenQuestions`) || "[]" + storage.getItem(`${pfx}writtenQuestions`) || "[]" ); const correctQuestionIds: number[] = JSON.parse( - localStorage.getItem(`${pfx}correctQuestions`) || "[]" + storage.getItem(`${pfx}correctQuestions`) || "[]" ); const rawQueries: Record = {}; for (const id of writtenQuestionIds) { - const q = localStorage.getItem(`${pfx}questionId-${id}`); + const q = storage.getItem(`${pfx}questionId-${id}`); if (q) rawQueries[String(id)] = q; } const correctQueries: Record = {}; for (const id of correctQuestionIds) { - const q = localStorage.getItem(`${pfx}correctQuestionId-${id}`); + const q = storage.getItem(`${pfx}correctQuestionId-${id}`); if (q) correctQueries[String(id)] = q; } - const views: View[] = JSON.parse(localStorage.getItem(`${pfx}views`) || "[]"); + const views: View[] = JSON.parse(storage.getItem(`${pfx}views`) || "[]"); return { rawQueries, correctQueries, writtenQuestionIds, correctQuestionIds, views, language: langPrefix }; } diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..66ba079 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,31 @@ +// Deploy-base-aware storage and asset helpers. +// +// The app is served both at the site root ("/") and as immutable, dated +// "compatibility" builds under "/compatibility//". On Cloudflare +// Pages these all share one origin, so without namespacing they would share the +// same localStorage and fetch each other's data. Deriving a key prefix and an +// asset prefix from the Vite base path keeps every build isolated: +// base "/" -> prefix "" (unchanged; existing users keep their data) +// base "/compatibility/2026-07-04/" -> prefix "compatibility:2026-07-04:" + +/** Vite base path; always starts and ends with "/" (e.g. "/" or "/compatibility/2026-07-04/"). */ +export const BASE_URL: string = import.meta.env.BASE_URL || "/"; + +/** localStorage key prefix derived from the base path ("" at the site root). */ +export const STORAGE_PREFIX: string = BASE_URL.replace(/^\/+|\/+$/g, "").replace(/\//g, ":"); + +const withPrefix = (key: string): string => + STORAGE_PREFIX ? `${STORAGE_PREFIX}:${key}` : key; + +/** localStorage wrapper that namespaces every key by the deploy base path. */ +export const storage = { + getItem: (key: string): string | null => localStorage.getItem(withPrefix(key)), + setItem: (key: string, value: string): void => localStorage.setItem(withPrefix(key), value), + removeItem: (key: string): void => localStorage.removeItem(withPrefix(key)), + has: (key: string): boolean => withPrefix(key) in localStorage, +}; + +/** Resolve a public asset path against the deploy base so dated builds load their own frozen assets. */ +export function asset(path: string): string { + return BASE_URL + path.replace(/^\/+/, ""); +} diff --git a/src/useTheme.tsx b/src/useTheme.tsx index 01a4341..7dec37a 100644 --- a/src/useTheme.tsx +++ b/src/useTheme.tsx @@ -1,11 +1,12 @@ // useTheme.ts import { useState, useEffect, useCallback } from "react"; +import { storage } from "./storage"; const useTheme = () => { const [theme, setThemeState] = useState<"light" | "dark" | "system">("system"); useEffect(() => { - const storedTheme = localStorage.theme as "light" | "dark" | "system"; + const storedTheme = storage.getItem("theme") as "light" | "dark" | "system"; if (storedTheme) { setThemeState(storedTheme); } else if (window.matchMedia("(prefers-color-scheme: dark)").matches) { @@ -15,7 +16,7 @@ const useTheme = () => { } const applyTheme = () => { - const isDark = localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches); + const isDark = storage.getItem("theme") === "dark" || (!storage.has("theme") && window.matchMedia("(prefers-color-scheme: dark)").matches); if (isDark) { document.documentElement.classList.add("dark"); } else { @@ -43,9 +44,9 @@ const useTheme = () => { const setTheme = (theme: "light" | "dark" | "system") => { setThemeState(theme); if (theme === "system") { - localStorage.removeItem("theme"); + storage.removeItem("theme"); } else { - localStorage.theme = theme; + storage.setItem("theme", theme); } const isDark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); diff --git a/vite.config.ts b/vite.config.ts index 7a3a673..ca0177e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,10 @@ import { nodePolyfills } from "vite-plugin-node-polyfills"; import path from "path"; // https://vite.dev/config/ +// APP_BASE is set per compatibility snapshot by scripts/build-deploy.mjs so each +// dated build is served (and isolated) under /compatibility//. Unset = "/". export default defineConfig({ + base: process.env.APP_BASE || "/", plugins: [ react(), nodePolyfills({