From 8313d62ac77f60bd185b9f8f2b8c25a360e77f0f Mon Sep 17 00:00:00 2001 From: Alex Warren Date: Sat, 8 Aug 2026 09:14:54 +0100 Subject: [PATCH] feat(AppShell): match Play tab to textadventures.co.uk's catalog/search/gamebook API Adds gamebook/language badges to every game card, a paginated category browser and free-text search wired to the new Api/Category and Api/Search endpoints, and a "Browse category" dropdown next to search (ordered the same way the backend now orders categories: descending game count). Also relocates the "Open a game file"/Recently Played flow to its own /play/local page (Quest 5's side-by-side layout), reached via a compact link, so the main Play tab reads as one continuous online-catalog-browsing flow. Co-Authored-By: Claude Sonnet 5 --- docs/electron-desktop-app.md | 2 +- src/AppShell/src/components/GameCard.svelte | 41 +++ src/AppShell/src/components/GamesPager.svelte | 21 ++ .../src/components/PlayCatalog.svelte | 331 +++--------------- src/AppShell/src/lib/electron-types.d.ts | 8 +- .../src/lib/filesystem/electron-adapter.ts | 8 +- src/AppShell/src/lib/home-catalog.ts | 59 ++++ src/AppShell/src/routes/+layout.svelte | 8 +- .../src/routes/play/[id]/+page.svelte | 12 +- .../routes/play/category/[slug]/+page.svelte | 80 +++++ .../src/routes/play/local/+page.svelte | 286 +++++++++++++++ .../src/routes/play/search/+page.svelte | 102 ++++++ src/ElectronApp/src/ipc/player.ts | 4 +- src/ElectronApp/src/main.ts | 18 +- src/ElectronApp/src/preload.ts | 4 +- src/WasmPlayer/wasm-player.js | 6 +- 16 files changed, 679 insertions(+), 311 deletions(-) create mode 100644 src/AppShell/src/components/GameCard.svelte create mode 100644 src/AppShell/src/components/GamesPager.svelte create mode 100644 src/AppShell/src/routes/play/category/[slug]/+page.svelte create mode 100644 src/AppShell/src/routes/play/local/+page.svelte create mode 100644 src/AppShell/src/routes/play/search/+page.svelte diff --git a/docs/electron-desktop-app.md b/docs/electron-desktop-app.md index e3c3548cb..d2575ce46 100644 --- a/docs/electron-desktop-app.md +++ b/docs/electron-desktop-app.md @@ -70,7 +70,7 @@ electron/ - Editor window: loads the AppShell SPA over a local loopback HTTP server (`src/ElectronApp/src/static-server.ts`) — not `file://` (fetch of `.wasm`/`.dll` assets over `file://` hits CORS/mime-type issues in Chromium) and not a custom protocol. The server binds `127.0.0.1:0` (random free port, same trick as LocalPlayer's own port selection below) and serves the same three-directory layout `deploy-play.yml` already produces for play.questviva.com — `editor/` (AppShell build) at `/`, `AppBundle/` (WasmEditor) at `/AppBundle/`, `player/` (WasmPlayer, Phase 1) or LocalPlayer (Phase 2) at `/player/`. - Player windows: Phase 1 navigates a second `BrowserWindow` to `http://127.0.0.1:{port}/player/` (bundled WasmPlayer); Phase 2 switches this to LocalPlayer's `http://localhost:{port}/?game={encodedPath}`. - Phase 1's Preview button needs no IPC round-trip at all: `previewInWasmPlayer()` (`editor-store.ts`) already does a plain `window.open('/player/?source=editor', ...)` and talks to it over `BroadcastChannel('quest-preview')` — both work unchanged against the loopback origin. `editorWindow.webContents.setWindowOpenHandler` in `main.ts` just needs to `{ action: "allow" }` same-origin `/player/` popups (and send anything else to `shell.openExternal`) for that `window.open` call to become the player `BrowserWindow`. -- **File associations**: `package.json`'s `build.fileAssociations` registers `.aslx` (role `Editor`) and `.quest`/`.asl`/`.cas` (role `Viewer`) with the OS, so double-clicking one of these launches or focuses the app. `.aslx` routes to the editor (`/open?action=open-recent&...`, reusing the same query shape as the native "Open Recent" submenu); `.quest`/`.asl`/`.cas` route to `PlayCatalog.svelte`'s Play flow (`/?action=play-file&...`). `app.requestSingleInstanceLock()` plus a `second-instance` handler in `main.ts` make a second double-click while the app is already running focus the existing window instead of spawning another — Windows/Linux pass the file path via argv (cold start: `process.argv`; already-running: the `second-instance` event's argv), macOS via the `open-file` app event (registered before `whenReady()`, since it can fire before `ready`). A cold-start open is folded straight into the editor window's initial URL rather than sent over IPC, since a `webContents.send()` right after constructing the window would race the page's own `onMount` listeners and get dropped; an already-running instance uses IPC (`open-recent-game` / the new `open-play-file` channel) since those listeners are already wired up. +- **File associations**: `package.json`'s `build.fileAssociations` registers `.aslx` (role `Editor`) and `.quest`/`.asl`/`.cas` (role `Viewer`) with the OS, so double-clicking one of these launches or focuses the app. `.aslx` routes to the editor (`/open?action=open-recent&...`, reusing the same query shape as the native "Open Recent" submenu); `.quest`/`.asl`/`.cas` route to `play/local/+page.svelte`'s Play flow (`/play/local?action=play-file&...`). `app.requestSingleInstanceLock()` plus a `second-instance` handler in `main.ts` make a second double-click while the app is already running focus the existing window instead of spawning another — Windows/Linux pass the file path via argv (cold start: `process.argv`; already-running: the `second-instance` event's argv), macOS via the `open-file` app event (registered before `whenReady()`, since it can fire before `ready`). A cold-start open is folded straight into the editor window's initial URL rather than sent over IPC, since a `webContents.send()` right after constructing the window would race the page's own `onMount` listeners and get dropped; an already-running instance uses IPC (`open-recent-game` / the new `open-play-file` channel) since those listeners are already wired up. ### contextBridge API (`preload.ts`) diff --git a/src/AppShell/src/components/GameCard.svelte b/src/AppShell/src/components/GameCard.svelte new file mode 100644 index 000000000..e3dd8b6a8 --- /dev/null +++ b/src/AppShell/src/components/GameCard.svelte @@ -0,0 +1,41 @@ + + + +
+ {#if game.cover || game.thumbnail} + + {/if} +
+
+
{game.name}
+ {#if game.author} +
by {game.author}
+ {/if} +
+ + {game.isGamebook ? "Gamebook" : "Text Adventure"} + + {#if game.language !== "en"} + + {languageName(game.language)} + + {/if} +
+ {#if game.rating > 0} +
{ratingStars(game.rating)}
+ {/if} +
+
diff --git a/src/AppShell/src/components/GamesPager.svelte b/src/AppShell/src/components/GamesPager.svelte new file mode 100644 index 000000000..81f6cd122 --- /dev/null +++ b/src/AppShell/src/components/GamesPager.svelte @@ -0,0 +1,21 @@ + + +{#if pageCount > 1} +
+ + Page {page} of {pageCount} + +
+{/if} diff --git a/src/AppShell/src/components/PlayCatalog.svelte b/src/AppShell/src/components/PlayCatalog.svelte index 4a31a6cff..751b300be 100644 --- a/src/AppShell/src/components/PlayCatalog.svelte +++ b/src/AppShell/src/components/PlayCatalog.svelte @@ -1,13 +1,12 @@ +
+
+ ← Back to Play + + {#if loading} +
+
+
+ {:else if error} +
+

Couldn't load this category.

+ +
+ {:else} +

+ {title} + ({totalCount}) +

+
+ {#each games as game (game.id)} + + {/each} +
+ + {/if} +
+
diff --git a/src/AppShell/src/routes/play/local/+page.svelte b/src/AppShell/src/routes/play/local/+page.svelte new file mode 100644 index 000000000..0671352bc --- /dev/null +++ b/src/AppShell/src/routes/play/local/+page.svelte @@ -0,0 +1,286 @@ + + + +
+
+ ← Back to Play + + +
+
+ {#if isElectronApp} + + {#if electronError} +

{electronError}

+ {/if} + {:else if !pickedFile} + + {:else} +
+ {pickedFile.name} + + +
+ {/if} + {#if !isElectronApp && pickError} +

{pickError}

+ {/if} + {#if !isElectronApp && startError} +

{startError}

+ {/if} +
+ + {#if isElectronApp && recentPlayed.length > 0} +
+

Recently played

+
+ {#each recentPlayed as game (game.dirPath + "/" + game.filename)} +
+ + +
+ {/each} +
+
+ {/if} +
+
+
diff --git a/src/AppShell/src/routes/play/search/+page.svelte b/src/AppShell/src/routes/play/search/+page.svelte new file mode 100644 index 000000000..b6c6776cf --- /dev/null +++ b/src/AppShell/src/routes/play/search/+page.svelte @@ -0,0 +1,102 @@ + + + +
+
+ ← Back to Play + +
+ + +
+ + {#if loading} +
+
+
+ {:else if error} +
+

Couldn't load search results.

+ +
+ {:else if searched} +

{totalCount} result{totalCount === 1 ? "" : "s"}

+ {#if games.length === 0} +

No games found.

+ {:else} +
+ {#each games as game (game.id)} + + {/each} +
+ + {/if} + {/if} +
+
diff --git a/src/ElectronApp/src/ipc/player.ts b/src/ElectronApp/src/ipc/player.ts index 56884ae7c..61bd258ae 100644 --- a/src/ElectronApp/src/ipc/player.ts +++ b/src/ElectronApp/src/ipc/player.ts @@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow, dialog, shell } from "electron"; export interface PlayerWindowRequest { // Catalog game (textadventures.co.uk id) vs. a locally-picked file whose - // bytes/resource-request handling PlayCatalog.svelte hands over the + // bytes/resource-request handling play/local/+page.svelte hands over the // BroadcastChannel once this window signals 'ready' — see wasm-player.js's // `source=local` boot branch. id?: string; @@ -17,7 +17,7 @@ export interface PlayerWindowRequest { // executed via eval (see wasm-player.js's WebPlayer.runJs) — an untrusted // trust boundary that must never get window.electronApp's fs/dialog bridge. // A locally-picked game's sibling resources are instead resolved by -// PlayCatalog.svelte (which *does* have fs access) answering +// play/local/+page.svelte (which *does* have fs access) answering // 'resource-request' messages over the same BroadcastChannel used to hand // over the initial game bytes, exactly like the existing editor-preview path. export function registerPlayerHandlers(getOrigin: () => string | null): void { diff --git a/src/ElectronApp/src/main.ts b/src/ElectronApp/src/main.ts index 6c69c427f..b1172735e 100644 --- a/src/ElectronApp/src/main.ts +++ b/src/ElectronApp/src/main.ts @@ -192,9 +192,9 @@ function sendOpenRecentGame(game: RecentGame): void { } // Same delivery as sendOpenRecentGame, for a file-association open of a -// play-kind file (.quest/.asl/.cas) — PlayCatalog.svelte's onOpenPlayFile -// listener (see preload.ts) launches a player window for it, the same as -// its own file-picker/Recently Played flows. +// play-kind file (.quest/.asl/.cas) — play/local/+page.svelte's +// onOpenPlayFile listener (see preload.ts) launches a player window for it, +// the same as its own file-picker/Recently Played flows. function sendOpenPlayFile(file: { dirPath: string; filename: string }): void { focusEditorWindow(); editorWindow?.webContents.send("open-play-file", file); @@ -202,9 +202,9 @@ function sendOpenPlayFile(file: { dirPath: string; filename: string }): void { // .aslx opens the editor (it's the unpacked source format the editor works // with); .quest/.asl/.cas launch the player directly — matches the split -// PlayCatalog.svelte/electron-adapter.ts already draw between the editor's -// Open (ASLX_FILTER, .aslx only) and Play's file picker (PLAY_FILTER, all -// four). +// play/local/+page.svelte/electron-adapter.ts already draw between the +// editor's Open (ASLX_FILTER, .aslx only) and Play's file picker +// (PLAY_FILTER, all four). const PLAY_EXTENSIONS = new Set([".quest", ".asl", ".cas"]); const GAME_EXTENSIONS = new Set([".aslx", ...PLAY_EXTENSIONS]); @@ -244,8 +244,8 @@ function routeOpenedFile(filePath: string): void { // URL rather than delivered over IPC (see routeOpenedFile's comment) — both // query shapes are already handled by the target page: /open?action= // open-recent&... by open/+page.svelte (shared with the native "Open Recent" -// menu), /?action=play-file&... by PlayCatalog.svelte (new, mirrors it for -// Play). +// menu), /play/local?action=play-file&... by play/local/+page.svelte +// (mirrors it for Play). function initialUrlPath(filePath: string | null): string { if (!filePath) return "/"; const ext = path.extname(filePath).toLowerCase(); @@ -256,7 +256,7 @@ function initialUrlPath(filePath: string | null): string { return `/open?action=open-recent&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`; } if (PLAY_EXTENSIONS.has(ext)) { - return `/?action=play-file&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`; + return `/play/local?action=play-file&dir=${encodeURIComponent(dirPath)}&file=${encodeURIComponent(filename)}&t=${t}`; } return "/"; } diff --git a/src/ElectronApp/src/preload.ts b/src/ElectronApp/src/preload.ts index 01b3de14a..a417dbcc3 100644 --- a/src/ElectronApp/src/preload.ts +++ b/src/ElectronApp/src/preload.ts @@ -127,8 +127,8 @@ contextBridge.exposeInMainWorld("electronApp", { // Fired when the OS launches or relaunches the app via a play-kind // file association (.quest/.asl/.cas) while a window already exists // — main.ts's routeOpenedFile sends this instead of focusing straight - // into a player window, since PlayCatalog.svelte needs to load the - // file's bytes first (see its onOpenPlayFile listener). Cold-start + // into a player window, since play/local/+page.svelte needs to load + // the file's bytes first (see its onOpenPlayFile listener). Cold-start // opens don't use this channel at all — see main.ts's initialUrlPath. onOpenPlayFile: (callback: (file: { dirPath: string; filename: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, file: { dirPath: string; filename: string }) => callback(file); diff --git a/src/WasmPlayer/wasm-player.js b/src/WasmPlayer/wasm-player.js index 1dfe73b8f..fb27fb4db 100644 --- a/src/WasmPlayer/wasm-player.js +++ b/src/WasmPlayer/wasm-player.js @@ -1713,7 +1713,7 @@ function showFileProtocolError() { // The AppShell tab that was going to hand over game bytes (see the // `source=local` boot branch below) turned out not to be there to answer — // closed, navigated away, or superseded by a newer Start click in that same -// tab (see PlayCatalog.svelte's handleStart, which closes its previous +// tab (see play/local/+page.svelte's handleBrowserStart, which closes its previous // channel before opening a new one). Without this, that's a silent infinite // loading spinner with no way out; this at least gets the user back to a // working picker in the same tab. @@ -1897,7 +1897,7 @@ async function fetchGameBytes(url) { return; } - // AppShell's Play tab (see PlayCatalog.svelte) — the user already picked + // AppShell's Play tab (see play/local/+page.svelte) — the user already picked // a file and clicked Start (browser build) or the window just got opened // straight from the file picker (Electron, see ipc/player.ts), so the // game bytes are sitting in that tab's memory. Same handoff as the editor @@ -1905,7 +1905,7 @@ async function fetchGameBytes(url) { // build a raw picked File has nothing to answer those with (so they just // go unanswered — fine for a self-contained .quest package, which is all // the plain file-input path in wireStartScreen() below ever supported - // either), but Electron's PlayCatalog.svelte backs the picked file with a + // either), but Electron's play/local/+page.svelte backs the picked file with a // real ElectronFileAdapter and answers them from disk, exactly like // editor-store.ts's previewInWasmPlayer does for the live editor. A // distinct channel name keeps this from cross-talking with a real