From b83079cfeffd9fb9d3c0f834b87c2fb75e00d3bc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:15:10 +0000 Subject: [PATCH 1/4] chore(web): wire svelte-check module/global resolution for type safety - Add real @std/* JSR types, zod, ts-pattern and @types/deno to web deps so svelte-check can resolve Deno-style imports and the Deno global - Add tsconfig.svelte.json mapping @forager/core and ~/* to core source while excluding the core package from the reported fileset - Declare App.Locals (forager, config) in app.d.ts - Remove unused kit.experimental.remoteFunctions from svelte.config.js - Point check scripts at tsconfig.svelte.json Co-authored-by: andykais-claude --- deno.lock | 94 +++++++++++++++++++++++++++++-- packages/web/package.json | 16 +++++- packages/web/src/app.d.ts | 8 ++- packages/web/svelte.config.js | 3 - packages/web/tsconfig.svelte.json | 36 ++++++++++++ 5 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 packages/web/tsconfig.svelte.json diff --git a/deno.lock b/deno.lock index e55205d4..c3467fd0 100644 --- a/deno.lock +++ b/deno.lock @@ -67,6 +67,13 @@ "jsr:@torm/sqlite@^1.9.7": "1.9.7", "npm:@deno/vite-plugin@1.0.6": "1.0.6_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "npm:@jsr/andykais__ts-rpc@~0.2.4": "0.2.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", + "npm:@jsr/std__datetime@~0.225.3": "0.225.7", + "npm:@jsr/std__fmt@0.225.6": "0.225.6", + "npm:@jsr/std__fs@0.229.3": "0.229.3", + "npm:@jsr/std__log@~0.224.6": "0.224.14", + "npm:@jsr/std__media-types@0.224.0": "0.224.0", + "npm:@jsr/std__path@1.0.3": "1.0.3", + "npm:@jsr/std__yaml@^1.0.5": "1.1.2", "npm:@jsr/torm__sqlite@^1.9.7": "1.9.7", "npm:@opentelemetry/api@1.9.0": "1.9.0", "npm:@opentelemetry/api@^1.9.0": "1.9.0", @@ -74,11 +81,13 @@ "npm:@sveltejs/kit@2.5.24": "2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "npm:@tailwindcss/vite@4.1.14": "4.1.14_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", + "npm:@types/deno@^2.5.0": "2.7.0", "npm:@types/node@*": "24.2.0", "npm:esbuild@*": "0.25.10", "npm:path-to-regexp@6.2.1": "6.2.1", "npm:sv@*": "0.12.5", "npm:sv@latest": "0.12.5", + "npm:svelte-check@*": "4.3.2_svelte@5.39.8__acorn@8.15.0_typescript@5.9.3", "npm:svelte-check@4.3.2": "4.3.2_svelte@5.39.8__acorn@8.15.0_typescript@5.9.3", "npm:svelte@5.39.8": "5.39.8_acorn@8.15.0", "npm:svelte@~5.39.8": "5.39.8_acorn@8.15.0", @@ -574,7 +583,7 @@ "@jsr/std__assert@0.226.0": { "integrity": "sha512-xCuUFDfHkIZd96glKgjZbnYFqu6blu8Y53SyvDMlFDJm1Y/j+/FcW6xq7TzGFIaF5B9QecIlDfamfhzA8ZdVbg==", "dependencies": [ - "@jsr/std__internal@1.0.10" + "@jsr/std__internal@1.0.14" ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__assert/0.226.0.tgz" }, @@ -628,6 +637,10 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__crypto/0.224.0.tgz" }, + "@jsr/std__datetime@0.225.7": { + "integrity": "sha512-z1N0Ompk9zzcrwExq2uMxGAvnXDjV9W9m5Lun+OE27/Y2F+jvDWdZ766a28h+U3XKKoDq0hByYVuhOZ7pzWuhw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__datetime/0.225.7.tgz" + }, "@jsr/std__encoding@0.223.0": { "integrity": "sha512-auQ0wUZFsVGXU+6qfjGeonWe8utBhp+y43etR1lanCJXXUzL5W3yS+i73Fki+RLjvxUG4Pwd1CrGSR57kIvrIg==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__encoding/0.223.0.tgz" @@ -652,6 +665,25 @@ "integrity": "sha512-5wll7CImyrJfen4fecziNSxZ5S6Fo3H1rQqKMQDewRGHAnFVqcHLe8wwCuDLK+HTbSPy6AkR3b136WpjVdOjTQ==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/0.225.6.tgz" }, + "@jsr/std__fmt@1.0.10": { + "integrity": "sha512-Q2N8XDNkh/c1IKTi+sO4Vm64M5m4W0o47qTURmMX0vgHm9AJ6jL7jEQ2kw0M1T2rBZiwAY78eUbYzFyOYWaCqQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.10.tgz" + }, + "@jsr/std__fs@0.229.3": { + "integrity": "sha512-hXsLcP1dumz3VbRKVhPtzs7Wigxhqp2rtBzwzrF9Cz9IMqZkp7EoKHxeqcUiduf7yYxVV/vbnfhjqbOLvlljjA==", + "dependencies": [ + "@jsr/std__path@1.0.0-rc.1" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/0.229.3.tgz" + }, + "@jsr/std__fs@1.0.24": { + "integrity": "sha512-UunW2Rg++9sA74NJnYMGTsMVQgoFRAwPP02wRRohL0PQBNorbyxHiyCMRaxYQCtLepTLkC4nUyMtcwrGKqTR6g==", + "dependencies": [ + "@jsr/std__internal@1.0.14", + "@jsr/std__path@1.1.6" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__fs/1.0.24.tgz" + }, "@jsr/std__http@0.223.0": { "integrity": "sha512-v60Hv8J1GXM1ExsJgBXVcmhbyt4Ip3RFaIdkVrycujbBL5qFuhNnvkmggAonP+nwXy5V0crU/tkLNWTyncoPXQ==", "dependencies": [ @@ -687,9 +719,9 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/0.224.0.tgz" }, - "@jsr/std__internal@1.0.10": { - "integrity": "sha512-fmD6yKep/sMnB2yPQU/REZG7Z4N9SZwcUBNnceo4QkXk67l3JEfxHoROQ/YHeVSOmq6x55Ra6nuMjz2ib3nj3g==", - "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.10.tgz" + "@jsr/std__internal@1.0.14": { + "integrity": "sha512-JT8b/t40WcR9q0GDwRZUooY7aXeXnFal8iidE/5TckdAFtvpVAsaJ71/Xgf1SfoChs41s6CZkuCYM8toaNgdvA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.14.tgz" }, "@jsr/std__io@0.223.0": { "integrity": "sha512-VUPpfHeLvhooQFyujLSj6/KDCqgF+joBpb6eO9dg+JJWvy5wHj1Nl2EMRQe6Pl8nNVUwjt+ZraydkGTdlDS1jw==", @@ -706,10 +738,30 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__io/0.224.9.tgz" }, + "@jsr/std__io@0.225.3": { + "integrity": "sha512-IDXY253ipW6FV34CJVxO+3ubfvSEEzw9N2W303KnLe9K/Y9+v/ID1dQYf9VsCCOFMpFtCmOLqzIZsRqv6yQnWw==", + "dependencies": [ + "@jsr/std__bytes@1.0.6" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__io/0.225.3.tgz" + }, + "@jsr/std__log@0.224.14": { + "integrity": "sha512-EHT7E0plakyzk/gxMrwqUf3YGCCxN3Is25QrEh7toYA7qwj46R4qY7cIaDEKy8QqI5JHOFHwWXOClcPK6goIoQ==", + "dependencies": [ + "@jsr/std__fmt@1.0.10", + "@jsr/std__fs@1.0.24", + "@jsr/std__io@0.225.3" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__log/0.224.14.tgz" + }, "@jsr/std__media-types@0.223.0": { "integrity": "sha512-wJe0dRZiL1OqU8oGXReVuMK5stHERyGgpR/6yS4qW9+p74+XXSIP+OGubfzl8TjeKeULd82zLbk1QNo5FvwXNw==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__media-types/0.223.0.tgz" }, + "@jsr/std__media-types@0.224.0": { + "integrity": "sha512-ua4EvwuGmFFB0KREfJGROd+EeVxGE6IwNJnfP4jHGyIuH7JLxX78r1cNbPxVjTrNso2GbjFdBQ2pKcyPQOOhUQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__media-types/0.224.0.tgz" + }, "@jsr/std__media-types@0.224.1": { "integrity": "sha512-L3kF5zNOA0KNibbdKwxmjaHUOpa7vWTb6BNwv41QZEjOtmS8GWPTRAXkglLEUfu2gl6pXlOoT3BmXv/naO5tKA==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__media-types/0.224.1.tgz" @@ -729,10 +781,25 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/0.223.0.tgz" }, + "@jsr/std__path@1.0.0-rc.1": { + "integrity": "sha512-bGolWCcaF8HGT0QVZr/heEaHm6MEgpOI7LyrV4JLuBYrLb81FAX7m3Li77JnF06RNhJViY8K7zvPpuVIiakyvA==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.0.0-rc.1.tgz" + }, "@jsr/std__path@1.0.0-rc.2": { "integrity": "sha512-qUHfVMqAtMGPFmdkOfmkCONqgpe4UFVAaD+wL6ZdlQxtvQiNxHQWsje+djri+0095s/kkoBOPH4CpYxBq+zh3A==", "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.0.0-rc.2.tgz" }, + "@jsr/std__path@1.0.3": { + "integrity": "sha512-XH7cE38WHq4J3ICVKrVs0GvKf9EXfMxAzvXkVewHXJeyh7TW/bN47yIPArDQUybHlyLkTQvQaDW0hWDY3TP2xQ==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.0.3.tgz" + }, + "@jsr/std__path@1.1.6": { + "integrity": "sha512-H1Cmg6z8jFyIsQbVV+vZB4eOx4k6Qg8lyUM6l7nZysj5MZumg89kb0M1bsPBTGQis3fw6mJFkJELo5+AK6oCFA==", + "dependencies": [ + "@jsr/std__internal@1.0.14" + ], + "tarball": "https://npm.jsr.io/~/11/@jsr/std__path/1.1.6.tgz" + }, "@jsr/std__streams@0.223.0": { "integrity": "sha512-YfOfRTpnTocgC5h22aRMgu74ByBO6763PgwG2p7iFWYZFBRrDNW6WqYK1MFXcbU8VrwlgpUEvkS3Xk9dJnd+hw==", "dependencies": [ @@ -750,6 +817,10 @@ ], "tarball": "https://npm.jsr.io/~/11/@jsr/std__streams/0.224.5.tgz" }, + "@jsr/std__yaml@1.1.2": { + "integrity": "sha512-R2U09+AG6VgFet8qmVhkFw1eKQKeG54RAejd64G5F0XrzMFmHp6KOMnabOdSowd7kj28wFt/x4XT2ARGZEEeVw==", + "tarball": "https://npm.jsr.io/~/11/@jsr/std__yaml/1.1.2.tgz" + }, "@jsr/torm__sqlite@1.9.7": { "integrity": "sha512-naligACB12UtWD43fd0QtUCIgNotPoiib3r/qFHAMj0Hy2RxapfR24dSH2U5gHQM4DSBCt2/sqwcjojY2T3SMw==", "dependencies": [ @@ -1060,6 +1131,9 @@ "@types/cookie@0.6.0": { "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "@types/deno@2.7.0": { + "integrity": "sha512-Y6fWcV8KpYeO3Lik/RiYnUxtr/LWqoeACciU0CA+dr1U/45tGgaX3HWQQAPCNN53raN4Rr4Fht4y6qsUH57fDA==" + }, "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, @@ -1549,17 +1623,27 @@ "dependencies": [ "npm:@deno/vite-plugin@1.0.6", "npm:@jsr/andykais__ts-rpc@~0.2.4", + "npm:@jsr/std__datetime@~0.225.3", + "npm:@jsr/std__fmt@0.225.6", + "npm:@jsr/std__fs@0.229.3", + "npm:@jsr/std__log@~0.224.6", + "npm:@jsr/std__media-types@0.224.0", + "npm:@jsr/std__path@1.0.3", + "npm:@jsr/std__yaml@^1.0.5", "npm:@jsr/torm__sqlite@^1.9.7", "npm:@opentelemetry/api@1.9.0", "npm:@sveltejs/kit@2.43.7", "npm:@sveltejs/vite-plugin-svelte@6.2.1", "npm:@tailwindcss/vite@4.1.14", + "npm:@types/deno@^2.5.0", "npm:svelte-check@4.3.2", "npm:svelte@5.39.8", "npm:tailwindcss@4.1.14", + "npm:ts-pattern@5.2.0", "npm:typescript-svelte-plugin@0.3.50", "npm:typescript@5.9.3", - "npm:vite@7.1.8" + "npm:vite@7.1.8", + "npm:zod@4.1.11" ] } }, diff --git a/packages/web/package.json b/packages/web/package.json index 35993e91..a433f4ac 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,19 +8,29 @@ "build": "vite build", "build:local": "LOCAL_BUILD=true vite build", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.svelte.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.svelte.json --watch" }, "dependencies": { "@andykais/ts-rpc": "npm:@jsr/andykais__ts-rpc@^0.2.4", "@opentelemetry/api": "1.9.0", - "@torm/sqlite": "npm:@jsr/torm__sqlite@^1.9.7" + "@std/datetime": "npm:@jsr/std__datetime@^0.225.3", + "@std/fmt": "npm:@jsr/std__fmt@0.225.6", + "@std/fs": "npm:@jsr/std__fs@0.229.3", + "@std/log": "npm:@jsr/std__log@^0.224.6", + "@std/media-types": "npm:@jsr/std__media-types@0.224.0", + "@std/path": "npm:@jsr/std__path@1.0.3", + "@std/yaml": "npm:@jsr/std__yaml@^1.0.5", + "@torm/sqlite": "npm:@jsr/torm__sqlite@^1.9.7", + "ts-pattern": "npm:ts-pattern@5.2.0", + "zod": "npm:zod@4.1.11" }, "devDependencies": { "@deno/vite-plugin": "1.0.6", "@sveltejs/kit": "2.43.7", "@sveltejs/vite-plugin-svelte": "6.2.1", "@tailwindcss/vite": "4.1.14", + "@types/deno": "^2.5.0", "svelte": "5.39.8", "svelte-check": "4.3.2", "tailwindcss": "4.1.14", diff --git a/packages/web/src/app.d.ts b/packages/web/src/app.d.ts index da08e6da..591e5d15 100644 --- a/packages/web/src/app.d.ts +++ b/packages/web/src/app.d.ts @@ -1,9 +1,15 @@ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces +import type { Forager } from '@forager/core' +import type { Config } from '$lib/server/config.ts' + declare global { namespace App { // interface Error {} - // interface Locals {} + interface Locals { + forager: Forager + config: Config + } // interface PageData {} // interface PageState {} // interface Platform {} diff --git a/packages/web/svelte.config.js b/packages/web/svelte.config.js index 02299a17..02a9423f 100644 --- a/packages/web/svelte.config.js +++ b/packages/web/svelte.config.js @@ -9,9 +9,6 @@ const config = { kit: { adapter: adapter(), - experimental: { - remoteFunctions: true, - }, prerender: { crawl: false, entries: [] diff --git a/packages/web/tsconfig.svelte.json b/packages/web/tsconfig.svelte.json new file mode 100644 index 00000000..6d534dfa --- /dev/null +++ b/packages/web/tsconfig.svelte.json @@ -0,0 +1,36 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler", + + "plugins": [{ + "name": "typescript-svelte-plugin", + "enabled": true, + "assumeIsSvelteProject": true + }], + + "allowImportingTsExtensions": true, + + "baseUrl": ".", + "paths": { + "~/*": ["../core/src/*"], + "@forager/core": ["../core/src/mod.ts"] + } + }, + // `@forager/core` is resolved to its TypeScript source so svelte-check can infer + // types across the workspace, but the core package is excluded from the checked + // fileset since it is a Deno project that is type-checked separately. + "exclude": [ + "../node_modules/**", + "../../node_modules/**", + "../core/**" + ] +} From 5551a2f9f10cdc1c89022678391b82a460a46889 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:20:50 +0000 Subject: [PATCH 2/4] fix(web): resolve $lib aliases and core stream typing for svelte-check - Re-declare SvelteKit $lib/$app path aliases in tsconfig.svelte.json (paths do not merge across extends) - Disable verbatimModuleSyntax and add DOM.AsyncIterable in the check config so Deno-valid imports and async-iterable streams type-check - Bridge TextDecoderStream/pipeThrough DOM lib variance in file_processor Co-authored-by: andykais-claude --- packages/core/src/lib/file_processor.ts | 7 ++++++- packages/web/tsconfig.svelte.json | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index b2212c42..26e3ca3c 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -649,7 +649,12 @@ class FileProcessor { } async * #readlines(stream: ReadableStream) { - const reader = stream.pipeThrough(new TextDecoderStream()).getReader() + // TextDecoderStream types its writable side as WritableStream, which + // TypeScript's DOM lib does not treat as assignable to the WritableStream + // that pipeThrough infers. The pairing is valid at runtime (a Uint8Array is a + // BufferSource), so bridge the lib-level mismatch explicitly. + const decoder = new TextDecoderStream() as ReadableWritablePair + const reader = stream.pipeThrough(decoder).getReader() let buffer = '' let read_result = await reader.read() while (!read_result.done) { diff --git a/packages/web/tsconfig.svelte.json b/packages/web/tsconfig.svelte.json index 6d534dfa..5032adf0 100644 --- a/packages/web/tsconfig.svelte.json +++ b/packages/web/tsconfig.svelte.json @@ -11,6 +11,14 @@ "strict": true, "moduleResolution": "bundler", + // `@forager/core` is a Deno project whose source is pulled into the program + // by the path mapping below. `verbatimModuleSyntax` is disabled so its + // (Deno-valid) value/type imports don't surface here, and `DOM.AsyncIterable` + // is added so async-iterable web streams (e.g. `for await (…of readable)`) + // type-check the same way they run under Deno. + "verbatimModuleSyntax": false, + "lib": ["esnext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "plugins": [{ "name": "typescript-svelte-plugin", "enabled": true, @@ -20,17 +28,26 @@ "allowImportingTsExtensions": true, "baseUrl": ".", + // NOTE: `paths` fully replaces the mapping inherited from + // ./.svelte-kit/tsconfig.json (TypeScript does not merge `paths` across + // `extends`), so the SvelteKit `$lib`/`$app` aliases are re-declared here + // relative to this file's location, alongside the workspace aliases. "paths": { "~/*": ["../core/src/*"], - "@forager/core": ["../core/src/mod.ts"] + "@forager/core": ["../core/src/mod.ts"], + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"], + "$app/types": ["./.svelte-kit/types/index.d.ts"] } }, // `@forager/core` is resolved to its TypeScript source so svelte-check can infer // types across the workspace, but the core package is excluded from the checked // fileset since it is a Deno project that is type-checked separately. "exclude": [ + "node_modules/**", "../node_modules/**", "../../node_modules/**", + "../core/**/*", "../core/**" ] } From 454ae043cc813d125d24dc49a93e023a69f39abe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:39:54 +0000 Subject: [PATCH 3/4] fix(web): correct rune/controller/api types across browse UI - Type the media view runes as a discriminated union (file/series/grouped) with union-narrowing getters, fixing MediaResponse member access - Expose media/series/views as ts-rpc modules so InferSpec resolves the client type (fixes 'never' RPC call chains) - Fix real bugs in MediaSeriesRune.load_detailed_view (undefined refs) - Assorted fixes: settings setter casts, keybind actions, filmstrip config default, Header/SelectInput/StarInput props, hooks env casts Co-authored-by: andykais-claude --- packages/web/src/hooks.server.ts | 6 +- packages/web/src/lib/api.ts | 32 +++++++++- packages/web/src/lib/base_controller.ts | 2 - packages/web/src/lib/components/Header.svelte | 1 + .../web/src/lib/components/SelectInput.svelte | 4 +- .../web/src/lib/components/StarInput.svelte | 6 +- packages/web/src/lib/keybinds.ts | 9 ++- .../src/lib/runes/base_queryparams.svelte.ts | 6 +- .../src/lib/runes/media_list_rune.svelte.ts | 29 +++++---- .../src/lib/runes/media_view_rune.svelte.ts | 64 +++++++++++-------- .../web/src/lib/runes/settings_rune.svelte.ts | 19 +++--- packages/web/src/lib/server/config.ts | 2 +- .../routes/browse/components/Footer.svelte | 2 +- .../routes/browse/components/MediaView.svelte | 6 +- .../browse/components/SearchLink.svelte | 2 +- packages/web/src/routes/browse/controller.ts | 2 +- .../browse/runes/media_selections.svelte.ts | 12 ++-- .../routes/tags/runes/queryparams.svelte.ts | 2 +- 18 files changed, 127 insertions(+), 79 deletions(-) diff --git a/packages/web/src/hooks.server.ts b/packages/web/src/hooks.server.ts index d6b8e76f..787bc67a 100644 --- a/packages/web/src/hooks.server.ts +++ b/packages/web/src/hooks.server.ts @@ -29,8 +29,10 @@ if (!building) { forager.init() } else { if (env.FORAGER_INSTANCE) { - forager = env.FORAGER_INSTANCE - config = env.FORAGER_CONFIG + // In production the CLI injects a live Forager instance and parsed config + // through these env slots, so they are not the plain strings $env types them as. + forager = env.FORAGER_INSTANCE as unknown as Forager + config = env.FORAGER_CONFIG as unknown as Config } else { throw new Error(`FORAGER_INSTANCE must be passed to sveltekit hooks`) } diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index 039ddc95..b871b0d0 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -18,11 +18,37 @@ class ForagerTagApi extends rpc.ApiController { parent_delete = this.context.forager.tag.parent_delete } +class ForagerMediaApi extends rpc.ApiController { + create = this.context.forager.media.create + update = this.context.forager.media.update + upsert = this.context.forager.media.upsert + delete = this.context.forager.media.delete + search = this.context.forager.media.search + group = this.context.forager.media.group + get = this.context.forager.media.get + thumbnail = this.context.forager.media.thumbnail + reload = this.context.forager.media.reload +} + +class ForagerSeriesApi extends rpc.ApiController { + create = this.context.forager.series.create + update = this.context.forager.series.update + upsert = this.context.forager.series.upsert + add = this.context.forager.series.add + get = this.context.forager.series.get + search = this.context.forager.series.search +} + +class ForagerViewsApi extends rpc.ApiController { + start = this.context.forager.views.start + update = this.context.forager.views.update +} + class ForagerApi extends rpc.ApiController { - media = this.context.forager.media - series = this.context.forager.series + media = this.module(ForagerMediaApi) + series = this.module(ForagerSeriesApi) tag = this.module(ForagerTagApi) - views = this.context.forager.views + views = this.module(ForagerViewsApi) } diff --git a/packages/web/src/lib/base_controller.ts b/packages/web/src/lib/base_controller.ts index b221163e..9d0499b2 100644 --- a/packages/web/src/lib/base_controller.ts +++ b/packages/web/src/lib/base_controller.ts @@ -26,8 +26,6 @@ abstract class BaseController { if (this.#config) return this.#config else throw new Error(`Controller::config not initialized`) } - - abstract handlers: {} } export { BaseController } diff --git a/packages/web/src/lib/components/Header.svelte b/packages/web/src/lib/components/Header.svelte index 4ed3a0e7..18387468 100644 --- a/packages/web/src/lib/components/Header.svelte +++ b/packages/web/src/lib/components/Header.svelte @@ -8,6 +8,7 @@ ...props }: { title: string + height?: number children: SvelteHTMLElements['div']['children'] } = $props() diff --git a/packages/web/src/lib/components/SelectInput.svelte b/packages/web/src/lib/components/SelectInput.svelte index f8b7e702..4d85832a 100644 --- a/packages/web/src/lib/components/SelectInput.svelte +++ b/packages/web/src/lib/components/SelectInput.svelte @@ -5,9 +5,9 @@ } interface Props { label: string - options: SelectOption + options: SelectOption[] value: string - onchange: () => void | undefined + onchange?: (e: Event) => void } let { label, diff --git a/packages/web/src/lib/components/StarInput.svelte b/packages/web/src/lib/components/StarInput.svelte index b72f7bbf..53ae452e 100644 --- a/packages/web/src/lib/components/StarInput.svelte +++ b/packages/web/src/lib/components/StarInput.svelte @@ -5,16 +5,16 @@ let { star, - value = $bindable(), + value = $bindable(), onclick, }: { star: number - value: number + value: number | undefined onclick: () => void } = $props() let icon_color = $derived.by(() => { - if (value >= star) { + if ((value ?? 0) >= star) { // starred return theme.colors.gray[400] } else { diff --git a/packages/web/src/lib/keybinds.ts b/packages/web/src/lib/keybinds.ts index 2ac2670c..95145431 100644 --- a/packages/web/src/lib/keybinds.ts +++ b/packages/web/src/lib/keybinds.ts @@ -5,6 +5,9 @@ import type { Config } from '$lib/server/config.ts' type KeybindAction = | 'Escape' | 'Search' + | 'OpenMedia' + | 'CopyMedia' + | 'AddTag' | 'PrevMedia' | 'NextMedia' | 'PrevTagSuggestion' @@ -55,12 +58,14 @@ export class Keybinds { } public listen(event: KeybindAction, handler: KeybindActionListener) { - this.emitter.addEventListener(event, handler) + // keybind handlers receive the KeyboardEvent dispatched via CustomEvent detail, + // but the DOM addEventListener signature is typed against the base Event. + this.emitter.addEventListener(event, handler as EventListener) return handler } public remove_listener(event: KeybindAction, handler: KeybindActionListener) { - this.emitter.removeEventListener(event, handler) + this.emitter.removeEventListener(event, handler as EventListener) } public handler = (e: KeyboardEvent) => { diff --git a/packages/web/src/lib/runes/base_queryparams.svelte.ts b/packages/web/src/lib/runes/base_queryparams.svelte.ts index f0973022..b0eeda6e 100644 --- a/packages/web/src/lib/runes/base_queryparams.svelte.ts +++ b/packages/web/src/lib/runes/base_queryparams.svelte.ts @@ -18,10 +18,10 @@ export abstract class BaseQueryParams> exten public draft: TParams = $state() as TParams public current_serialized: string = '?' - constructor(client: BaseController['client']) { + constructor(client: BaseController['client'], defaults: TParams) { super(client) - this.current = { ...this.DEFAULTS } - this.draft = { ...this.DEFAULTS } + this.current = { ...defaults } + this.draft = { ...defaults } onMount(async () => { const params = this.parse_url(new URL(window.location.toString())) diff --git a/packages/web/src/lib/runes/media_list_rune.svelte.ts b/packages/web/src/lib/runes/media_list_rune.svelte.ts index 9388acf7..77383a6d 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -1,8 +1,13 @@ import {Rune} from '$lib/runes/rune.ts' -import type { Forager, MediaResponse, outputs } from '@forager/core' +import type { Forager, MediaResponse, MediaGroupResponse, inputs } from '@forager/core' import { MediaViewRune } from '.' +import type { AnyMediaViewRune } from '.' +type AnyMediaResponse = MediaResponse | MediaGroupResponse + +type GroupSearchParams = Parameters[0] + type Result = | ReturnType | ReturnType @@ -27,12 +32,12 @@ export type Input = interface MediaListState { loading: boolean content: Result | null - results: MediaViewRune[] + results: AnyMediaViewRune[] } export class MediaListRune extends Rune { - #saved_params_type: 'media' | 'group_by' = 'media' - #saved_params: {} | undefined + #saved_params_type: Input['type'] = 'media' + #saved_params: Partial = {} #prev_query_hash: string = '' #fetch_count = 0 #has_more = true @@ -47,7 +52,7 @@ export class MediaListRune extends Rune { get content(): Result | null { return this.#state.content } - get results(): Result['results'] { return this.#state.results } + get results(): AnyMediaViewRune[] { return this.#state.results } get total() { return this.#state.content?.total ?? 0 } @@ -73,26 +78,26 @@ export class MediaListRune extends Rune { this.#fetch_count ++ this.#state.loading = true - let fetch_params: Input['params'] | undefined = this.#saved_params + let fetch_params: Partial = this.#saved_params if (this.#cursor !== undefined) { - fetch_params = {...fetch_params, cursor: this.#cursor} as any + fetch_params = {...fetch_params, cursor: this.#cursor} } const params_type = params?.type ?? this.#saved_params_type this.#saved_params_type = params_type let content: Result if (params_type === 'media') { - content = await this.client.forager.media.search(fetch_params) + content = await this.client.forager.media.search(fetch_params as inputs.PaginatedSearch) } else if (params_type === 'group_by') { fetch_params.limit = fetch_params.limit ?? 30 - content = await this.client.forager.media.group(fetch_params) + content = await this.client.forager.media.group(fetch_params as GroupSearchParams) } else { throw new Error('unimplemented') } - const results = content.results.map(result => { - return MediaViewRune.create(this.client, result, fetch_params) + const results = (content.results as AnyMediaResponse[]).map(result => { + return MediaViewRune.create(this.client, result, fetch_params as GroupSearchParams) }) this.#cursor = content.cursor @@ -112,7 +117,7 @@ export class MediaListRune extends Rune { } } - async fetch_tag_summary(params: Input) { + async fetch_tag_summary(params?: Input) { // NOTE this currently just does a "union". We want an "intersection" for this view. Otherwise our default view returns all tags! /* diff --git a/packages/web/src/lib/runes/media_view_rune.svelte.ts b/packages/web/src/lib/runes/media_view_rune.svelte.ts index 3950ea90..6d24bd6b 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -1,22 +1,24 @@ import {Rune} from '$lib/runes/rune.ts' +import type { MediaResponse, MediaGroupResponse, Forager, inputs, model_types } from '@forager/core' import type { BaseController } from '$lib/base_controller.ts' -import type { MediaResponse, inputs, type model_types } from '@forager/core' +type AnyMediaResponse = MediaResponse | MediaGroupResponse + +type GroupSearchParams = Parameters[0] + interface State { - media: MediaResponse + media: AnyMediaResponse full_thumbnails: MediaResponse['thumbnails'] | undefined } -interface SearchParams {} - export class MediaViewRune extends Rune { - media_type!: MediaResponse['media_type'] | 'grouped' + media_type!: AnyMediaResponse['media_type'] state = $state() - current_view: model_types.View + current_view: model_types.View | undefined - protected constructor(client: BaseController['client'], media_response: MediaResponse) { + protected constructor(client: BaseController['client'], media_response: AnyMediaResponse) { super(client) this.state = { media: media_response, @@ -28,28 +30,33 @@ export class MediaViewRune extends Rune { return this.state!.media } - set media(media: MediaResponse) { - return this.state!.media = media + set media(media: AnyMediaResponse) { + this.state!.media = media } get tags() { - return this.media.tags + if ('tags' in this.media) return this.media.tags + throw new Error(`tags are not available on ${this.media.media_type} responses`) } get media_reference() { - return this.media.media_reference + if ('media_reference' in this.media) return this.media.media_reference + throw new Error(`media_reference is not available on ${this.media.media_type} responses`) } get media_file() { - return this.media.media_file + if (this.media.media_type === 'media_file') return this.media.media_file + throw new Error(`media_file is not available on ${this.media.media_type} responses`) } get preview_thumbnail(): string | undefined { - return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}` + if ('thumbnails' in this.media) return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}` + return undefined } get thumbnails() { - return this.media.thumbnails + if ('thumbnails' in this.media) return this.media.thumbnails + throw new Error(`thumbnails are not available on ${this.media.media_type} responses`) } public update(media_info: inputs.MediaInfo, tags: inputs.MediaReferenceUpdateTags) { @@ -72,10 +79,10 @@ export class MediaViewRune extends Rune { // TODO track view and update it as a video loops, or as an image has stayed open for a while const view_response = await this.client.forager.views.start({media_reference_id: this.media_reference.id }) this.current_view = view_response.view - this.state.media.media_reference.view_count = view_response.media_reference.view_count + this.media_reference.view_count = view_response.media_reference.view_count } - static create(client: BaseController['client'], media_response: MediaResponse, search_params: SearchParams) { + static create(client: BaseController['client'], media_response: AnyMediaResponse, search_params: GroupSearchParams) { if (media_response.media_type === 'media_file') { return new MediaFileRune(client, media_response) } else if (media_response.media_type === 'media_series') { @@ -87,7 +94,7 @@ export class MediaViewRune extends Rune { } } - public img_fit_classes() { + public img_fit_classes(): string { throw new Error('requires override') } @@ -113,7 +120,7 @@ export class MediaFileRune extends MediaViewRune { } public img_fit_classes() { - if (this.media.media_file.width > this.media.media_file.height) { + if (this.media_file.width > this.media_file.height) { // its long edge is wide return "w-full" } else { @@ -129,10 +136,10 @@ export class MediaSeriesRune extends MediaViewRune { public override async load_detailed_view() { if (this.state!.full_thumbnails) return - const series = await forager.series.get({series_id: media_response.media_reference.id }) + const series = await this.client.forager.series.get({series_id: this.media_reference.id }) this.state!.full_thumbnails = series.thumbnails - const series_items = await forager.media.search({query: {series_id: media_response.media_reference.id }}) // TODO attach series items + // const series_items = await this.client.forager.media.search({query: {series_id: this.media_reference.id }}) } public img_fit_classes() { @@ -151,8 +158,7 @@ export class MediaGroupRune extends MediaViewRune { media_list: [] }) - protected constructor(client: BaseController['client'], media_response: MediaResponse, search_params: SearchParams) { - // debugger + constructor(client: BaseController['client'], media_response: MediaGroupResponse, search_params: GroupSearchParams) { super(client, media_response) const {group_by, cursor, ...merged_search_params} = search_params @@ -172,19 +178,22 @@ export class MediaGroupRune extends MediaViewRune { throw new Error(`unexpected search group`) } - this.client.forager.media.search(merged_search_params) - .then((result: {}) => { + // `merged_search_params` is the group-by query with its grouping fields removed, so + // it maps onto a standard media search that fetches the media under this group. + this.client.forager.media.search(merged_search_params as inputs.PaginatedSearch) + .then(result => { this.grouped_state.media_list = result.results }) } get group_metadata() { - return this.media.group + if (this.media.media_type === 'grouped') return this.media.group + throw new Error(`group metadata is not available on ${this.media.media_type} responses`) } get preview_thumbnail(): string | undefined { const media_entry = this.grouped_state.media_list.at(0) - if (media_entry) { + if (media_entry && 'thumbnails' in media_entry) { return `/files/thumbnail/${media_entry.thumbnails.results[0].id}` } return undefined @@ -195,7 +204,7 @@ export class MediaGroupRune extends MediaViewRune { return "w-full h-full" } else { const media = this.grouped_state.media_list[0] - if (media.media_file.width > media.media_file.height) { + if (media.media_type === 'media_file' && media.media_file.width > media.media_file.height) { // its long edge is wide return "w-full" } else { @@ -207,3 +216,4 @@ export class MediaGroupRune extends MediaViewRune { } export type MediaViewRunes = MediaSeriesRune | MediaFileRune +export type AnyMediaViewRune = MediaFileRune | MediaSeriesRune | MediaGroupRune diff --git a/packages/web/src/lib/runes/settings_rune.svelte.ts b/packages/web/src/lib/runes/settings_rune.svelte.ts index 4e335605..0b8cbd00 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -25,22 +25,25 @@ export class SettingsRune extends Rune { return this.#state.web.ui_defaults } + // NOTE: TypeScript does not narrow `value`/return types against the switched + // `path` because the generic `K` stays a union inside the body, so each branch + // asserts the concrete setting type it operates on. public set(path: K, value: MutatableSettings[K]) { switch(path) { case 'ui.media_list.thumbnail_size': { - this.ui.media_list.thumbnail_size = value + this.ui.media_list.thumbnail_size = value as MutatableSettings['ui.media_list.thumbnail_size'] break } case 'ui.media_list.thumbnail_shape': { - this.ui.media_list.thumbnail_shape = value + this.ui.media_list.thumbnail_shape = value as MutatableSettings['ui.media_list.thumbnail_shape'] break } case 'ui.search.advanced_filters.hide': { - this.ui.search.advanced_filters.hide = value + this.ui.search.advanced_filters.hide = value as MutatableSettings['ui.search.advanced_filters.hide'] break } case 'ui.sidebar.hide': { - this.ui.sidebar.hide = value + this.ui.sidebar.hide = value as MutatableSettings['ui.sidebar.hide'] break } default: { @@ -54,19 +57,19 @@ export class SettingsRune extends Rune { if (typeof value !== 'boolean') { throw new Error(`Unexpected value '${value}' for path '${path}'`) } - this.set(path, !value) + this.set(path, !value as MutatableSettings[K]) } private get(path: K): MutatableSettings[K] { switch(path) { case 'ui.media_list.thumbnail_size': { - return this.ui.media_list.thumbnail_size + return this.ui.media_list.thumbnail_size as MutatableSettings[K] } case 'ui.media_list.thumbnail_shape': { - return this.ui.media_list.thumbnail_shape + return this.ui.media_list.thumbnail_shape as MutatableSettings[K] } case 'ui.search.advanced_filters.hide': { - return this.ui.search.advanced_filters.hide + return this.ui.search.advanced_filters.hide as MutatableSettings[K] } default: { throw new Error(`Unexpected path '${path}'`) diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index c3d83c0f..733cd194 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -55,7 +55,7 @@ export const PackagesConfig = z.object({ filmstrip: z.object({ enabled: z.boolean().default(false), thumbnail_size: z.number().default(100), - }).strict().optional().transform(f => f ?? {enabled: false}) + }).strict().optional().transform(f => f ?? {enabled: false, thumbnail_size: 100}) }).strict().prefault({}), }).strict().prefault({}), diff --git a/packages/web/src/routes/browse/components/Footer.svelte b/packages/web/src/routes/browse/components/Footer.svelte index 3c6445f2..0f0a02b4 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -66,7 +66,7 @@ max={500} value={runes.settings.ui.media_list.thumbnail_size} oninput={e => { - runes.settings.set('ui.media_list.thumbnail_size', e.target.value) + runes.settings.set('ui.media_list.thumbnail_size', Number(e.currentTarget.value)) }}> {runes.settings.ui.media_list.thumbnail_size}px diff --git a/packages/web/src/routes/browse/components/MediaView.svelte b/packages/web/src/routes/browse/components/MediaView.svelte index 9f6ac754..9e73d5a6 100644 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ b/packages/web/src/routes/browse/components/MediaView.svelte @@ -47,9 +47,9 @@ } }) - function video_loader(node: HTMLVideoElement) { + function video_loader(node: HTMLVideoElement, video_url: string | undefined) { return { - update(video_source_url: string) { + update(video_source_url: string | undefined) { animation_progress = 0 node.load() } @@ -109,7 +109,7 @@ max={media_selections.current_selection.media_response.media_file.duration} value={animation_progress}> {#if controller.runes.settings.ui.media_view.filmstrip.enabled}
- {#each media_selections.current_selection.thumbnails.results as thumbnail, index} + {#each media_selections.current_selection.media_response.thumbnails.results as thumbnail, index}
Thumbnail {index}
{/each} diff --git a/packages/web/src/routes/browse/components/SearchLink.svelte b/packages/web/src/routes/browse/components/SearchLink.svelte index d82646bf..31949a19 100644 --- a/packages/web/src/routes/browse/components/SearchLink.svelte +++ b/packages/web/src/routes/browse/components/SearchLink.svelte @@ -4,7 +4,7 @@ interface Props { controller: BrowseController - params: Partial + params: BrowseController['runes']['queryparams']['DEFAULTS'] class?: ClassValue title?: string children: SvelteHTMLElements['div']['children'] diff --git a/packages/web/src/routes/browse/controller.ts b/packages/web/src/routes/browse/controller.ts index b505140f..4f77a189 100644 --- a/packages/web/src/routes/browse/controller.ts +++ b/packages/web/src/routes/browse/controller.ts @@ -19,7 +19,7 @@ class BrowseController extends BaseController { public constructor(config: Config) { super(config) - const media_list_rune = new MediaListRune(this.client, this.settings) + const media_list_rune = new MediaListRune(this.client) this.runes = { media_list: media_list_rune, focus: create_focuser(), diff --git a/packages/web/src/routes/browse/runes/media_selections.svelte.ts b/packages/web/src/routes/browse/runes/media_selections.svelte.ts index 71687f60..c881509f 100644 --- a/packages/web/src/routes/browse/runes/media_selections.svelte.ts +++ b/packages/web/src/routes/browse/runes/media_selections.svelte.ts @@ -1,5 +1,4 @@ import {Rune} from '$lib/runes/rune.ts' -import type { Forager, MediaResponse } from '@forager/core' import type * as runes from '$lib/runes/index.ts' import type { BaseController } from '$lib/base_controller.ts' @@ -26,13 +25,12 @@ export type ThumbnailSelections = export interface CurrentSelection { show: boolean - media_response: runes.MediaViewRunes | null + media_response: runes.AnyMediaViewRune | null result_index: number } const CURRENT_SELECTION_DEFAULTS: CurrentSelection = { show: false, - thumbnails: null, media_response: null, result_index: 0, } @@ -40,7 +38,7 @@ export class MediaSelectionsRune extends Rune { #selected_thumbnails = $state({type: 'none'}) #current_selection = $state({...CURRENT_SELECTION_DEFAULTS}) - public constructor(client: BaseController['client'], media_list_rune: MediaListRune) { + public constructor(client: BaseController['client'], media_list_rune: runes.MediaListRune) { super(client) } @@ -60,7 +58,7 @@ export class MediaSelectionsRune extends Rune { return false } - public async set_current_selection(media_response: runes.MediaViewRune, result_index: number) { + public async set_current_selection(media_response: runes.AnyMediaViewRune, result_index: number) { this.#current_selection.show if (this.is_currently_selected(media_response.media_reference.id)) { await this.open_media() @@ -92,7 +90,7 @@ export class MediaSelectionsRune extends Rune { this.#current_selection.show = false } - public async next_media(results: MediaResponse[]) { + public async next_media(results: runes.AnyMediaViewRune[]) { let next_index = 0 if ( this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 @@ -109,7 +107,7 @@ export class MediaSelectionsRune extends Rune { await this.view_media() } - async prev_media(results: MediaResponse[]) { + async prev_media(results: runes.AnyMediaViewRune[]) { let prev_index = 0 if ( this.#selected_thumbnails.type === 'ids' && this.#selected_thumbnails.media_reference_ids.length <= 1 diff --git a/packages/web/src/routes/tags/runes/queryparams.svelte.ts b/packages/web/src/routes/tags/runes/queryparams.svelte.ts index 81b2a61a..5fc3d2c1 100644 --- a/packages/web/src/routes/tags/runes/queryparams.svelte.ts +++ b/packages/web/src/routes/tags/runes/queryparams.svelte.ts @@ -33,7 +33,7 @@ export class TagQueryParams extends BaseQueryParams { get URL_PARAM_MAP() { return URL_PARAM_MAP } constructor(client: BaseController['client']) { - super(client) + super(client, DEFAULTS) } #build_search_options(params: SearchParams, limit: number) { From 353541e39d60618aa39fdd13efed91d693c771dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:54:53 +0000 Subject: [PATCH 4/4] fix(web): resolve remaining svelte-check type errors and enable CI typecheck - Type keybind listeners as the dispatched CustomEvent so detail access works - Relax Tag display/encode contracts to structural/model tag types - Fix svelte actions (focusable/scrollable) to accept their use: parameter - Narrow media_type before media_file access in the media_file endpoint - Correct RPC route adapter typing, queryparams index/serialize typing, TagAutoCompleteInput event/target typing, and misc component props - Enable the web 'deno task check' typecheck step in Web CI Co-authored-by: andykais-claude --- .github/workflows/web.yml | 6 ++--- packages/web/src/lib/actions/focusable.ts | 18 +++++++++------ packages/web/src/lib/actions/scrollable.ts | 2 +- .../web/src/lib/components/SelectInput.svelte | 2 +- packages/web/src/lib/components/Tag.svelte | 4 ++-- .../components/TagAutoCompleteInput.svelte | 22 +++++++++---------- packages/web/src/lib/keybinds.ts | 3 ++- packages/web/src/lib/parsers.ts | 8 ++++--- .../src/lib/runes/media_list_rune.svelte.ts | 2 +- .../src/lib/runes/media_view_rune.svelte.ts | 8 +++---- .../browse/components/MediaDetails.svelte | 4 ++-- .../routes/browse/components/MediaView.svelte | 8 +++---- .../browse/components/SearchResults.svelte | 3 +-- .../routes/browse/runes/queryparams.svelte.ts | 14 +++++------- .../routes/files/media_file/[id]/+server.ts | 4 ++++ .../web/src/routes/rpc/[signature]/+server.ts | 4 ++-- .../web/src/routes/tags/[slug]/+page.svelte | 10 ++++----- packages/web/vite.config.ts | 2 +- 18 files changed, 66 insertions(+), 58 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index beeb8991..c0ee889a 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -40,13 +40,13 @@ jobs: # - name: Lint # run: deno task lint - # - name: Typecheck - # run: deno task check - # package.json based projects need to run deno install step - name: Install Dependencies run: deno install --allow-scripts + - name: Typecheck + run: deno task check + - name: Build run: deno task build diff --git a/packages/web/src/lib/actions/focusable.ts b/packages/web/src/lib/actions/focusable.ts index c4534a0d..c04dfab6 100644 --- a/packages/web/src/lib/actions/focusable.ts +++ b/packages/web/src/lib/actions/focusable.ts @@ -1,11 +1,15 @@ -export function focusable(node: HTMLElement) { +export function focusable(node: HTMLElement, focused: boolean) { + const apply = (should_focus: boolean) => { + if (should_focus) { + node.focus() + } else { + node.blur() + } + } + apply(focused) return { - update(focused: boolean) { - if (focused) { - node.focus() - } else { - node.blur() - } + update(should_focus: boolean) { + apply(should_focus) } } diff --git a/packages/web/src/lib/actions/scrollable.ts b/packages/web/src/lib/actions/scrollable.ts index ea361469..991ec967 100644 --- a/packages/web/src/lib/actions/scrollable.ts +++ b/packages/web/src/lib/actions/scrollable.ts @@ -1,4 +1,4 @@ -export function scrollable(node: HTMLElement) { +export function scrollable(node: HTMLElement, _focused: boolean) { let is_visible = false const observer = new IntersectionObserver((entries) => { is_visible = entries[0].isIntersecting diff --git a/packages/web/src/lib/components/SelectInput.svelte b/packages/web/src/lib/components/SelectInput.svelte index 4d85832a..428ec35c 100644 --- a/packages/web/src/lib/components/SelectInput.svelte +++ b/packages/web/src/lib/components/SelectInput.svelte @@ -4,7 +4,7 @@ value: string } interface Props { - label: string + label?: string options: SelectOption[] value: string onchange?: (e: Event) => void diff --git a/packages/web/src/lib/components/Tag.svelte b/packages/web/src/lib/components/Tag.svelte index ba630442..8fe0c232 100644 --- a/packages/web/src/lib/components/Tag.svelte +++ b/packages/web/src/lib/components/Tag.svelte @@ -1,9 +1,9 @@ @@ -68,11 +68,11 @@ class="rounded-md py-1 px-3 text-slate-100 bg-gray-800 text-sm min-h-16" bind:value={controller.draft.description} > - + Media - + Unread - + Created
@@ -148,7 +148,7 @@ } }}>
- + Set this tag as an alias that hides the following tags: } = JSON.parse(new TextDecoder().decode(Deno.readFileSync(path.join(package_root, 'deno.json')))) const resolved_imports: Record = {} for (const [package_alias, specifier] of Object.entries(denojson.imports)) {