From fec82fb6b14ffe8a8db203eb46a913ece2c40c40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:15:10 +0000 Subject: [PATCH 01/18] 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 --- packages/web/package.json | 16 +++++++++++--- packages/web/src/app.d.ts | 8 ++++++- packages/web/svelte.config.js | 3 --- packages/web/tsconfig.svelte.json | 36 +++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 packages/web/tsconfig.svelte.json diff --git a/packages/web/package.json b/packages/web/package.json index dd554f94..92d0b15c 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.56.4", "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 9629aa4c754f96928a8fca05ac3c6c10e6dce689 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:20:50 +0000 Subject: [PATCH 02/18] 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 10646220..cdf3a889 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -668,7 +668,12 @@ class FileProcessor { } static async * read_lines(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 = '' while (true) { const read_result = await reader.read() 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 2d880b95f0d57c54336f9e410a2104a88bbc6a48 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:39:54 +0000 Subject: [PATCH 03/18] 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 | 2 + .../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 +- packages/web/src/routes/browse/controller.ts | 2 +- .../browse/runes/media_selections.svelte.ts | 12 ++-- .../routes/tags/runes/queryparams.svelte.ts | 2 +- 17 files changed, 121 insertions(+), 76 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 b1f28301..008b5024 100644 --- a/packages/web/src/lib/keybinds.ts +++ b/packages/web/src/lib/keybinds.ts @@ -101,6 +101,8 @@ export class Keybinds { } public listen(event: KeybindAction, handler: KeybindActionListener) { + // 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 } 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 b0b56bc3..964764a5 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 @@ -174,19 +180,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 @@ -197,7 +206,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 { @@ -209,3 +218,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 ffa75c32..9209e95f 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -26,14 +26,17 @@ 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.media_view.fit.mode': { @@ -41,11 +44,11 @@ export class SettingsRune extends Rune { 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: { @@ -59,19 +62,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] } case 'ui.media_view.fit.mode': { return this.ui.media_view.fit.mode diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index a41dd73c..c31ca8e7 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -59,7 +59,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 fc9617da..99a2df89 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -67,7 +67,7 @@ max={500} value={controller.runes.settings.ui.media_list.thumbnail_size} oninput={e => { - controller.runes.settings.set('ui.media_list.thumbnail_size', e.target.value) + controller.runes.settings.set('ui.media_list.thumbnail_size', Number(e.currentTarget.value)) }}> {controller.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 ecb71a37..b9cfdb6f 100644 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ b/packages/web/src/routes/browse/components/MediaView.svelte @@ -80,9 +80,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() } @@ -147,7 +147,7 @@ max={controller.runes.media_selections.current_selection.media_response.media_file.duration} value={animation_progress}> {#if controller.runes.settings.ui.media_view.filmstrip.enabled}
- {#each controller.runes.media_selections.current_selection.thumbnails.results as thumbnail, index (thumbnail.id)} + {#each controller.runes.media_selections.current_selection.media_response.thumbnails.results as thumbnail, index (thumbnail.id)}
Thumbnail {index}
{/each} 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 e654b11e6299d20d4bd2f813bed564e1ba8e6eaf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 8 Jul 2026 03:54:53 +0000 Subject: [PATCH 04/18] 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 | 4 ++-- 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/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 | 4 ++++ .../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 +- 17 files changed, 63 insertions(+), 52 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 5edbe93a..63d364a3 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -34,8 +34,8 @@ jobs: # - name: Lint # run: deno task lint - # - name: Typecheck - # run: deno task check + - 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 @@ @@ -69,11 +69,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
@@ -149,7 +149,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)) { From 572af0db26c4247435c8de93d321df6aff0fb42e Mon Sep 17 00:00:00 2001 From: Andrew Kaiser Date: Sun, 19 Jul 2026 14:55:38 -0400 Subject: [PATCH 05/18] better typing option --- .../web/src/lib/runes/settings_rune.svelte.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/web/src/lib/runes/settings_rune.svelte.ts b/packages/web/src/lib/runes/settings_rune.svelte.ts index 9209e95f..8a715541 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -2,7 +2,7 @@ import type { Config } from '$lib/server/config.ts' import { Rune } from './rune'; import type { BaseController } from '$lib/base_controller.ts' -interface MutatableSettings { +interface MutableSettings { 'ui.media_list.thumbnail_size': Config['web']['ui_defaults']['media_list']['thumbnail_size'] 'ui.media_list.thumbnail_shape': Config['web']['ui_defaults']['media_list']['thumbnail_shape'] 'ui.media_view.fit.mode': Config['web']['ui_defaults']['media_view']['fit']['mode'] @@ -10,6 +10,12 @@ interface MutatableSettings { 'ui.sidebar.hide': Config['web']['ui_defaults']['sidebar']['hide'] } + +type MutableSettingsUpdate = { + [K in keyof MutableSettings]: { path: K; value: MutableSettings[K] } +}[keyof MutableSettings] + + export class SettingsRune extends Rune { #state = $state({} as Config); @@ -29,14 +35,15 @@ export class SettingsRune extends Rune { // 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) { + public set(path: K, value: MutableSettings[K]) { + const update = {path, value} as MutableSettingsUpdate + switch(update.path) { case 'ui.media_list.thumbnail_size': { - this.ui.media_list.thumbnail_size = value as MutatableSettings['ui.media_list.thumbnail_size'] + this.ui.media_list.thumbnail_size = update.value break } case 'ui.media_list.thumbnail_shape': { - this.ui.media_list.thumbnail_shape = value as MutatableSettings['ui.media_list.thumbnail_shape'] + this.ui.media_list.thumbnail_shape = update.value break } case 'ui.media_view.fit.mode': { @@ -44,11 +51,11 @@ export class SettingsRune extends Rune { break } case 'ui.search.advanced_filters.hide': { - this.ui.search.advanced_filters.hide = value as MutatableSettings['ui.search.advanced_filters.hide'] + this.ui.search.advanced_filters.hide = update.value break } case 'ui.sidebar.hide': { - this.ui.sidebar.hide = value as MutatableSettings['ui.sidebar.hide'] + this.ui.sidebar.hide = update.value break } default: { @@ -57,24 +64,24 @@ export class SettingsRune extends Rune { } } - public toggle(path: K) { + public toggle(path: K) { const value = this.get(path) if (typeof value !== 'boolean') { throw new Error(`Unexpected value '${value}' for path '${path}'`) } - this.set(path, !value as MutatableSettings[K]) + this.set(path, !value as MutableSettings[K]) } - private get(path: K): MutatableSettings[K] { + private get(path: K): MutableSettings[K] { switch(path) { case 'ui.media_list.thumbnail_size': { - return this.ui.media_list.thumbnail_size as MutatableSettings[K] + return this.ui.media_list.thumbnail_size as MutableSettings[K] } case 'ui.media_list.thumbnail_shape': { - return this.ui.media_list.thumbnail_shape as MutatableSettings[K] + return this.ui.media_list.thumbnail_shape as MutableSettings[K] } case 'ui.search.advanced_filters.hide': { - return this.ui.search.advanced_filters.hide as MutatableSettings[K] + return this.ui.search.advanced_filters.hide as MutableSettings[K] } case 'ui.media_view.fit.mode': { return this.ui.media_view.fit.mode From 6327933822c5c7e8d4419efafb5a14d2eeb66a26 Mon Sep 17 00:00:00 2001 From: Andrew Kaiser Date: Sun, 19 Jul 2026 14:57:23 -0400 Subject: [PATCH 06/18] update comments --- packages/web/src/lib/runes/settings_rune.svelte.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/web/src/lib/runes/settings_rune.svelte.ts b/packages/web/src/lib/runes/settings_rune.svelte.ts index 8a715541..9d2d622c 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -2,6 +2,10 @@ import type { Config } from '$lib/server/config.ts' import { Rune } from './rune'; import type { BaseController } from '$lib/base_controller.ts' + +// Note that this file in general is weird. Svelte runes specifically need static accessors and static getters, so we cannot just have generic key/value accessors. +// I landed on this design, where all the accessors are explicit. It looks unnecessary, but in practice its still ergonomic to use outside this module, and updating it is very straight forward. + interface MutableSettings { 'ui.media_list.thumbnail_size': Config['web']['ui_defaults']['media_list']['thumbnail_size'] 'ui.media_list.thumbnail_shape': Config['web']['ui_defaults']['media_list']['thumbnail_shape'] @@ -32,9 +36,6 @@ 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: MutableSettings[K]) { const update = {path, value} as MutableSettingsUpdate switch(update.path) { From 9f736f1c6fb91d523b466a02fac8b5237089a879 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 19 Jul 2026 19:20:37 +0000 Subject: [PATCH 07/18] refactor(web): address PR review feedback - media_view_rune: base MediaViewRune methods now error; concrete runes (file/series via a shared MediaReferenceRune, grouped) implement only what they support, instead of duck-typing on the base class - media_list_rune: MediaViewRune.create accepts a neutral MediaSearchInput union and passes params straight through (no longer privileges group params) - config: replace the .optional().transform() filmstrip fallback with prefault({}) - queryparams: coerce the unread URL param to a real boolean in parse_url so the boolean type is honest and unread=false is preserved - drop unused ts-pattern dependency from packages/web Co-authored-by: andykais-claude --- packages/web/package.json | 1 - .../src/lib/runes/media_list_rune.svelte.ts | 12 +- .../src/lib/runes/media_view_rune.svelte.ts | 182 +++++++++++------- packages/web/src/lib/server/config.ts | 4 +- .../routes/browse/runes/queryparams.svelte.ts | 2 + 5 files changed, 119 insertions(+), 82 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index 92d0b15c..0d0ee041 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -22,7 +22,6 @@ "@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": { 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 a7b94d2e..c0a64d51 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -1,13 +1,11 @@ import {Rune} from '$lib/runes/rune.ts' import type { Forager, MediaResponse, MediaGroupResponse, inputs } from '@forager/core' import { MediaViewRune } from '.' -import type { AnyMediaViewRune } from '.' +import type { AnyMediaViewRune, MediaSearchInput } from '.' type AnyMediaResponse = MediaResponse | MediaGroupResponse -type GroupSearchParams = Parameters[0] - type Result = | ReturnType | ReturnType @@ -37,7 +35,7 @@ interface MediaListState { export class MediaListRune extends Rune { #saved_params_type: Input['type'] = 'media' - #saved_params: Partial = {} + #saved_params: MediaSearchInput = {} #prev_query_hash: string = '' #fetch_count = 0 #has_more = true @@ -78,7 +76,7 @@ export class MediaListRune extends Rune { this.#fetch_count ++ this.#state.loading = true - let fetch_params: Partial = this.#saved_params + let fetch_params: MediaSearchInput = this.#saved_params if (this.#cursor !== undefined) { fetch_params = {...fetch_params, cursor: this.#cursor} } @@ -91,13 +89,13 @@ export class MediaListRune extends Rune { } else if (params_type === 'group_by') { fetch_params.limit = fetch_params.limit ?? 30 - content = await this.client.forager.media.group(fetch_params as GroupSearchParams) + content = await this.client.forager.media.group(fetch_params as inputs.PaginatedSearchGroupBy) } else { throw new Error('unimplemented') } const results = (content.results as AnyMediaResponse[]).map(result => { - return MediaViewRune.create(this.client, result, fetch_params as GroupSearchParams) + return MediaViewRune.create(this.client, result, fetch_params) }) this.#cursor = content.cursor 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 34399ba5..695f25cd 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -1,88 +1,94 @@ import {Rune} from '$lib/runes/rune.ts' -import type { MediaResponse, MediaGroupResponse, Forager, inputs, model_types } from '@forager/core' +import type { MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupResponse, Forager, inputs, model_types } from '@forager/core' import type { BaseController } from '$lib/base_controller.ts' type AnyMediaResponse = MediaResponse | MediaGroupResponse -type GroupSearchParams = Parameters[0] +/** + * The search/group params that produced a given result. These are threaded straight + * through from the media list without favoring one search variant over another; the + * rune that needs them (the grouped rune) narrows to the shape it requires. + */ +export type MediaSearchInput = + | NonNullable[0]> + | Parameters[0] -interface State { - media: AnyMediaResponse + +interface State { + media: M full_thumbnails: MediaResponse['thumbnails'] | undefined } -export class MediaViewRune extends Rune { - media_type!: AnyMediaResponse['media_type'] - state = $state() +/** + * Base class for a single item rendered in the media list. The data getters and + * mutations below are only meaningful for the media types that actually carry the + * relevant data, so the base implementations error and each subclass overrides the + * ones it supports. + */ +export abstract class MediaViewRune extends Rune { + abstract media_type: M['media_type'] + state = $state>() current_view: model_types.View | undefined - protected constructor(client: BaseController['client'], media_response: AnyMediaResponse) { + protected constructor(client: BaseController['client'], media_response: M) { super(client) this.state = { media: media_response, - full_thumbnails: undefined + full_thumbnails: undefined, } } - get media() { + get media(): M { return this.state!.media } - set media(media: AnyMediaResponse) { + set media(media: M) { this.state!.media = media } - get 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(): model_types.MediaReference { + throw new Error(`media_reference is not available on ${this.media_type} media`) + } + + get tags(): model_types.Tag[] { + throw new Error(`tags are not available on ${this.media_type} media`) } - get 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(): model_types.MediaFile { + throw new Error(`media_file is not available on ${this.media_type} media`) } - get 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 thumbnails(): MediaResponse['thumbnails'] { + throw new Error(`thumbnails are not available on ${this.media_type} media`) } get preview_thumbnail(): string | undefined { - if ('thumbnails' in this.media) return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}` - return undefined + throw new Error(`preview_thumbnail is not available on ${this.media_type} media`) } - get 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): Promise { + throw new Error(`update is not available on ${this.media_type} media`) } - public update(media_info?: inputs.MediaInfo, tags?: inputs.MediaReferenceUpdateTags) { - throw new Error('requires override') + public star(stars: number): Promise { + throw new Error(`star is not available on ${this.media_type} media`) } - public async star(stars: number) { - const updated = await this.client.forager.media.update( - this.media_reference.id, - {stars} - ) - this.media = updated + public add_view(): Promise { + throw new Error(`add_view is not available on ${this.media_type} media`) } - public async load_detailed_view() { - throw new Error('requires override') + public load_detailed_view(): Promise { + throw new Error(`load_detailed_view is not available on ${this.media_type} media`) } - public async add_view() { - // 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.media_reference.view_count = view_response.media_reference.view_count + public img_fit_classes(): string { + throw new Error(`img_fit_classes is not available on ${this.media_type} media`) } - static create(client: BaseController['client'], media_response: AnyMediaResponse, search_params: GroupSearchParams) { + static create(client: BaseController['client'], media_response: AnyMediaResponse, search_params: MediaSearchInput) { if (media_response.media_type === 'media_file') { return new MediaFileRune(client, media_response) } else if (media_response.media_type === 'media_series') { @@ -93,16 +99,42 @@ export class MediaViewRune extends Rune { throw new Error(`Unexpected media_response ${JSON.stringify(media_response)}`) } } +} - public img_fit_classes(): string { - throw new Error('requires override') + +/** Shared behavior for runes backed by a concrete media reference (files and series). */ +abstract class MediaReferenceRune extends MediaViewRune { + override get media_reference(): model_types.MediaReference { + return this.media.media_reference + } + + override get tags(): model_types.Tag[] { + return this.media.tags + } + + override get thumbnails(): MediaResponse['thumbnails'] { + return this.media.thumbnails + } + + override get preview_thumbnail(): string | undefined { + return `/files/thumbnail/${this.media.thumbnails.results.at(0)?.id}` } + public override async add_view() { + // 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.media_reference.view_count = view_response.media_reference.view_count + } } -export class MediaFileRune extends MediaViewRune { - media_type = 'media_file' as const satisfies MediaResponse['media_type'] +export class MediaFileRune extends MediaReferenceRune { + media_type = 'media_file' as const + + override get media_file(): model_types.MediaFile { + return this.media.media_file + } public override async update(media_info?: inputs.MediaInfo, tags?: inputs.MediaReferenceUpdateTags) { const updated = await this.client.forager.media.update( @@ -113,13 +145,21 @@ export class MediaFileRune extends MediaViewRune { this.media = updated } + public override async star(stars: number) { + const updated = await this.client.forager.media.update( + this.media_reference.id, + {stars} + ) + this.media = updated + } + public override async load_detailed_view() { if (this.state!.full_thumbnails) return const result = await this.client.forager.media.get({media_reference_id: this.media_reference.id }) this.state!.full_thumbnails = result.thumbnails } - public img_fit_classes() { + public override img_fit_classes() { if ((this.media_file.width ?? 0) > (this.media_file.height ?? 0)) { // its long edge is wide return "w-full" @@ -131,8 +171,8 @@ export class MediaFileRune extends MediaViewRune { } -export class MediaSeriesRune extends MediaViewRune { - media_type = 'media_series' as const satisfies MediaResponse['media_type'] +export class MediaSeriesRune extends MediaReferenceRune { + media_type = 'media_series' as const public override async load_detailed_view() { if (this.state!.full_thumbnails) return @@ -142,7 +182,7 @@ export class MediaSeriesRune extends MediaViewRune { // const series_items = await this.client.forager.media.search({query: {series_id: this.media_reference.id }}) } - public img_fit_classes() { + public override img_fit_classes() { console.warn(`media series img fit functions are not implemented`) return "w-full" } @@ -152,34 +192,33 @@ export class MediaSeriesRune extends MediaViewRune { interface GroupState { media_list: MediaResponse[] } -export class MediaGroupRune extends MediaViewRune { - media_type = 'grouped' as const +export class MediaGroupRune extends MediaViewRune { + media_type = 'grouped' as const grouped_state = $state({ media_list: [] }) - constructor(client: BaseController['client'], media_response: MediaGroupResponse, search_params: GroupSearchParams) { + constructor(client: BaseController['client'], media_response: MediaGroupResponse, search_params: MediaSearchInput) { super(client, media_response) - const {group_by, cursor, ...merged_search_params} = search_params - - if (group_by['tag_group'] !== undefined) { - merged_search_params.query = {...merged_search_params.query} - merged_search_params.query.tags = merged_search_params.query.tags - ? [...merged_search_params.query.tags] - : [] - const tag = `${group_by.tag_group}:${media_response.group.value}` - merged_search_params.query.tags.push(tag) - // 'count' sorting only exists on group_by searches, so fall back to the default media search sort - if (merged_search_params.sort_by === 'count') { - delete merged_search_params.sort_by - delete merged_search_params.order - } - merged_search_params.limit = 1 // TODO until we implement a filmstrip render, we only need one image - } else { + if (!('group_by' in search_params) || search_params.group_by?.tag_group === undefined) { throw new Error(`unexpected search group`) } + const {group_by, cursor, ...merged_search_params} = search_params + merged_search_params.query = {...merged_search_params.query} + merged_search_params.query.tags = merged_search_params.query.tags + ? [...merged_search_params.query.tags] + : [] + const tag = `${group_by.tag_group}:${media_response.group.value}` + merged_search_params.query.tags.push(tag) + // 'count' sorting only exists on group_by searches, so fall back to the default media search sort + if (merged_search_params.sort_by === 'count') { + delete merged_search_params.sort_by + delete merged_search_params.order + } + merged_search_params.limit = 1 // TODO until we implement a filmstrip render, we only need one image + // `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) @@ -189,11 +228,10 @@ export class MediaGroupRune extends MediaViewRune { } get group_metadata() { - if (this.media.media_type === 'grouped') return this.media.group - throw new Error(`group metadata is not available on ${this.media.media_type} responses`) + return this.media.group } - get preview_thumbnail(): string | undefined { + override get preview_thumbnail(): string | undefined { const media_entry = this.grouped_state.media_list.at(0) if (media_entry && 'thumbnails' in media_entry) { return `/files/thumbnail/${media_entry.thumbnails.results[0].id}` @@ -201,7 +239,7 @@ export class MediaGroupRune extends MediaViewRune { return undefined } - public img_fit_classes() { + public override img_fit_classes() { if (this.grouped_state.media_list.length === 0) { return "w-full h-full" } else { diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index c31ca8e7..16b91bee 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -58,8 +58,8 @@ export const PackagesConfig = z.object({ }).strict().prefault({}), filmstrip: z.object({ enabled: z.boolean().default(false), - thumbnail_size: z.number().default(100), - }).strict().optional().transform(f => f ?? {enabled: false, thumbnail_size: 100}) + thumbnail_size: z.number().default(100), + }).strict().prefault({}) }).strict().prefault({}), }).strict().prefault({}), diff --git a/packages/web/src/routes/browse/runes/queryparams.svelte.ts b/packages/web/src/routes/browse/runes/queryparams.svelte.ts index bf50c66f..7ac53360 100644 --- a/packages/web/src/routes/browse/runes/queryparams.svelte.ts +++ b/packages/web/src/routes/browse/runes/queryparams.svelte.ts @@ -130,6 +130,8 @@ export class QueryParamsManager extends Rune { params.stars = parseInt(val) } else if (params_key === 'filepath') { params.filepath = decodeURIComponent(val) + } else if (params_key === 'unread_only') { + params.unread_only = val === 'true' } else if (params_key === 'media_type') { if (MEDIA_TYPE_FILTER_VALUES.has(val as MediaTypeFilter)) { params.media_type = val as MediaTypeFilter From f59664bf41d34c6af1477af033610bea5ba4c358 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Jul 2026 02:38:49 +0000 Subject: [PATCH 08/18] chore(web): declare shared deps once via jsrDepsInNodeModules Instead of duplicating the @std/*, zod and @torm/sqlite versions in both the root deno.json imports and packages/web/package.json, declare them once in deno.json and enable Deno's jsrDepsInNodeModules so the jsr: deps are materialized into node_modules for svelte-check (tsc) to resolve. - Remove the @std/*, zod, @torm/sqlite duplicates from packages/web/package.json (only genuinely web-specific npm deps remain there) - Add jsrDepsInNodeModules: true to the root deno.json - Add an explicit @std/fmt/bytes import-map entry to work around the subpath prefix rewrite that jsrDepsInNodeModules otherwise fails to resolve Co-authored-by: andykais-claude --- deno.json | 2 ++ packages/web/package.json | 11 +---------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/deno.json b/deno.json index eee795aa..26fbf991 100644 --- a/deno.json +++ b/deno.json @@ -16,6 +16,7 @@ "@std/datetime": "jsr:@std/datetime@^0.225.3", "@std/yaml": "jsr:@std/yaml@^1.0.5", "@std/fmt": "jsr:@std/fmt@0.225.6", + "@std/fmt/bytes": "jsr:@std/fmt@0.225.6/bytes", "@std/fs": "jsr:@std/fs@0.229.3", "@std/log": "jsr:@std/log@^0.224.6", "@std/media-types": "jsr:@std/media-types@0.224.0", @@ -31,5 +32,6 @@ "unstable": ["raw-imports"], "nodeModulesDir": "auto", + "jsrDepsInNodeModules": true, "allowScripts": ["npm:@sveltejs/kit@2.5.24", "npm:@tailwindcss/oxide@4.1.14", "npm:esbuild@0.25.10", "npm:fsevents@2.3.3"] } diff --git a/packages/web/package.json b/packages/web/package.json index 0d0ee041..64dc1167 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -13,16 +13,7 @@ }, "dependencies": { "@andykais/ts-rpc": "npm:@jsr/andykais__ts-rpc@^0.2.4", - "@opentelemetry/api": "1.9.0", - "@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", - "zod": "npm:zod@4.1.11" + "@opentelemetry/api": "1.9.0" }, "devDependencies": { "@deno/vite-plugin": "1.0.6", From 65b6acee5363beba27eab2f6780c982ce0023b08 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 21:36:26 +0000 Subject: [PATCH 09/18] fix(web): address review feedback on typechecking PR - media_view_rune: MediaGroupRune overrides media_reference from the group's representative media instead of the base getter throwing, which broke rendering and selecting grouped results. media_reference is optional on the base so consumers must narrow; MediaViewRunes (unused) dropped and the erroring base methods are async so they reject rather than throw past callers - media_selections/MediaDetails: handle grouped selections explicitly now that the compiler surfaces them - tsconfig: collapse tsconfig.svelte.json into tsconfig.json and move the workspace path aliases into svelte.config.js kit.alias, so svelte-kit sync generates them and there is no hand-maintained copy (also drops the baseUrl/paths warning) - queryparams: URL_PARAM_MAP now covers every SearchParams key, parse_url loops known keys and parses each explicitly (no @ts-ignore), merge takes a Partial, and SearchParams is exported for SearchLink - settings_rune: fix missing ui.sidebar.hide read and make both switches exhaustive without eagerly reading every setting - base_queryparams: DEFAULTS is a readonly field assigned from the constructor rather than a second abstract getter - deno.json: single @std/fmt entry with a note on the subpath rewrite - misc: rpc singleton narrowing, 400 instead of 404 for non-file references Co-authored-by: andykais-claude --- deno.json | 5 +- packages/core/src/lib/file_processor.ts | 3 + packages/web/package.json | 4 +- .../src/lib/runes/base_queryparams.svelte.ts | 6 +- .../src/lib/runes/media_list_rune.svelte.ts | 4 +- .../src/lib/runes/media_view_rune.svelte.ts | 51 +++-- .../web/src/lib/runes/settings_rune.svelte.ts | 24 ++- .../browse/components/MediaDetails.svelte | 52 +++-- .../browse/components/SearchLink.svelte | 3 +- .../browse/components/SearchResults.svelte | 2 +- .../browse/runes/media_selections.svelte.ts | 16 +- .../routes/browse/runes/queryparams.svelte.ts | 196 ++++++++++++------ .../routes/files/media_file/[id]/+server.ts | 4 +- .../web/src/routes/rpc/[signature]/+server.ts | 7 +- .../routes/tags/runes/queryparams.svelte.ts | 1 - packages/web/svelte.config.js | 9 + packages/web/tsconfig.json | 27 ++- packages/web/tsconfig.svelte.json | 53 ----- 18 files changed, 278 insertions(+), 189 deletions(-) delete mode 100644 packages/web/tsconfig.svelte.json diff --git a/deno.json b/deno.json index 26fbf991..7332215a 100644 --- a/deno.json +++ b/deno.json @@ -15,7 +15,10 @@ "imports": { "@std/datetime": "jsr:@std/datetime@^0.225.3", "@std/yaml": "jsr:@std/yaml@^1.0.5", - "@std/fmt": "jsr:@std/fmt@0.225.6", + // NOTE mapped as an explicit subpath rather than a bare "@std/fmt" or an "@std/fmt/" + // prefix: `jsrDepsInNodeModules` rewrites those to their npm form + // ("npm:@jsr/std__fmt@0.225.6/"), which cannot resolve a subpath. Any other + // @std/fmt subpath we start importing needs its own entry here. "@std/fmt/bytes": "jsr:@std/fmt@0.225.6/bytes", "@std/fs": "jsr:@std/fs@0.229.3", "@std/log": "jsr:@std/log@^0.224.6", diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index cdf3a889..13a91dbc 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -672,6 +672,9 @@ class FileProcessor { // 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. + // NOTE `deno check` passes without this, but @forager/web type-checks core's source + // under the DOM lib, where it does not. Removing this breaks `deno task --cwd + // packages/web check`. const decoder = new TextDecoderStream() as ReadableWritablePair const reader = stream.pipeThrough(decoder).getReader() let buffer = '' diff --git a/packages/web/package.json b/packages/web/package.json index 64dc1167..d34594bf 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -8,8 +8,8 @@ "build": "vite build", "build:local": "LOCAL_BUILD=true vite build", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.svelte.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.svelte.json --watch" + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "dependencies": { "@andykais/ts-rpc": "npm:@jsr/andykais__ts-rpc@^0.2.4", diff --git a/packages/web/src/lib/runes/base_queryparams.svelte.ts b/packages/web/src/lib/runes/base_queryparams.svelte.ts index b0eeda6e..236b213d 100644 --- a/packages/web/src/lib/runes/base_queryparams.svelte.ts +++ b/packages/web/src/lib/runes/base_queryparams.svelte.ts @@ -17,9 +17,14 @@ export abstract class BaseQueryParams> exten public current: TParams = $state() as TParams public draft: TParams = $state() as TParams public current_serialized: string = '?' + // NOTE assigned from the constructor rather than declared as an abstract getter: + // TypeScript forbids reading an abstract member from the base constructor (ts2715), + // and the defaults are needed here to seed `current`/`draft`. + public readonly DEFAULTS: TParams constructor(client: BaseController['client'], defaults: TParams) { super(client) + this.DEFAULTS = defaults this.current = { ...defaults } this.draft = { ...defaults } @@ -38,7 +43,6 @@ export abstract class BaseQueryParams> exten }) } - abstract get DEFAULTS(): TParams abstract get URL_PARAM_MAP(): Partial> protected get URL_PARAM_MAP_REVERSED(): Record { 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 c0a64d51..41ead5e9 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -88,7 +88,9 @@ export class MediaListRune extends Rune { 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 + // NOTE not mutated in place: when there is no cursor, fetch_params still aliases + // #saved_params, and writing through it would leak into saved state + fetch_params = {...fetch_params, limit: fetch_params.limit ?? 30} content = await this.client.forager.media.group(fetch_params as inputs.PaginatedSearchGroupBy) } else { throw new Error('unimplemented') 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 695f25cd..a4288b7e 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -48,7 +48,12 @@ export abstract class MediaViewRune { + // NOTE these are async so that the base implementations reject rather than throwing + // synchronously past a caller's `.catch()`, which their declared types promise. + public async update(media_info?: inputs.MediaInfo, tags?: inputs.MediaReferenceUpdateTags): Promise { throw new Error(`update is not available on ${this.media_type} media`) } - public star(stars: number): Promise { + public async star(stars: number): Promise { throw new Error(`star is not available on ${this.media_type} media`) } - public add_view(): Promise { + public async add_view(): Promise { throw new Error(`add_view is not available on ${this.media_type} media`) } - public load_detailed_view(): Promise { + public async load_detailed_view(): Promise { throw new Error(`load_detailed_view is not available on ${this.media_type} media`) } @@ -231,29 +238,39 @@ export class MediaGroupRune extends MediaViewRune { return this.media.group } + /** + * The media standing in for this group. Core supplies it on `group.media` when the + * search asks for `grouped_media`, otherwise we fall back to the list this rune + * fetches itself. Undefined until one of those has loaded. + */ + get #representative_media(): MediaResponse | undefined { + return this.media.group.media?.at(0) ?? this.grouped_state.media_list.at(0) + } + + override get media_reference(): model_types.MediaReference | undefined { + return this.#representative_media?.media_reference + } + override get preview_thumbnail(): string | undefined { - const media_entry = this.grouped_state.media_list.at(0) - if (media_entry && 'thumbnails' in media_entry) { + const media_entry = this.#representative_media + if (media_entry) { return `/files/thumbnail/${media_entry.thumbnails.results[0].id}` } return undefined } public override img_fit_classes() { - if (this.grouped_state.media_list.length === 0) { + const media = this.#representative_media + if (media === undefined) { return "w-full h-full" + } else if (media.media_type === 'media_file' && (media.media_file.width ?? 0) > (media.media_file.height ?? 0)) { + // its long edge is wide + return "w-full" } else { - const media = this.grouped_state.media_list[0] - if (media.media_type === 'media_file' && (media.media_file.width ?? 0) > (media.media_file.height ?? 0)) { - // its long edge is wide - return "w-full" - } else { - // its long edge is tall - return "h-full" - } + // its long edge is tall + return "h-full" } } } -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 9d2d622c..39d2c922 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -60,7 +60,9 @@ export class SettingsRune extends Rune { break } default: { - throw new Error(`Unexpected path '${path}'`) + // adding a MutableSettings key without a case above fails to compile here + const unhandled: never = update + throw new Error(`Unexpected path '${path}' (${JSON.stringify(unhandled)})`) } } } @@ -74,21 +76,33 @@ export class SettingsRune extends Rune { } private get(path: K): MutableSettings[K] { + return this.#read(path) as MutableSettings[K] + } + + // NOTE this is deliberately non-generic. With a plain union `path` narrows per case, + // so the default branch can assert exhaustiveness and adding a MutableSettings key + // without a case here fails to compile. Each case still reads exactly one setting, + // so a caller only subscribes to the value it asked for. + #read(path: keyof MutableSettings): MutableSettings[keyof MutableSettings] { switch(path) { case 'ui.media_list.thumbnail_size': { - return this.ui.media_list.thumbnail_size as MutableSettings[K] + return this.ui.media_list.thumbnail_size } case 'ui.media_list.thumbnail_shape': { - return this.ui.media_list.thumbnail_shape as MutableSettings[K] + return this.ui.media_list.thumbnail_shape } case 'ui.search.advanced_filters.hide': { - return this.ui.search.advanced_filters.hide as MutableSettings[K] + return this.ui.search.advanced_filters.hide + } + case 'ui.sidebar.hide': { + return this.ui.sidebar.hide } case 'ui.media_view.fit.mode': { return this.ui.media_view.fit.mode } default: { - throw new Error(`Unexpected path '${path}'`) + const unhandled: never = path + throw new Error(`Unexpected path '${unhandled}'`) } } } diff --git a/packages/web/src/routes/browse/components/MediaDetails.svelte b/packages/web/src/routes/browse/components/MediaDetails.svelte index 4ffd38b5..18ab0316 100644 --- a/packages/web/src/routes/browse/components/MediaDetails.svelte +++ b/packages/web/src/routes/browse/components/MediaDetails.svelte @@ -18,10 +18,17 @@ let new_tag_str = $state('') + // this sidebar renders media reference data (tags, stars, title, file), which grouped + // results do not carry, so they are treated as having nothing to show + let selected_media = $derived.by(() => { + const selection = media_selections.current_selection.media_response + return selection?.media_type === 'grouped' ? undefined : selection + }) + type TagRecord = model_types.Tag let sorted_tags: [string, TagRecord[]][] = $derived.by(() => { const grouped_tags: Record = {} - media_selections.current_selection.media_response?.tags.map(t => { + selected_media?.tags.map(t => { if (grouped_tags[t.group] === undefined) { grouped_tags[t.group] = [] } @@ -71,16 +78,18 @@ if (new_tag_str) { tags = {add: [new_tag_str]} } - media_selections.current_selection.media_response!.update(media_info, tags) + selected_media?.update(media_info, tags) new_tag_str = '' }} > - {#if media_selections.current_selection.media_response} - + {#if selected_media} + - + {#each sorted_tags as tag_group_entry, tag_entry_index (tag_group_entry[0])} @@ -96,13 +105,14 @@ title="Remove from media" type="button" onclick={async () => { - await media_selections.current_selection.media_response!.update(undefined, {remove: [`${tag.group}:${tag.name}`]}) + await selected_media!.update(undefined, {remove: [`${tag.group}:${tag.name}`]}) }}> + title="Apply to search" + params={queryparams.merge({search_string: parsers.Tag.encode(tag)})}> + content={selected_media.media_reference.title}/> + content={selected_media.media_reference.description}/> + content={selected_media.media_reference.source_created_at}/> + content={selected_media.media_reference.created_at}/> + content={selected_media.media_reference.source_url}/> + content={selected_media.media_reference.metadata}/> - {#if media_selections.current_selection.media_response.media_type === 'media_file'} - + {#if selected_media.media_type === 'media_file'} + - + {/if} {:else} diff --git a/packages/web/src/routes/browse/components/SearchLink.svelte b/packages/web/src/routes/browse/components/SearchLink.svelte index dc58f261..b077ccd8 100644 --- a/packages/web/src/routes/browse/components/SearchLink.svelte +++ b/packages/web/src/routes/browse/components/SearchLink.svelte @@ -1,10 +1,11 @@ {#if !(props.hide_if_null && props.content === null)} diff --git a/packages/web/src/routes/tags/+page.svelte b/packages/web/src/routes/tags/+page.svelte index 484fa6bc..df058418 100644 --- a/packages/web/src/routes/tags/+page.svelte +++ b/packages/web/src/routes/tags/+page.svelte @@ -8,6 +8,8 @@ /** @type {import('./$types').PageProps} */ let props = $props() + // the controller is created once per page, from the config the page was loaded with + // svelte-ignore state_referenced_locally contexts.controller.set(new TagsController(props.data.config)) diff --git a/packages/web/src/routes/tags/[slug]/+page.svelte b/packages/web/src/routes/tags/[slug]/+page.svelte index 3d6562eb..d1bd0f19 100644 --- a/packages/web/src/routes/tags/[slug]/+page.svelte +++ b/packages/web/src/routes/tags/[slug]/+page.svelte @@ -11,6 +11,8 @@ import Numeric from '$lib/components/Numeric.svelte'; let props = $props() + // the controller is created once per page, from the config the page was loaded with + // svelte-ignore state_referenced_locally const controller = contexts.controller.set(new TagDetailController(props.data.config)) let alias_input = $state('') From 084b2684c8f9f02a859e7a4174795aa8a08b8ad0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 13:56:11 +0000 Subject: [PATCH 13/18] chore(web): drop redundant tsconfig exclude that overrode sveltekit's generated excludes Co-authored-by: andykais-ai --- packages/web/tsconfig.json | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json index ca731c31..27393233 100644 --- a/packages/web/tsconfig.json +++ b/packages/web/tsconfig.json @@ -12,7 +12,7 @@ "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 + // through the `kit.alias` mapping. `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. @@ -29,14 +29,5 @@ // `svelte-kit sync` compiles into ./.svelte-kit/tsconfig.json for us. Declaring // paths here instead would replace (not merge with) that generated mapping. "allowImportingTsExtensions": true - }, - // NOTE this only trims the `include` globs. Core files reached through an - // `import` are still part of the program and still report diagnostics, so core - // type errors can surface in this package's check. - "exclude": [ - "node_modules/**", - "../node_modules/**", - "../../node_modules/**", - "../core/**" - ] + } } From e08881157ba8260cb71241d0a39636a0898ade3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 13:56:11 +0000 Subject: [PATCH 14/18] docs(core): condense read_lines TextDecoderStream cast note Co-authored-by: andykais-ai --- packages/core/src/lib/file_processor.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index 13a91dbc..642c651e 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -668,13 +668,9 @@ class FileProcessor { } static async * read_lines(stream: ReadableStream) { - // 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. - // NOTE `deno check` passes without this, but @forager/web type-checks core's source - // under the DOM lib, where it does not. Removing this breaks `deno task --cwd - // packages/web check`. + // NOTE `deno check` passes without this cast, but @forager/web type-checks core's source + // under TypeScript's DOM lib, which types TextDecoderStream's writable side as + // WritableStream and rejects piping a Uint8Array stream into it. const decoder = new TextDecoderStream() as ReadableWritablePair const reader = stream.pipeThrough(decoder).getReader() let buffer = '' From e8e40e8ee7a6ae2adff0b5ca29b70297ec374e7e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 13:56:44 +0000 Subject: [PATCH 15/18] refactor(web): share AnyMediaResponse type between media runes Co-authored-by: andykais-ai --- packages/web/src/lib/runes/media_list_rune.svelte.ts | 6 ++---- packages/web/src/lib/runes/media_view_rune.svelte.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) 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 41ead5e9..4c40944a 100644 --- a/packages/web/src/lib/runes/media_list_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_list_rune.svelte.ts @@ -1,11 +1,9 @@ import {Rune} from '$lib/runes/rune.ts' -import type { Forager, MediaResponse, MediaGroupResponse, inputs } from '@forager/core' +import type { Forager, inputs } from '@forager/core' import { MediaViewRune } from '.' -import type { AnyMediaViewRune, MediaSearchInput } from '.' +import type { AnyMediaResponse, AnyMediaViewRune, MediaSearchInput } from '.' -type AnyMediaResponse = MediaResponse | MediaGroupResponse - type Result = | ReturnType | ReturnType 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 a4288b7e..360fc3ed 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -3,7 +3,7 @@ import type { MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupR import type { BaseController } from '$lib/base_controller.ts' -type AnyMediaResponse = MediaResponse | MediaGroupResponse +export type AnyMediaResponse = MediaResponse | MediaGroupResponse /** * The search/group params that produced a given result. These are threaded straight From 39d009d7c56a58c6dc08bd1bfc7e533e816a9975 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 14:04:51 +0000 Subject: [PATCH 16/18] fix(web): do not open the media view for grouped results Co-authored-by: andykais-ai --- .../web/src/routes/browse/runes/media_selections.svelte.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 02afb6cd..e7fc8a13 100644 --- a/packages/web/src/routes/browse/runes/media_selections.svelte.ts +++ b/packages/web/src/routes/browse/runes/media_selections.svelte.ts @@ -84,9 +84,10 @@ export class MediaSelectionsRune extends Rune { } public async open_media() { - if (this.#current_selection.media_response) { - this.#current_selection.show = true - } + const media_response = this.#current_selection.media_response + // a group has no single media to view, its tile link navigates into the group instead + if (!media_response || media_response.media_type === 'grouped') return + this.#current_selection.show = true await this.view_media() } From d9d91cde932925c7416e4bec60fc4fb2c831e332 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 15:06:09 +0000 Subject: [PATCH 17/18] refactor(web): use core input/model types directly and drop base_queryparams note Co-authored-by: andykais-ai --- packages/web/src/lib/runes/base_queryparams.svelte.ts | 3 --- packages/web/src/lib/runes/media_view_rune.svelte.ts | 6 ++---- .../web/src/routes/browse/components/MediaDetails.svelte | 5 ++--- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/web/src/lib/runes/base_queryparams.svelte.ts b/packages/web/src/lib/runes/base_queryparams.svelte.ts index 236b213d..fb84cdfa 100644 --- a/packages/web/src/lib/runes/base_queryparams.svelte.ts +++ b/packages/web/src/lib/runes/base_queryparams.svelte.ts @@ -17,9 +17,6 @@ export abstract class BaseQueryParams> exten public current: TParams = $state() as TParams public draft: TParams = $state() as TParams public current_serialized: string = '?' - // NOTE assigned from the constructor rather than declared as an abstract getter: - // TypeScript forbids reading an abstract member from the base constructor (ts2715), - // and the defaults are needed here to seed `current`/`draft`. public readonly DEFAULTS: TParams constructor(client: BaseController['client'], defaults: TParams) { 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 360fc3ed..bb722e6d 100644 --- a/packages/web/src/lib/runes/media_view_rune.svelte.ts +++ b/packages/web/src/lib/runes/media_view_rune.svelte.ts @@ -1,5 +1,5 @@ import {Rune} from '$lib/runes/rune.ts' -import type { MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupResponse, Forager, inputs, model_types } from '@forager/core' +import type { MediaResponse, MediaFileResponse, MediaSeriesResponse, MediaGroupResponse, inputs, model_types } from '@forager/core' import type { BaseController } from '$lib/base_controller.ts' @@ -10,9 +10,7 @@ export type AnyMediaResponse = MediaResponse | MediaGroupResponse * through from the media list without favoring one search variant over another; the * rune that needs them (the grouped rune) narrows to the shape it requires. */ -export type MediaSearchInput = - | NonNullable[0]> - | Parameters[0] +export type MediaSearchInput = inputs.PaginatedSearch | inputs.PaginatedSearchGroupBy interface State { diff --git a/packages/web/src/routes/browse/components/MediaDetails.svelte b/packages/web/src/routes/browse/components/MediaDetails.svelte index 18ab0316..f508e5ad 100644 --- a/packages/web/src/routes/browse/components/MediaDetails.svelte +++ b/packages/web/src/routes/browse/components/MediaDetails.svelte @@ -25,9 +25,8 @@ return selection?.media_type === 'grouped' ? undefined : selection }) - type TagRecord = model_types.Tag - let sorted_tags: [string, TagRecord[]][] = $derived.by(() => { - const grouped_tags: Record = {} + let sorted_tags: [string, model_types.Tag[]][] = $derived.by(() => { + const grouped_tags: Record = {} selected_media?.tags.map(t => { if (grouped_tags[t.group] === undefined) { grouped_tags[t.group] = [] From d0ef76a618f74436c672150e877ccbfc5c3b3cc0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 15:06:09 +0000 Subject: [PATCH 18/18] fix(ci): comment out stray publish steps that ran in the web test job Co-authored-by: andykais-ai --- .github/workflows/web.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 63d364a3..c88c66b8 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -46,11 +46,11 @@ jobs: # needs: test # steps: - - name: checkout repository - uses: actions/checkout@v6 + # - name: checkout repository + # uses: actions/checkout@v6 - - name: setup - uses: './.github/actions/shared_setup' + # - name: setup + # uses: './.github/actions/shared_setup' # - name: Build # run: deno task build