mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Home-Screen Recommendations & GQL Cleanup P.2
This commit is contained in:
@@ -204,7 +204,7 @@
|
|||||||
{#each visibleItems as m, i (m.id)}
|
{#each visibleItems as m, i (m.id)}
|
||||||
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="card-title">{m.title}</p>
|
<p class="card-title">{m.title}</p>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
let kw_inputEl: HTMLInputElement | null = $state(null);
|
let kw_inputEl: HTMLInputElement | null = $state(null);
|
||||||
let kw_abortCtrl: AbortController | null = null;
|
let kw_abortCtrl: AbortController | null = null;
|
||||||
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let kw_localQuery = $state(query);
|
||||||
|
let kw_pending = $state(false);
|
||||||
|
|
||||||
interface SourceResult {
|
interface SourceResult {
|
||||||
source: Source;
|
source: Source;
|
||||||
@@ -57,18 +59,23 @@
|
|||||||
if (!loadingSources && pendingPrefill && allSources.length) {
|
if (!loadingSources && pendingPrefill && allSources.length) {
|
||||||
const q = pendingPrefill;
|
const q = pendingPrefill;
|
||||||
onPrefillConsumed();
|
onPrefillConsumed();
|
||||||
|
kw_localQuery = q;
|
||||||
onQueryChange(q);
|
onQueryChange(q);
|
||||||
kwDoSearch(q);
|
kwDoSearch(q);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
function kwHandleInput(value: string) {
|
||||||
const q = query;
|
kw_localQuery = value;
|
||||||
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
|
||||||
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
|
if (!value.trim()) { kw_abortCtrl?.abort(); kw_results = []; kw_pending = false; onQueryChange(""); return; }
|
||||||
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
|
kw_pending = true;
|
||||||
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
|
kw_debounceTimer = setTimeout(() => {
|
||||||
});
|
kw_pending = false;
|
||||||
|
onQueryChange(value);
|
||||||
|
kwDoSearch(value);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
function kwGetVisibleSources(): Source[] {
|
function kwGetVisibleSources(): Source[] {
|
||||||
let srcs = allSources;
|
let srcs = allSources;
|
||||||
@@ -142,18 +149,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
bind:this={kw_inputEl}
|
bind:this={kw_inputEl}
|
||||||
value={query}
|
value={kw_localQuery}
|
||||||
oninput={(e) => onQueryChange((e.target as HTMLInputElement).value)}
|
oninput={(e) => kwHandleInput((e.target as HTMLInputElement).value)}
|
||||||
class="searchInput"
|
class="searchInput"
|
||||||
placeholder="Search across sources…"
|
placeholder="Search across sources…"
|
||||||
use:focusOnMount
|
|
||||||
/>
|
/>
|
||||||
{#if kw_anyLoading}
|
{#if kw_pending || kw_anyLoading}
|
||||||
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" aria-hidden="true">
|
||||||
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{:else if query}
|
{:else if kw_localQuery}
|
||||||
<button class="clearBtn" title="Clear" onclick={() => { onQueryChange(""); kw_results = []; kw_inputEl?.focus(); }}>×</button>
|
<button class="clearBtn" title="Clear" onclick={() => { kwHandleInput(""); kw_inputEl?.focus(); }}>×</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasMultipleLangs}
|
{#if hasMultipleLangs}
|
||||||
<button
|
<button
|
||||||
@@ -193,7 +199,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !query.trim()}
|
{#if !kw_localQuery.trim()}
|
||||||
{#if popularLoading && popularResults.length === 0}
|
{#if popularLoading && popularResults.length === 0}
|
||||||
<div class="searchGrid">
|
<div class="searchGrid">
|
||||||
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
@@ -206,7 +212,7 @@
|
|||||||
{#each popularResults as m (m.id)}
|
{#each popularResults as m (m.id)}
|
||||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
<div class="srchCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
<div class="srchGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="srchFooter">
|
<div class="srchFooter">
|
||||||
@@ -235,16 +241,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{:else if kw_pending}
|
||||||
|
<div class="searchGrid">
|
||||||
|
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if kw_flatResults.length > 0}
|
{#if kw_flatResults.length > 0}
|
||||||
<div class="searchHeader">
|
<div class="searchHeader">
|
||||||
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
|
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} for "{kw_localQuery.trim()}"</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="searchGrid">
|
<div class="searchGrid">
|
||||||
{#each kw_flatResults as m (m.id)}
|
{#each kw_flatResults as m (m.id)}
|
||||||
<button class="srchCard" onclick={() => onPreview(m)}>
|
<button class="srchCard" onclick={() => onPreview(m)}>
|
||||||
<div class="srchCoverWrap">
|
<div class="srchCoverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} id={m.id} />
|
||||||
<div class="srchGradient"></div>
|
<div class="srchGradient"></div>
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
<div class="srchFooter">
|
<div class="srchFooter">
|
||||||
@@ -264,16 +274,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if kw_allDone && !kw_hasResults}
|
{:else if kw_allDone && !kw_hasResults}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p class="emptyText">No results for "{query.trim()}"</p>
|
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
|
||||||
|
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="emptyText">No results for "{kw_localQuery.trim()}"</p>
|
||||||
<p class="emptyHint">Try a different spelling or fewer words</p>
|
<p class="emptyHint">Try a different spelling or fewer words</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<script module>
|
|
||||||
function focusOnMount(node: HTMLElement) { node.focus(); }
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
|
||||||
|
|||||||
@@ -261,7 +261,7 @@
|
|||||||
{#each src_browseResults as m, i (m.id)}
|
{#each src_browseResults as m, i (m.id)}
|
||||||
<button class="card" onclick={() => onPreview(m)}>
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="cardTitle">{m.title}</p>
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
|||||||
@@ -360,7 +360,7 @@
|
|||||||
{#each tag_mergedResults as m, i (m.id)}
|
{#each tag_mergedResults as m, i (m.id)}
|
||||||
<button class="card" onclick={() => onPreview(m)}>
|
<button class="card" onclick={() => onPreview(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} id={m.id} />
|
||||||
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="cardTitle">{m.title}</p>
|
<p class="cardTitle">{m.title}</p>
|
||||||
|
|||||||
@@ -104,8 +104,7 @@
|
|||||||
async function loadRepos() {
|
async function loadRepos() {
|
||||||
reposLoading = true;
|
reposLoading = true;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ settings: { extensionRepos: string[] } }>(`query GetSettings { settings { extensionRepos } }`);
|
repos = await getAdapter().getExtensionRepos();
|
||||||
repos = d.settings.extensionRepos ?? [];
|
|
||||||
} catch (e) { console.error(e); }
|
} catch (e) { console.error(e); }
|
||||||
finally { reposLoading = false; }
|
finally { reposLoading = false; }
|
||||||
}
|
}
|
||||||
@@ -113,11 +112,11 @@
|
|||||||
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
async function saveRepos(updated: string[], intent: "add" | "remove") {
|
||||||
savingRepos = true;
|
savingRepos = true;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(`mutation SetExtensionRepos($repos: [String!]!) { setSettings(input: { settings: { extensionRepos: $repos } }) { settings { extensionRepos } } }`, { repos: updated });
|
const removed = repos.find(r => !updated.includes(r)) ?? "";
|
||||||
repos = d.setSettings.settings.extensionRepos;
|
repos = await getAdapter().setExtensionRepos(updated);
|
||||||
addToast(intent === "add"
|
addToast(intent === "add"
|
||||||
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
|
||||||
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
|
: { kind: "info", title: "Repo removed", body: removed }
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
repoError = e instanceof Error ? e.message : "Failed to save";
|
repoError = e instanceof Error ? e.message : "Failed to save";
|
||||||
@@ -138,13 +137,11 @@
|
|||||||
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
|
||||||
working = new Set(working).add(pkgName);
|
working = new Set(working).add(pkgName);
|
||||||
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
|
||||||
const gqlArgs = {
|
|
||||||
install: { id: pkgName, install: true },
|
|
||||||
update: { id: pkgName, update: true },
|
|
||||||
uninstall: { id: pkgName, uninstall: true },
|
|
||||||
}[op];
|
|
||||||
try {
|
try {
|
||||||
await getAdapter()[{ install: 'installExtension', update: 'updateExtension', uninstall: 'uninstallExtension' }[op] as 'installExtension'](pkgName);
|
const adapter = getAdapter();
|
||||||
|
if (op === "install") await adapter.installExtension(pkgName);
|
||||||
|
else if (op === "update") await adapter.updateExtension(pkgName);
|
||||||
|
else await adapter.uninstallExtension(pkgName);
|
||||||
await load();
|
await load();
|
||||||
addToast({
|
addToast({
|
||||||
install: { kind: "download" as const, title: "Extension installed", body: label },
|
install: { kind: "download" as const, title: "Extension installed", body: label },
|
||||||
|
|||||||
@@ -66,17 +66,7 @@
|
|||||||
editKey = null;
|
editKey = null;
|
||||||
listOpen = null;
|
listOpen = null;
|
||||||
try {
|
try {
|
||||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
prefs = (await getAdapter().getSourceSettings(src.id)) as Preference[];
|
||||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { 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 }
|
|
||||||
} } }`,
|
|
||||||
{ id: String(src.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,24 +76,9 @@
|
|||||||
|
|
||||||
async function save(position: number, changeType: string, value: unknown) {
|
async function save(position: number, changeType: string, value: unknown) {
|
||||||
if (!activeSource) return;
|
if (!activeSource) return;
|
||||||
const pref = prefs[position];
|
saving = prefs[position].key;
|
||||||
saving = pref.key;
|
|
||||||
try {
|
try {
|
||||||
await (getAdapter() as any).gql(
|
prefs = (await getAdapter().updateSourcePreference(activeSource.id, position, changeType, value)) as Preference[];
|
||||||
`mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { updateSourcePreference(input: { source: $source, change: $change }) { source { id } } }`,
|
|
||||||
{ source: String(activeSource.id), change: { position, [changeType]: value } },
|
|
||||||
);
|
|
||||||
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
|
|
||||||
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
|
|
||||||
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxCurrentValue: currentValue key }
|
|
||||||
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceCurrentValue: currentValue key }
|
|
||||||
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceCurrentValue: currentValue entries entryValues key }
|
|
||||||
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceCurrentValue: currentValue key }
|
|
||||||
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
|
|
||||||
} } }`,
|
|
||||||
{ id: String(activeSource.id) },
|
|
||||||
);
|
|
||||||
prefs = d.source.preferences ?? [];
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface RecommendedManga {
|
|||||||
|
|
||||||
const TOP_GENRES = 6
|
const TOP_GENRES = 6
|
||||||
const TARGET_PER_GENRE = 20
|
const TARGET_PER_GENRE = 20
|
||||||
|
const FALLBACK_GENRES = ['Action', 'Adventure', 'Fantasy', 'Romance', 'Comedy', 'Drama']
|
||||||
|
|
||||||
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
|
||||||
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
const byId = new Map(libraryManga.map(m => [m.id, m]))
|
||||||
@@ -16,8 +17,8 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
|||||||
|
|
||||||
for (const session of history) {
|
for (const session of history) {
|
||||||
const manga = byId.get(session.mangaId)
|
const manga = byId.get(session.mangaId)
|
||||||
if (!manga?.genre?.length) continue
|
if (!manga?.tags?.length) continue
|
||||||
for (const g of manga.genre) {
|
for (const g of manga.tags) {
|
||||||
const key = g.toLowerCase()
|
const key = g.toLowerCase()
|
||||||
const existing = tally.get(key)
|
const existing = tally.get(key)
|
||||||
if (existing) existing.count++
|
if (existing) existing.count++
|
||||||
@@ -25,10 +26,12 @@ export function topGenres(history: ReadSession[], libraryManga: Manga[]): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...tally.values()]
|
const derived = [...tally.values()]
|
||||||
.sort((a, b) => b.count - a.count)
|
.sort((a, b) => b.count - a.count)
|
||||||
.slice(0, TOP_GENRES)
|
.slice(0, TOP_GENRES)
|
||||||
.map(e => e.original)
|
.map(e => e.original)
|
||||||
|
|
||||||
|
return derived.length > 0 ? derived : FALLBACK_GENRES
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchRecommendations(
|
export async function fetchRecommendations(
|
||||||
@@ -36,20 +39,22 @@ export async function fetchRecommendations(
|
|||||||
libraryManga: Manga[],
|
libraryManga: Manga[],
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<RecommendedManga[]> {
|
): Promise<RecommendedManga[]> {
|
||||||
if (!history.length || !libraryManga.length) return []
|
|
||||||
|
|
||||||
const genres = topGenres(history, libraryManga)
|
const genres = topGenres(history, libraryManga)
|
||||||
if (!genres.length) return []
|
|
||||||
|
|
||||||
const adapter = getAdapter()
|
const adapter = getAdapter() as any
|
||||||
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
|
||||||
|
|
||||||
const perGenre = await Promise.all(
|
const perGenre = await Promise.all(
|
||||||
genres.map(async genre => {
|
genres.map(async genre => {
|
||||||
if (signal?.aborted) return []
|
if (signal?.aborted) return []
|
||||||
try {
|
try {
|
||||||
const { items } = await adapter.getMangaList({ tags: [genre], inLibrary: false })
|
const { items } = await adapter.getMangasByGenre(
|
||||||
return items
|
{ genre: { like: `%${genre}%` } },
|
||||||
|
TARGET_PER_GENRE,
|
||||||
|
0,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
return items as Manga[]
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -57,19 +62,21 @@ export async function fetchRecommendations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const merged: Manga[] = []
|
const merged: Manga[] = []
|
||||||
for (const items of perGenre) {
|
outer: for (const items of perGenre) {
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
|
if (signal?.aborted) break outer
|
||||||
if (globalSeen.has(m.id)) continue
|
if (globalSeen.has(m.id)) continue
|
||||||
globalSeen.add(m.id)
|
globalSeen.add(m.id)
|
||||||
merged.push(m)
|
merged.push(m)
|
||||||
if (merged.length >= genres.length * TARGET_PER_GENRE) break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.map(m => ({
|
return merged.map(m => {
|
||||||
manga: m,
|
const mTagsLower = (m.tags ?? []).map(g => g.toLowerCase())
|
||||||
matchedGenres: (m.genre ?? []).filter(g =>
|
const matched = genres.filter(g => mTagsLower.includes(g.toLowerCase()))
|
||||||
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
|
return {
|
||||||
),
|
manga: m,
|
||||||
}))
|
matchedGenres: matched.length > 0 ? matched : [genres[0]],
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
import { CheckSquare, Trash, Folder } from 'phosphor-svelte'
|
||||||
|
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||||
import type { Manga, Category } from '$lib/types'
|
import type { Manga, Category } from '$lib/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -24,15 +25,8 @@
|
|||||||
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
onCardClick, onCardContextMenu, onSelectAll, onExitSelect, onBulkRemove, onBulkMove,
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
const THUMB_BASE = 'http://127.0.0.1:4567'
|
|
||||||
|
|
||||||
let movePanelOpen = $state(false)
|
let movePanelOpen = $state(false)
|
||||||
|
|
||||||
function coverUrl(m: Manga) {
|
|
||||||
const url = m.thumbnailUrl ?? ''
|
|
||||||
return url.startsWith('http') ? url : `${THUMB_BASE}${url}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDocDown(e: MouseEvent) {
|
function onDocDown(e: MouseEvent) {
|
||||||
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
|
||||||
}
|
}
|
||||||
@@ -124,13 +118,7 @@
|
|||||||
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
oncontextmenu={(e) => onCardContextMenu(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap" class:completed={isCompleted}>
|
<div class="cover-wrap" class:completed={isCompleted}>
|
||||||
<img
|
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" id={m.id} />
|
||||||
class="cover"
|
|
||||||
src={coverUrl(m)}
|
|
||||||
alt={m.title}
|
|
||||||
draggable="false"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<div class="overlay">
|
<div class="overlay">
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
{#if isCompleted}
|
{#if isCompleted}
|
||||||
@@ -247,7 +235,7 @@
|
|||||||
}
|
}
|
||||||
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
|
||||||
|
|
||||||
.cover { width: 100%; height: 100%; object-fit: cover; display: block; }
|
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { readerState } from "$lib/state/reader.svelte";
|
import { readerState } from "$lib/state/reader.svelte";
|
||||||
import { settingsState } from "$lib/state/settings.svelte";
|
|
||||||
import { getAdapter } from "$lib/request-manager";
|
import { getAdapter } from "$lib/request-manager";
|
||||||
import type { Chapter } from "$lib/types";
|
import type { Chapter } from "$lib/types";
|
||||||
|
|
||||||
@@ -15,28 +14,6 @@
|
|||||||
|
|
||||||
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
|
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
|
||||||
|
|
||||||
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
|
|
||||||
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
|
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
||||||
const mode = settingsState.settings.serverAuthMode ?? "NONE";
|
|
||||||
if (mode === "BASIC_AUTH") {
|
|
||||||
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
|
|
||||||
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
|
|
||||||
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
|
|
||||||
}
|
|
||||||
const res = await fetch(`${base}/api/graphql`, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ query, variables }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.errors?.length) throw new Error(json.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
|
|
||||||
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
|
|
||||||
|
|
||||||
async function runDl(fn: () => Promise<void>) {
|
async function runDl(fn: () => Promise<void>) {
|
||||||
readerState.dlBusy = true;
|
readerState.dlBusy = true;
|
||||||
try { await fn(); } catch (e) { console.error(e); }
|
try { await fn(); } catch (e) { console.error(e); }
|
||||||
@@ -60,14 +37,14 @@
|
|||||||
<p class="dl-title">Download</p>
|
<p class="dl-title">Download</p>
|
||||||
|
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownload(String(chapter.id)))}>
|
||||||
This chapter
|
This chapter
|
||||||
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dl-row">
|
<div class="dl-row">
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.slice(0, readerState.nextN).map(c => String(c.id))))}>
|
||||||
Next chapters
|
Next chapters
|
||||||
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -79,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
|
||||||
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
|
onclick={() => runDl(() => getAdapter().enqueueDownloads(queueable.map(c => String(c.id))))}>
|
||||||
All remaining
|
All remaining
|
||||||
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
<span class="dl-sub">{queueable.length} not yet downloaded</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
|
||||||
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" />
|
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" id={seriesState.activeManga?.id ?? manga?.id} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
let {
|
let {
|
||||||
src,
|
src,
|
||||||
|
id = undefined,
|
||||||
alt = "",
|
alt = "",
|
||||||
class: cls = "",
|
class: cls = "",
|
||||||
loading = "lazy",
|
loading = "lazy",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
...rest
|
...rest
|
||||||
}: {
|
}: {
|
||||||
src: string | null | undefined;
|
src: string | null | undefined;
|
||||||
|
id?: string | number;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
loading?: string;
|
loading?: string;
|
||||||
@@ -27,10 +29,14 @@
|
|||||||
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : "http://127.0.0.1:4567";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withBust(url: string): string {
|
||||||
|
return id != null ? `${url}${url.includes('?') ? '&' : '?'}id=${id}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
function plainThumbUrl(path: string | null | undefined): string {
|
function plainThumbUrl(path: string | null | undefined): string {
|
||||||
if (!path) return "";
|
if (!path) return "";
|
||||||
if (path.startsWith("http")) return path;
|
const base = path.startsWith("http") ? path : `${getServerUrl()}${path}`;
|
||||||
return `${getServerUrl()}${path}`;
|
return withBust(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
const isAuth = $derived((settingsState.settings.serverAuthMode ?? "NONE") !== "NONE");
|
||||||
@@ -45,15 +51,15 @@
|
|||||||
|
|
||||||
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
if (!_isAuth || !_src) { blobUrl = ""; return; }
|
||||||
|
|
||||||
const id = ++reqId;
|
const myId = ++reqId;
|
||||||
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
const bareUrl = _src.startsWith("http") ? _src : `${getServerUrl()}${_src}`;
|
||||||
getBlobUrl(bareUrl, _priority)
|
getBlobUrl(withBust(bareUrl), _priority)
|
||||||
.then(u => { if (id === reqId) blobUrl = u; })
|
.then(u => { if (myId === reqId) blobUrl = u; })
|
||||||
.catch(() => { if (id === reqId) blobUrl = ""; });
|
.catch(() => { if (myId === reqId) blobUrl = ""; });
|
||||||
});
|
});
|
||||||
|
|
||||||
const plainUrl = $derived(plainThumbUrl(src));
|
const plainUrl = $derived(plainThumbUrl(src));
|
||||||
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
const resolved = $derived(isAuth ? (blobUrl || plainUrl) || undefined : plainUrl || undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
|
||||||
@@ -410,6 +410,18 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
|
await this.gql(INSTALL_EXTERNAL_EXTENSION, { url })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExtensionRepos(): Promise<string[]> {
|
||||||
|
const data = await this.gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS)
|
||||||
|
return data.settings.extensionRepos ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async setExtensionRepos(repos: string[]): Promise<string[]> {
|
||||||
|
const data = await this.gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(
|
||||||
|
SET_EXTENSION_REPOS, { repos }
|
||||||
|
)
|
||||||
|
return data.setSettings.settings.extensionRepos
|
||||||
|
}
|
||||||
|
|
||||||
async getSources(): Promise<Source[]> {
|
async getSources(): Promise<Source[]> {
|
||||||
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||||
return data.sources.nodes
|
return data.sources.nodes
|
||||||
@@ -425,6 +437,29 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getSourceSettings(sourceId: string): Promise<unknown[]> {
|
||||||
|
const data = await this.gql<{ source: { preferences: unknown[] } }>(
|
||||||
|
GET_SOURCE_SETTINGS, { id: sourceId }
|
||||||
|
)
|
||||||
|
return data.source.preferences ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateSourcePreference(
|
||||||
|
sourceId: string,
|
||||||
|
position: number,
|
||||||
|
changeType: string,
|
||||||
|
value: unknown,
|
||||||
|
): Promise<unknown[]> {
|
||||||
|
await this.gql(UPDATE_SOURCE_PREFERENCE, {
|
||||||
|
source: sourceId,
|
||||||
|
change: { position, [changeType]: value },
|
||||||
|
})
|
||||||
|
const data = await this.gql<{ source: { preferences: unknown[] } }>(
|
||||||
|
GET_SOURCE_SETTINGS, { id: sourceId }
|
||||||
|
)
|
||||||
|
return data.source.preferences ?? []
|
||||||
|
}
|
||||||
|
|
||||||
async getCategories(): Promise<Category[]> {
|
async getCategories(): Promise<Category[]> {
|
||||||
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
||||||
return data.categories.nodes.map(mapCategory)
|
return data.categories.nodes.map(mapCategory)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const LIBRARY_UPDATE_STATUS = `
|
|||||||
|
|
||||||
export const MANGAS_BY_GENRE = `
|
export const MANGAS_BY_GENRE = `
|
||||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
|
mangas(filter: $filter, first: $first, offset: $offset) {
|
||||||
nodes {
|
nodes {
|
||||||
id title thumbnailUrl inLibrary genre status
|
id title thumbnailUrl inLibrary genre status
|
||||||
source { id displayName }
|
source { id displayName }
|
||||||
|
|||||||
@@ -175,9 +175,13 @@ export interface ServerAdapter {
|
|||||||
updateExtension(id: string): Promise<void>
|
updateExtension(id: string): Promise<void>
|
||||||
updateExtensions(ids: string[]): Promise<void>
|
updateExtensions(ids: string[]): Promise<void>
|
||||||
installExternalExtension(url: string): Promise<void>
|
installExternalExtension(url: string): Promise<void>
|
||||||
|
getExtensionRepos(): Promise<string[]>
|
||||||
|
setExtensionRepos(repos: string[]): Promise<string[]>
|
||||||
|
|
||||||
getSources(): Promise<Source[]>
|
getSources(): Promise<Source[]>
|
||||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||||
|
getSourceSettings(sourceId: string): Promise<unknown[]>
|
||||||
|
updateSourcePreference(sourceId: string, position: number, changeType: string, value: unknown): Promise<unknown[]>
|
||||||
|
|
||||||
getCategories(): Promise<Category[]>
|
getCategories(): Promise<Category[]>
|
||||||
createCategory(name: string): Promise<Category>
|
createCategory(name: string): Promise<Category>
|
||||||
|
|||||||
Reference in New Issue
Block a user