From 5af80213c7f3aba7e0b08dc9fad209c18411992a Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Fri, 15 May 2026 07:19:21 -0500 Subject: [PATCH] Feat: Extension Settings & Library Filtering (#71) (#72) --- src/api/mutations/extensions.ts | 42 ++ src/api/queries/extensions.ts | 71 +++ .../components/ExtensionCard.svelte | 68 ++- .../extensions/components/Extensions.svelte | 261 +++++---- .../extensions/lib/extensionLibrary.ts | 56 ++ .../panels/ExtensionLibraryPanel.svelte | 185 ++++++ .../panels/ExtensionSettingsPanel.svelte | 526 ++++++++++++++++++ src/shared/chrome/Toaster.svelte | 123 +++- 8 files changed, 1207 insertions(+), 125 deletions(-) create mode 100644 src/features/extensions/lib/extensionLibrary.ts create mode 100644 src/features/extensions/panels/ExtensionLibraryPanel.svelte create mode 100644 src/features/extensions/panels/ExtensionSettingsPanel.svelte diff --git a/src/api/mutations/extensions.ts b/src/api/mutations/extensions.ts index 140ab4c..8e21a01 100644 --- a/src/api/mutations/extensions.ts +++ b/src/api/mutations/extensions.ts @@ -41,6 +41,48 @@ export const UPDATE_SOURCE_PREFERENCE = ` } `; +export const SET_SOURCE_METAS = ` + mutation SetSourceMetas($input: SetSourceMetasInput!) { + setSourceMetas(input: $input) { + metas { sourceId key value } + } + } +`; + +export const DELETE_SOURCE_METAS = ` + mutation DeleteSourceMetas($input: DeleteSourceMetasInput!) { + deleteSourceMetas(input: $input) { + metas { sourceId key value } + } + } +`; + +export const UPDATE_SOURCE_METADATA = ` + mutation UpdateSourceMetadata( + $preUpdateDeleteInput: DeleteSourceMetasInput! + $hasPreUpdateDeletions: Boolean! + $updateInput: SetSourceMetasInput! + $hasUpdates: Boolean! + $postUpdateDeleteInput: DeleteSourceMetasInput! + $hasPostUpdateDeletions: Boolean! + $migrateInput: SetSourceMetasInput! + $isMigration: Boolean! + ) { + preUpdateDeletedMeta: deleteSourceMetas(input: $preUpdateDeleteInput) @include(if: $hasPreUpdateDeletions) { + metas { sourceId key value } + } + updatedMeta: setSourceMetas(input: $updateInput) @include(if: $hasUpdates) { + metas { sourceId key value } + } + postUpdateDeletedMeta: deleteSourceMetas(input: $postUpdateDeleteInput) @include(if: $hasPostUpdateDeletions) { + metas { sourceId key value } + } + migrationMeta: setSourceMetas(input: $migrateInput) @include(if: $isMigration) { + metas { sourceId key value } + } + } +`; + export const SET_SOURCE_META = ` mutation SetSourceMeta($sourceId: LongString!, $key: String!, $value: String!) { setSourceMeta(input: { meta: { sourceId: $sourceId, key: $key, value: $value } }) { diff --git a/src/api/queries/extensions.ts b/src/api/queries/extensions.ts index 9bb9664..446ec37 100644 --- a/src/api/queries/extensions.ts +++ b/src/api/queries/extensions.ts @@ -23,6 +23,77 @@ export const GET_SOURCES = ` nodes { id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl + extension { pkgName } + } + } + } +`; + +export const GET_SOURCE_SETTINGS = ` + query GetSourceSettings($id: LongString!) { + source(id: $id) { + id + displayName + preferences { + ... on CheckBoxPreference { + type: __typename + CheckBoxTitle: title + CheckBoxSummary: summary + CheckBoxDefault: default + CheckBoxCurrentValue: currentValue + key + } + ... on SwitchPreference { + type: __typename + SwitchPreferenceTitle: title + SwitchPreferenceSummary: summary + SwitchPreferenceDefault: default + SwitchPreferenceCurrentValue: currentValue + key + } + ... on ListPreference { + type: __typename + ListPreferenceTitle: title + ListPreferenceSummary: summary + ListPreferenceDefault: default + ListPreferenceCurrentValue: currentValue + entries + entryValues + key + } + ... on EditTextPreference { + type: __typename + EditTextPreferenceTitle: title + EditTextPreferenceSummary: summary + EditTextPreferenceDefault: default + EditTextPreferenceCurrentValue: currentValue + dialogTitle + dialogMessage + key + } + ... on MultiSelectListPreference { + type: __typename + MultiSelectListPreferenceTitle: title + MultiSelectListPreferenceSummary: summary + MultiSelectListPreferenceDefault: default + MultiSelectListPreferenceCurrentValue: currentValue + entries + entryValues + key + } + } + } + } +`; + +export const GET_MIGRATABLE_SOURCES = ` + query GetMigratableSources { + mangas(condition: { inLibrary: true }) { + nodes { + sourceId + source { + id name lang displayName iconUrl isNsfw isConfigurable supportsLatest baseUrl + } } } } diff --git a/src/features/extensions/components/ExtensionCard.svelte b/src/features/extensions/components/ExtensionCard.svelte index fa60ee3..513d620 100644 --- a/src/features/extensions/components/ExtensionCard.svelte +++ b/src/features/extensions/components/ExtensionCard.svelte @@ -1,26 +1,38 @@
-
+ onLibrary(primary.pkgName, base, primary.iconUrl) : undefined} + > {base} {primary.lang.toUpperCase()} + {#if primary.isInstalled} + + 0 ? "fill" : "regular"} /> + {libraryCount > 0 ? libraryCount : 0} + + + {/if} v{primary.versionName}
@@ -39,22 +58,24 @@ {:else if primary.hasUpdate}
- - + +
{:else if primary.isInstalled} - +
+ +
{:else} - + {/if} {#if hasVariants} - {/if} -
+ {#if expanded && hasVariants}
@@ -68,11 +89,11 @@ {#if working.has(v.pkgName)} {:else if v.hasUpdate} - + {:else if v.isInstalled} {:else} - + {/if}
@@ -83,15 +104,18 @@ \ No newline at end of file diff --git a/src/features/extensions/components/Extensions.svelte b/src/features/extensions/components/Extensions.svelte index 2d21da9..5d2c18a 100644 --- a/src/features/extensions/components/Extensions.svelte +++ b/src/features/extensions/components/Extensions.svelte @@ -3,14 +3,21 @@ import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte"; import { gql } from "@api/client"; import { store, addToast } from "@store/state.svelte"; - import { GET_EXTENSIONS, GET_SETTINGS, GET_LOCAL_MANGA } from "@api/queries"; + import { GET_EXTENSIONS, GET_SOURCES, GET_SETTINGS, GET_LOCAL_MANGA, GET_LIBRARY } from "@api/queries"; import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations"; import type { Extension } from "@types/index"; import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers"; - import ExtensionFilters from "./ExtensionFilters.svelte"; - import ExtensionCard from "./ExtensionCard.svelte"; + import { libraryCountByPkg, type LibraryManga, type SourceNode } from "../lib/extensionLibrary"; + import ExtensionFilters from "./ExtensionFilters.svelte"; + import ExtensionCard from "./ExtensionCard.svelte"; + import ExtensionSettingsPanel from "../panels/ExtensionSettingsPanel.svelte"; + import ExtensionLibraryPanel from "../panels/ExtensionLibraryPanel.svelte"; + + const anims = $derived(store.settings.qolAnimations ?? true); + const cols = $derived(store.settings.libraryCols ?? 5); + const cropCovers = $derived(store.settings.cropCovers ?? true); + const statsAlways = $derived(store.settings.statsAlways ?? false); - const anims = $derived(store.settings.qolAnimations ?? true); let tabsEl = $state(undefined); let tabIndicator = $state({ left: 0, width: 0 }); @@ -33,6 +40,15 @@ let expanded = $state(new Set()); let panel = $state(null); + type SourceEntry = { id: string; displayName: string }; + type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] }; + type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string }; + + let settingsTarget = $state(null); + let libraryTarget = $state(null); + let sourcesByPkg = $state>({}); + let libCountByPkg = $state>({}); + $effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); }); let externalUrl = $state(""); @@ -47,8 +63,25 @@ let savingRepos = $state(false); async function load() { - const d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error); - if (d) extensions = d.extensions.nodes; + const [extData, srcData, libData] = await Promise.all([ + gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error), + gql<{ sources: { nodes: SourceNode[] } }>(GET_SOURCES).catch(console.error), + gql<{ mangas: { nodes: LibraryManga[] } }>(GET_LIBRARY).catch(console.error), + ]); + if (extData) extensions = extData.extensions.nodes; + if (srcData) { + const map: Record = {}; + for (const s of srcData.sources.nodes) { + if (!s.isConfigurable || !s.extension?.pkgName) continue; + const pkg = s.extension.pkgName; + if (!map[pkg]) map[pkg] = []; + map[pkg].push({ id: s.id, displayName: s.displayName }); + } + sourcesByPkg = map; + } + if (libData && srcData) { + libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes); + } } async function loadLocalManga() { @@ -213,111 +246,135 @@ function focusOnMount(node: HTMLElement) { node.focus(); } -
- search = q} - onLang={(l) => langFilter = l} - onPanel={openPanel} - onRefresh={fetchFromRepo} - onUpdateAll={updateAll} +{#if libraryTarget} + libraryTarget = null} + onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }} /> +{:else} +
+ search = q} + onLang={(l) => langFilter = l} + onPanel={openPanel} + onRefresh={fetchFromRepo} + onUpdateAll={updateAll} + /> - {#if panel === "apk"} -
-
- Install from APK URL -
-
- installError = null} - onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} - use:focusOnMount - /> - -
- {#if installError}
{installError}
{/if} -
- {/if} - - {#if panel === "repos"} -
-
- Extension Repositories -
- {#if reposLoading} -
- {:else} - {#if repos.length === 0} -
No repos configured.
- {:else} -
- {#each repos as url} -
- {url} - -
- {/each} -
- {/if} + {#if panel === "apk"} +
+
+ Install from APK URL +
repoError = null} - onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} + class="ext-input" class:error={installError} + placeholder="https://example.com/extension.apk" + bind:value={externalUrl} disabled={installing} + oninput={() => installError = null} + onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} + use:focusOnMount /> -
- {#if repoError}
{repoError}
{/if} - {/if} -
- {/if} + {#if installError}
{installError}
{/if} +
+ {/if} - {#if loading} -
- {:else} -
- {#if showLocal} -
-
-
- Local Source - Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"} -
- Built-in + {#if panel === "repos"} +
+
+ Extension Repositories
- {/if} - {#each groups as { base, primary, variants }} - - {/each} - {#if !showLocal && groups.length === 0} -
No extensions found.
- {/if} -
- {/if} -
+ {#if reposLoading} +
+ {:else} + {#if repos.length === 0} +
No repos configured.
+ {:else} +
+ {#each repos as url} +
+ {url} + +
+ {/each} +
+ {/if} +
+ repoError = null} + onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} + /> + +
+ {#if repoError}
{repoError}
{/if} + {/if} +
+ {/if} + + {#if loading} +
+ {:else} +
+ {#if showLocal} +
+
+
+ Local Source + Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"} +
+ Built-in +
+ {/if} + {#each groups as { base, primary, variants }} + libraryTarget = { pkgName, extensionName, iconUrl }} + /> + {/each} + {#if !showLocal && groups.length === 0} +
No extensions found.
+ {/if} +
+ {/if} +
+{/if} + +{#if settingsTarget} + settingsTarget = null} + /> +{/if} \ No newline at end of file diff --git a/src/features/extensions/panels/ExtensionSettingsPanel.svelte b/src/features/extensions/panels/ExtensionSettingsPanel.svelte new file mode 100644 index 0000000..3f34dfc --- /dev/null +++ b/src/features/extensions/panels/ExtensionSettingsPanel.svelte @@ -0,0 +1,526 @@ + + + + + + + \ No newline at end of file diff --git a/src/shared/chrome/Toaster.svelte b/src/shared/chrome/Toaster.svelte index 4ec7a2b..d1b4a36 100644 --- a/src/shared/chrome/Toaster.svelte +++ b/src/shared/chrome/Toaster.svelte @@ -6,6 +6,8 @@ const leaving = new Set(); const timers = new Map>(); + let detail = $state(null); + function schedule(t: Toast) { if (timers.has(t.id)) return; const dur = t.duration ?? 3500; @@ -30,12 +32,23 @@ dismissToast(id); } + function openDetail(e: MouseEvent, t: Toast) { + e.preventDefault(); + detail = t; + if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id); } + } + + function closeDetail() { + detail = null; + } + $effect(() => { const activeIds = new Set(store.toasts.map(t => t.id)); store.toasts.forEach(schedule); for (const [id, timer] of timers) { if (!activeIds.has(id)) { clearTimeout(timer); timers.delete(id); } } + if (detail && !activeIds.has(detail.id)) detail = null; }); const icons: Record = { @@ -49,7 +62,10 @@ {#if store.toasts.length}
{#each store.toasts as t (t.id)} -
{/if} +{#if detail} + +{/if} + \ No newline at end of file