mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
chore: ported over extensions & settings
This commit is contained in:
@@ -1 +1,354 @@
|
||||
<div>Extensions.svelte</div>
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
|
||||
import { settings } from "../../store";
|
||||
import type { Extension } from "../../lib/types";
|
||||
|
||||
type Filter = "installed" | "available" | "updates" | "all";
|
||||
type Panel = null | "apk" | "repos";
|
||||
|
||||
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
|
||||
|
||||
let extensions: Extension[] = [];
|
||||
let loading = true;
|
||||
let refreshing = false;
|
||||
let filter: Filter = "installed";
|
||||
let search = "";
|
||||
let working = new Set<string>();
|
||||
let expanded = new Set<string>();
|
||||
let panel: Panel = null;
|
||||
let externalUrl = "";
|
||||
let installing = false;
|
||||
let installError: string|null = null;
|
||||
let installSuccess = false;
|
||||
let repos: string[] = [];
|
||||
let reposLoading = false;
|
||||
let newRepoUrl = "";
|
||||
let repoError: string|null = null;
|
||||
let savingRepos = false;
|
||||
|
||||
async function load() {
|
||||
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
|
||||
.then((d) => extensions = d.extensions.nodes).catch(console.error);
|
||||
}
|
||||
|
||||
async function fetchFromRepo() {
|
||||
refreshing = true;
|
||||
return gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
|
||||
.then((d) => extensions = d.fetchExtensions.extensions).catch(console.error)
|
||||
.finally(() => refreshing = false);
|
||||
}
|
||||
|
||||
async function loadRepos() {
|
||||
reposLoading = true;
|
||||
try { const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS); repos = d.settings.extensionRepos ?? []; }
|
||||
catch (e) { console.error(e); } finally { reposLoading = false; }
|
||||
}
|
||||
|
||||
async function saveRepos(updated: string[]) {
|
||||
savingRepos = true;
|
||||
try { const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated }); repos = d.setSettings.settings.extensionRepos; }
|
||||
catch (e: any) { repoError = e instanceof Error ? e.message : "Failed to save"; } finally { savingRepos = false; }
|
||||
}
|
||||
|
||||
function addRepo() {
|
||||
const url = newRepoUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { repoError = "URL must start with http:// or https://"; return; }
|
||||
if (repos.includes(url)) { repoError = "Repo already added"; return; }
|
||||
repoError = null; newRepoUrl = "";
|
||||
saveRepos([...repos, url]);
|
||||
}
|
||||
|
||||
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
|
||||
|
||||
async function mutate(fn: () => Promise<unknown>, pkgName: string) {
|
||||
working = new Set(working).add(pkgName);
|
||||
await fn().catch(console.error);
|
||||
await load();
|
||||
working.delete(pkgName); working = new Set(working);
|
||||
}
|
||||
|
||||
async function installExternal() {
|
||||
const url = externalUrl.trim();
|
||||
if (!url) return;
|
||||
if (!url.startsWith("http://") && !url.startsWith("https://")) { installError = "URL must start with http:// or https://"; return; }
|
||||
if (!url.endsWith(".apk")) { installError = "URL must point to an .apk file"; return; }
|
||||
installing = true; installError = null; installSuccess = false;
|
||||
try {
|
||||
await gql(INSTALL_EXTERNAL_EXTENSION, { url });
|
||||
installSuccess = true; externalUrl = "";
|
||||
await load();
|
||||
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
|
||||
} catch (e: any) { installError = e instanceof Error ? e.message : "Install failed"; }
|
||||
finally { installing = false; }
|
||||
}
|
||||
|
||||
function openPanel(p: Panel) {
|
||||
panel = panel === p ? null : p;
|
||||
installError = null; installSuccess = false; externalUrl = "";
|
||||
repoError = null; newRepoUrl = "";
|
||||
if (p === "repos") loadRepos();
|
||||
}
|
||||
|
||||
onMount(() => { fetchFromRepo().finally(() => loading = false); });
|
||||
|
||||
$: filtered = extensions.filter((e) => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
|
||||
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
|
||||
return matchSearch && matchFilter;
|
||||
});
|
||||
|
||||
$: groups = (() => {
|
||||
const map = new Map<string, Extension[]>();
|
||||
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
|
||||
const preferredLang = $settings.preferredExtensionLang;
|
||||
return Array.from(map.entries()).map(([base, all]) => {
|
||||
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
|
||||
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
|
||||
});
|
||||
})();
|
||||
|
||||
$: updateCount = extensions.filter((e) => e.hasUpdate).length;
|
||||
|
||||
const FILTERS: { id: Filter; label: string }[] = [
|
||||
{ id: "installed", label: "Installed" },
|
||||
{ id: "available", label: "Available" },
|
||||
{ id: "updates", label: "Updates" },
|
||||
{ id: "all", label: "All" },
|
||||
];
|
||||
|
||||
function toggleExpand(base: string) {
|
||||
const next = new Set(expanded);
|
||||
next.has(base) ? next.delete(base) : next.add(base);
|
||||
expanded = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Extensions</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" class:active={panel === "repos"} on:click={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" class:active={panel === "apk"} on:click={() => openPanel("apk")} title="Install from URL">
|
||||
<Plus size={14} weight="light" />
|
||||
</button>
|
||||
<button class="icon-btn" on:click={fetchFromRepo} disabled={refreshing} title="Refresh repo">
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if panel === "apk"}
|
||||
<div class="ext-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Install from APK URL</span>
|
||||
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<div class="ext-row">
|
||||
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
|
||||
bind:value={externalUrl} disabled={installing}
|
||||
on:input={() => installError = null}
|
||||
on:keydown={(e) => e.key === "Enter" && !installing && installExternal()}
|
||||
use:focusEl />
|
||||
<button class="install-btn" class:success={installSuccess} on:click={installExternal} disabled={installing || !externalUrl.trim()}>
|
||||
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
{:else if installSuccess}<Check size={13} weight="bold" /> Done
|
||||
{:else}Install{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if installError}<div class="panel-error">{installError}</div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panel === "repos"}
|
||||
<div class="ext-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">Extension Repositories</span>
|
||||
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
{#if reposLoading}
|
||||
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else}
|
||||
{#if repos.length === 0}
|
||||
<div class="repo-empty">No repos configured.</div>
|
||||
{:else}
|
||||
<div class="repo-list">
|
||||
{#each repos as url}
|
||||
<div class="repo-row">
|
||||
<span class="repo-url">{url}</span>
|
||||
<button class="repo-remove" on:click={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
|
||||
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="ext-row" style="margin-top:var(--sp-2)">
|
||||
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
|
||||
bind:value={newRepoUrl} disabled={savingRepos}
|
||||
on:input={() => repoError = null}
|
||||
on:keydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
|
||||
<button class="install-btn" on:click={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
|
||||
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} on:click={() => filter = f.id}>
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="empty">No extensions found.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each groups as { base, primary, variants }}
|
||||
{@const isExpanded = expanded.has(base)}
|
||||
{@const hasVariants = variants.length > 0}
|
||||
<div class="group">
|
||||
<div class="row">
|
||||
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<div class="info">
|
||||
<span class="name">{base}</span>
|
||||
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
|
||||
</div>
|
||||
{#if primary.hasUpdate}<span class="update-badge">Update</span>{/if}
|
||||
{#if working.has(primary.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if primary.hasUpdate}
|
||||
<div class="row-actions">
|
||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
|
||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||
</div>
|
||||
{:else if primary.isInstalled}
|
||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
|
||||
{/if}
|
||||
{#if hasVariants}
|
||||
<button class="expand-btn" on:click={() => toggleExpand(base)} title="{variants.length + 1} languages">
|
||||
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
<span class="expand-count">{variants.length + 1}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isExpanded && hasVariants}
|
||||
<div class="variants">
|
||||
{#each variants as v}
|
||||
<div class="variant-row">
|
||||
<span class="lang-tag">{v.lang.toUpperCase()}</span>
|
||||
<span class="variant-name">{v.name}</span>
|
||||
<span class="variant-version">v{v.versionName}</span>
|
||||
{#if v.hasUpdate}<span class="update-badge-small">↑</span>{/if}
|
||||
<div class="variant-actions">
|
||||
{#if working.has(v.pkgName)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else if v.hasUpdate}
|
||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
|
||||
{:else if v.isInstalled}
|
||||
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<script context="module">
|
||||
function focusEl(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.header-actions { display: flex; gap: var(--sp-1); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.4; }
|
||||
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
|
||||
.ext-row { display: flex; gap: var(--sp-2); }
|
||||
.ext-input { flex: 1; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||
.ext-input:focus { border-color: var(--border-focus); }
|
||||
.ext-input:disabled { opacity: 0.5; }
|
||||
.ext-input.error { border-color: var(--color-error) !important; }
|
||||
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
|
||||
.install-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.install-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
|
||||
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-3); }
|
||||
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
|
||||
.repo-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
|
||||
.controls { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-6) var(--sp-3); gap: var(--sp-3); flex-shrink: 0; }
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
|
||||
.group { display: flex; flex-direction: column; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
|
||||
.update-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0; }
|
||||
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
|
||||
.row-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
.action-btn:hover { filter: brightness(1.1); }
|
||||
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
|
||||
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); animation: fadeIn 0.1s ease both; }
|
||||
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.variant-row:hover { background: var(--bg-raised); }
|
||||
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.variant-actions { flex-shrink: 0; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/util";
|
||||
import { settings, genreFilter, previewManga, addFolder, assignMangaToFolder } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const INITIAL_PAGES = 3;
|
||||
const MAX_SOURCES = 12;
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
function parseTags(f: string): string[] { return f.split("+").map((t) => t.trim()).filter(Boolean); }
|
||||
function tagsLabel(tags: string[]): string {
|
||||
if (tags.length === 1) return tags[0];
|
||||
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
|
||||
}
|
||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||
const g = (m.genre ?? []).map((x) => x.toLowerCase());
|
||||
return tags.every((t) => g.includes(t.toLowerCase()));
|
||||
}
|
||||
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
|
||||
let i = 0;
|
||||
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||
}
|
||||
|
||||
$: tags = parseTags($genreFilter);
|
||||
$: primaryTag = tags[0] ?? "";
|
||||
$: label = tagsLabel(tags);
|
||||
|
||||
let libraryManga: Manga[] = [];
|
||||
let sourceManga: Manga[] = [];
|
||||
let loadingInitial = true;
|
||||
let loadingMore = false;
|
||||
let visibleCount = PAGE_SIZE;
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||
|
||||
const nextPageMap = new Map<string, number>();
|
||||
let sources: Source[] = [];
|
||||
let abortCtrl: AbortController | null = null;
|
||||
|
||||
$: filtered = (() => {
|
||||
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
|
||||
const libIds = new Set(libMatches.map((m) => m.id));
|
||||
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
|
||||
})();
|
||||
|
||||
$: visibleItems = filtered.slice(0, visibleCount);
|
||||
$: hasMoreVisible = visibleCount < filtered.length;
|
||||
$: hasMoreNetwork = sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||
$: hasMore = hasMoreVisible || hasMoreNetwork;
|
||||
|
||||
$: if ($genreFilter) load($genreFilter);
|
||||
|
||||
async function load(filter: string) {
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
loadingInitial = true;
|
||||
sourceManga = [];
|
||||
libraryManga = [];
|
||||
visibleCount = PAGE_SIZE;
|
||||
nextPageMap.clear();
|
||||
|
||||
const preferredLang = $settings.preferredExtensionLang || "en";
|
||||
const t = parseTags(filter);
|
||||
const pt = t[0] ?? "";
|
||||
|
||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
Promise.all([gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA), gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)])
|
||||
.then(([all, lib]) => { const m = new Map(lib.mangas.nodes.map((x) => [x.id, x])); return all.mangas.nodes.map((x) => m.get(x.id) ?? x); })
|
||||
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
|
||||
|
||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
|
||||
Infinity,
|
||||
).then(async (allSources) => {
|
||||
const srcs = allSources.slice(0, MAX_SOURCES);
|
||||
sources = srcs;
|
||||
for (const src of srcs) nextPageMap.set(src.id, -1);
|
||||
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", t);
|
||||
const pageItems: Manga[] = [];
|
||||
for (let page = 1; page <= INITIAL_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal)
|
||||
.then((d) => d.fetchSourceManga),
|
||||
).catch(() => null);
|
||||
if (!result || ctrl.signal.aborted) break;
|
||||
ps.add(page);
|
||||
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
|
||||
pageItems.push(...matching);
|
||||
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
|
||||
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageItems.length > 0) {
|
||||
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
|
||||
loadingInitial = false;
|
||||
}
|
||||
}, ctrl.signal);
|
||||
|
||||
if (!ctrl.signal.aborted) loadingInitial = false;
|
||||
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
|
||||
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
|
||||
if (!srcs.length) return;
|
||||
loadingMore = true;
|
||||
abortCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortCtrl = ctrl;
|
||||
try {
|
||||
await runConcurrent(srcs, async (src) => {
|
||||
const page = nextPageMap.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
const ps = getPageSet(src.id, "SEARCH", tags);
|
||||
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
|
||||
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
|
||||
pageKey,
|
||||
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal)
|
||||
.then((d) => d.fetchSourceManga),
|
||||
).catch(() => { nextPageMap.set(src.id, -1); return null; });
|
||||
if (!result || ctrl.signal.aborted) return;
|
||||
ps.add(page);
|
||||
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
|
||||
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
|
||||
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
|
||||
...($settings.folders.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...$settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
];
|
||||
}
|
||||
|
||||
onDestroy(() => abortCtrl?.abort());
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" on:click={() => genreFilter.set("")}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Back</span>
|
||||
</button>
|
||||
<span class="title">{label}</span>
|
||||
{#if !loadingInitial || filtered.length > 0}
|
||||
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
|
||||
{/if}
|
||||
{#if !loadingInitial && hasMoreNetwork}
|
||||
<span class="loading-hint">More loading…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loadingInitial && filtered.length === 0}
|
||||
<div class="grid">
|
||||
{#each Array(50) as _}
|
||||
<div class="card-skeleton">
|
||||
<div class="cover-skeleton skeleton"></div>
|
||||
<div class="title-skeleton skeleton"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="empty">No manga found for "{label}".</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each visibleItems as m (m.id)}
|
||||
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
|
||||
</div>
|
||||
<p class="card-title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
{#if hasMore}
|
||||
<div class="show-more-cell">
|
||||
<button class="show-more-btn" on:click={loadMore} disabled={loadingMore}>
|
||||
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .card-title { color: var(--text-primary); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
|
||||
.show-more-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -1,3 +1,704 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { settings, settingsOpen, history, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab } from "../../store";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode, Theme } from "../../store";
|
||||
import type { Keybinds } from "../../lib/keybinds";
|
||||
|
||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "general", label: "General", icon: Gear },
|
||||
{ id: "appearance", label: "Appearance", icon: PaintBrush },
|
||||
{ id: "reader", label: "Reader", icon: Book },
|
||||
{ id: "library", label: "Library", icon: Image },
|
||||
{ id: "performance",label: "Performance", icon: Sliders },
|
||||
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
||||
{ id: "folders", label: "Folders", icon: FolderSimple },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
||||
];
|
||||
|
||||
const THEMES: { id: Theme; label: string; description: string; swatches: string[] }[] = [
|
||||
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
|
||||
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
|
||||
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
|
||||
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
|
||||
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
|
||||
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
|
||||
];
|
||||
|
||||
let tab: Tab = "general";
|
||||
let contentBodyEl: HTMLDivElement;
|
||||
|
||||
$: { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); }
|
||||
|
||||
function close() { settingsOpen.set(false); }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
|
||||
onMount(() => window.addEventListener("keydown", onKey));
|
||||
onDestroy(() => window.removeEventListener("keydown", onKey));
|
||||
|
||||
// ── Keybinds ─────────────────────────────────────────────────────────────────
|
||||
let listeningKey: keyof Keybinds | null = null;
|
||||
|
||||
function startListen(key: keyof Keybinds) {
|
||||
listeningKey = listeningKey === key ? null : key;
|
||||
}
|
||||
|
||||
function onKeyCapture(e: KeyboardEvent) {
|
||||
if (!listeningKey) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const bind = eventToKeybind(e);
|
||||
if (!bind) return;
|
||||
updateSettings({ keybinds: { ...$settings.keybinds, [listeningKey]: bind } });
|
||||
listeningKey = null;
|
||||
}
|
||||
|
||||
$: if (listeningKey) {
|
||||
window.addEventListener("keydown", onKeyCapture, true);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeyCapture, true);
|
||||
}
|
||||
|
||||
// ── Storage ───────────────────────────────────────────────────────────────────
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
|
||||
let storageInfo: StorageInfo | null = null;
|
||||
let storageLoading = false;
|
||||
let storageError: string | null = null;
|
||||
let clearing = false;
|
||||
let cleared = false;
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null;
|
||||
try {
|
||||
const pathData = await gql<{ settings: { downloadsPath: string } }>(GET_DOWNLOADS_PATH);
|
||||
storageInfo = await invoke<StorageInfo>("get_storage_info", { downloadsPath: pathData.settings.downloadsPath });
|
||||
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
|
||||
finally { storageLoading = false; }
|
||||
}
|
||||
|
||||
$: if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage();
|
||||
|
||||
function handleClearCache() {
|
||||
clearing = true;
|
||||
caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))).catch(() => {})
|
||||
.finally(() => { clearing = false; cleared = true; setTimeout(() => cleared = false, 2500); fetchStorage(); });
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B","KB","MB","GB","TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
// ── Folders ────────────────────────────────────────────────────────────────────
|
||||
let newFolderName = "";
|
||||
let editingId: string | null = null;
|
||||
let editingName = "";
|
||||
|
||||
function createFolder() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name) return;
|
||||
addFolder(name); newFolderName = "";
|
||||
}
|
||||
|
||||
function startEdit(id: string, name: string) { editingId = id; editingName = name; }
|
||||
|
||||
function commitEdit() {
|
||||
if (editingId && editingName.trim()) renameFolder(editingId, editingName.trim());
|
||||
editingId = null; editingName = "";
|
||||
}
|
||||
|
||||
// ── Select dropdown ────────────────────────────────────────────────────────────
|
||||
let selectOpen: string | null = null;
|
||||
|
||||
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
|
||||
|
||||
function onSelectOutside(e: MouseEvent) {
|
||||
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
|
||||
}
|
||||
|
||||
onMount(() => document.addEventListener("mousedown", onSelectOutside));
|
||||
onDestroy(() => document.removeEventListener("mousedown", onSelectOutside));
|
||||
|
||||
// ── DevTools ──────────────────────────────────────────────────────────────────
|
||||
let splashTriggered = false;
|
||||
function triggerSplash() {
|
||||
splashTriggered = true;
|
||||
setTimeout(() => splashTriggered = false, 200);
|
||||
(window as any).__mokuShowSplash?.();
|
||||
}
|
||||
</script>
|
||||
<div>Settings stub</div>
|
||||
|
||||
<div class="backdrop" on:click={(e) => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Settings">
|
||||
<div class="sidebar">
|
||||
<p class="modal-title">Settings</p>
|
||||
<nav class="nav">
|
||||
{#each TABS as t}
|
||||
<button class="nav-item" class:active={tab === t.id} on:click={() => tab = t.id}>
|
||||
<svelte:component this={t.icon} size={14} weight="light" />
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
|
||||
<button class="close-btn" on:click={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="content-body" bind:this={contentBodyEl}>
|
||||
|
||||
<!-- GENERAL -->
|
||||
{#if tab === "general"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Interface Scale</p>
|
||||
<div class="scale-row">
|
||||
<input type="range" min={70} max={150} step={5} value={$settings.uiScale}
|
||||
on:input={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
|
||||
<span class="scale-val">{$settings.uiScale}%</span>
|
||||
<button class="step-btn" on:click={() => updateSettings({ uiScale: 100 })} disabled={$settings.uiScale === 100} title="Reset">↺</button>
|
||||
</div>
|
||||
<p class="scale-hint">
|
||||
{#each [70,80,90,100,110,125,150] as v}
|
||||
<button class="scale-preset" class:active={$settings.uiScale === v} on:click={() => updateSettings({ uiScale: v })}>{v}%</button>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Server</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
|
||||
<input class="text-input" value={$settings.serverUrl ?? "http://localhost:4567"} on:input={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
|
||||
</div>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Server binary</span><span class="toggle-desc">Path or command to launch tachidesk-server</span></div>
|
||||
<input class="text-input" value={$settings.serverBinary} on:input={(e) => updateSettings({ serverBinary: e.currentTarget.value })} placeholder="tachidesk-server" spellcheck="false" />
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||
<button role="switch" aria-checked={$settings.autoStartServer} class="toggle" class:on={$settings.autoStartServer} on:click={() => updateSettings({ autoStartServer: !$settings.autoStartServer })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Inactivity</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
|
||||
<div class="select-wrap" id="idle-timeout">
|
||||
<button class="select-btn" on:click={() => toggleSelect("idle-timeout")}>
|
||||
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String($settings.idleTimeoutMin ?? 5)] ?? `${$settings.idleTimeoutMin} min`}</span>
|
||||
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "idle-timeout"}
|
||||
<div class="select-menu">
|
||||
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
|
||||
<button class="select-option" class:active={String($settings.idleTimeoutMin ?? 5) === v} on:click={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- APPEARANCE -->
|
||||
{:else if tab === "appearance"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Theme</p>
|
||||
<div class="theme-grid">
|
||||
{#each THEMES as theme}
|
||||
{@const active = ($settings.theme ?? "dark") === theme.id}
|
||||
<button class="theme-card" class:active on:click={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
||||
<div class="theme-preview">
|
||||
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
|
||||
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
||||
<div class="theme-preview-content">
|
||||
<div class="theme-preview-accent" style="background:{theme.swatches[2]}"></div>
|
||||
<div class="theme-preview-text" style="background:{theme.swatches[3]}55"></div>
|
||||
<div class="theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-card-info">
|
||||
<span class="theme-card-label">{theme.label}</span>
|
||||
<span class="theme-card-desc">{theme.description}</span>
|
||||
</div>
|
||||
{#if active}<span class="theme-card-check">✓</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- READER -->
|
||||
{:else if tab === "reader"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Page Layout</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
|
||||
<div class="select-wrap" id="page-style">
|
||||
<button class="select-btn" on:click={() => toggleSelect("page-style")}>
|
||||
<span>{{ "single":"Single page","longstrip":"Long strip" }[$settings.pageStyle === "double" ? "single" : $settings.pageStyle]}</span>
|
||||
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "page-style"}
|
||||
<div class="select-menu">
|
||||
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
|
||||
<button class="select-option" class:active={($settings.pageStyle === "double" ? "single" : $settings.pageStyle) === v} on:click={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
||||
<div class="select-wrap" id="reading-dir">
|
||||
<button class="select-btn" on:click={() => toggleSelect("reading-dir")}>
|
||||
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[$settings.readingDirection]}</span>
|
||||
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "reading-dir"}
|
||||
<div class="select-menu">
|
||||
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
|
||||
<button class="select-option" class:active={$settings.readingDirection === v} on:click={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
|
||||
<button role="switch" aria-checked={$settings.pageGap} class="toggle" class:on={$settings.pageGap} on:click={() => updateSettings({ pageGap: !$settings.pageGap })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Fit & Zoom</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
|
||||
<div class="select-wrap" id="fit-mode">
|
||||
<button class="select-btn" on:click={() => toggleSelect("fit-mode")}>
|
||||
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[$settings.fitMode ?? "width"]}</span>
|
||||
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "fit-mode"}
|
||||
<div class="select-menu">
|
||||
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
|
||||
<button class="select-option" class:active={($settings.fitMode ?? "width") === v} on:click={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
|
||||
<div class="step-controls">
|
||||
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.max(200, ($settings.maxPageWidth ?? 900) - 100) })}>−</button>
|
||||
<span class="step-val">{$settings.maxPageWidth ?? 900}px</span>
|
||||
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.min(2400, ($settings.maxPageWidth ?? 900) + 100) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
|
||||
<button role="switch" aria-checked={$settings.optimizeContrast} class="toggle" class:on={$settings.optimizeContrast} on:click={() => updateSettings({ optimizeContrast: !$settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Behaviour</p>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
|
||||
<button role="switch" aria-checked={$settings.autoMarkRead} class="toggle" class:on={$settings.autoMarkRead} on:click={() => updateSettings({ autoMarkRead: !$settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
|
||||
<button role="switch" aria-checked={$settings.autoNextChapter ?? false} class="toggle" class:on={$settings.autoNextChapter} on:click={() => updateSettings({ autoNextChapter: !($settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if !($settings.autoNextChapter ?? false)}
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
|
||||
<button role="switch" aria-checked={$settings.markReadOnNext ?? true} class="toggle" class:on={$settings.markReadOnNext ?? true} on:click={() => updateSettings({ markReadOnNext: !($settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
|
||||
<div class="step-controls">
|
||||
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.max(0, $settings.preloadPages - 1) })} disabled={$settings.preloadPages <= 0}>−</button>
|
||||
<span class="step-val">{$settings.preloadPages}</span>
|
||||
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.min(10, $settings.preloadPages + 1) })} disabled={$settings.preloadPages >= 10}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LIBRARY -->
|
||||
{:else if tab === "library"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Display</p>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
|
||||
<button role="switch" aria-checked={$settings.libraryCropCovers} class="toggle" class:on={$settings.libraryCropCovers} on:click={() => updateSettings({ libraryCropCovers: !$settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
|
||||
<button role="switch" aria-checked={$settings.showNsfw} class="toggle" class:on={$settings.showNsfw} on:click={() => updateSettings({ showNsfw: !$settings.showNsfw })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Chapters</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
|
||||
<div class="select-wrap" id="sort-dir">
|
||||
<button class="select-btn" on:click={() => toggleSelect("sort-dir")}>
|
||||
<span>{{ "desc":"Newest first","asc":"Oldest first" }[$settings.chapterSortDir]}</span>
|
||||
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === "sort-dir"}
|
||||
<div class="select-menu">
|
||||
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
|
||||
<button class="select-option" class:active={$settings.chapterSortDir === v} on:click={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">History</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{$history.length} entries stored</span></div>
|
||||
<button class="danger-btn" on:click={() => history.set([])} disabled={$history.length === 0}>Clear history</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERFORMANCE -->
|
||||
{:else if tab === "performance"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Rendering</p>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
|
||||
<button role="switch" aria-checked={$settings.gpuAcceleration} class="toggle" class:on={$settings.gpuAcceleration} on:click={() => updateSettings({ gpuAcceleration: !$settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Idle / Splash Screen</p>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
|
||||
<button role="switch" aria-checked={$settings.splashCards ?? true} class="toggle" class:on={$settings.splashCards ?? true} on:click={() => updateSettings({ splashCards: !($settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Interface</p>
|
||||
<label class="toggle-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
|
||||
<button role="switch" aria-checked={$settings.compactSidebar} class="toggle" class:on={$settings.compactSidebar} on:click={() => updateSettings({ compactSidebar: !$settings.compactSidebar })}><span class="toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KEYBINDS -->
|
||||
{:else if tab === "keybinds"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<div class="kb-header">
|
||||
<p class="section-title">Keyboard shortcuts</p>
|
||||
<button class="reset-all-btn" on:click={resetKeybinds}>Reset all</button>
|
||||
</div>
|
||||
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
|
||||
<div class="kb-list">
|
||||
{#each Object.keys(KEYBIND_LABELS) as key}
|
||||
{@const k = key as keyof Keybinds}
|
||||
{@const isListening = listeningKey === k}
|
||||
{@const isDefault = $settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
|
||||
<div class="kb-row">
|
||||
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
|
||||
<div class="kb-right">
|
||||
<button class="kb-bind" class:listening={isListening} on:click={() => startListen(k)}>
|
||||
{isListening ? "Press key…" : $settings.keybinds[k]}
|
||||
</button>
|
||||
<button class="kb-reset" on:click={() => updateSettings({ keybinds: { ...$settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STORAGE -->
|
||||
{:else if tab === "storage"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Disk Usage</p>
|
||||
{#if storageLoading}<p class="storage-loading">Reading filesystem…</p>
|
||||
{:else if storageError}<p class="storage-loading" style="color:var(--color-error)">{storageError}</p>
|
||||
{:else if storageInfo}
|
||||
{@const mangaBytes = storageInfo.manga_bytes}
|
||||
{@const totalBytes = storageInfo.total_bytes}
|
||||
{@const freeBytes = storageInfo.free_bytes}
|
||||
{@const limitGb = $settings.storageLimitGb ?? null}
|
||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||
{@const available = mangaBytes + freeBytes}
|
||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||
{@const pctUsed = cap > 0 ? Math.min(100, (mangaBytes / cap) * 100) : 0}
|
||||
<div class="storage-bar-wrap">
|
||||
<div class="storage-bar">
|
||||
<div class="storage-bar-fill" class:critical={pctUsed > 90} class:warn={pctUsed > 75 && pctUsed <= 90} style="width:{pctUsed}%"></div>
|
||||
</div>
|
||||
<div class="storage-bar-labels">
|
||||
<span class="storage-bar-used">{fmtBytes(mangaBytes)} used</span>
|
||||
<span class="storage-bar-free">{fmtBytes(Math.max(0, cap - mangaBytes))} free</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-legend">
|
||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-manga"></span><span class="storage-legend-label">Downloaded manga</span><span class="storage-legend-val">{fmtBytes(mangaBytes)}</span></div>
|
||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-free"></span><span class="storage-legend-label">Drive free</span><span class="storage-legend-val">{fmtBytes(freeBytes)}</span></div>
|
||||
<div class="storage-legend-row"><span class="storage-dot storage-dot-app"></span><span class="storage-legend-label">Drive total</span><span class="storage-legend-val">{fmtBytes(totalBytes)}</span></div>
|
||||
</div>
|
||||
<p class="storage-path-note">{storageInfo.path}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Cache</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
|
||||
<button class="danger-btn" on:click={handleClearCache} disabled={clearing}>
|
||||
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOLDERS -->
|
||||
{:else if tab === "folders"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Manage Folders</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p>
|
||||
<div class="folder-create-row">
|
||||
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
|
||||
on:keydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
|
||||
<button class="folder-create-btn" on:click={createFolder} disabled={!newFolderName.trim()}>
|
||||
<Plus size={13} weight="bold" /> Create
|
||||
</button>
|
||||
</div>
|
||||
{#if $settings.folders.length === 0}
|
||||
<p class="storage-loading">No folders yet. Create one above.</p>
|
||||
{:else}
|
||||
<div class="folder-list">
|
||||
{#each $settings.folders as folder}
|
||||
<div class="folder-row">
|
||||
{#if editingId === folder.id}
|
||||
<input class="text-input" bind:value={editingName}
|
||||
on:keydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
|
||||
on:blur={commitEdit} style="flex:1;width:auto" use:focusInput />
|
||||
<button class="kb-reset" on:click={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="folder-row-name">{folder.name}</span>
|
||||
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
|
||||
<button class="folder-tab-toggle" class:on={folder.showTab} on:click={() => toggleFolderTab(folder.id)}>
|
||||
{folder.showTab ? "Tab on" : "Tab off"}
|
||||
</button>
|
||||
<button class="kb-reset" on:click={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
|
||||
<button class="kb-reset folder-delete" on:click={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ABOUT -->
|
||||
{:else if tab === "about"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Moku</p>
|
||||
<div class="about-block">
|
||||
<p class="about-line">A manga reader frontend for Suwayomi / Tachidesk.</p>
|
||||
<p class="about-line" style="color:var(--text-faint);margin-top:var(--sp-2)">Built with Tauri + Svelte. Connects to tachidesk-server.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEVTOOLS -->
|
||||
{:else if tab === "devtools"}
|
||||
<div class="panel">
|
||||
<div class="section">
|
||||
<p class="section-title">Splash Screen</p>
|
||||
<div class="step-row">
|
||||
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
|
||||
<button class="danger-btn" on:click={triggerSplash}
|
||||
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
|
||||
Show idle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p class="section-title">Build Info</p>
|
||||
<div class="about-block">
|
||||
<p class="about-line" style="font-family:monospace;font-size:11px;color:var(--text-faint)">Mode: {import.meta.env.MODE}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script context="module">
|
||||
function focusInput(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; backdrop-filter: blur(4px); }
|
||||
.modal { width: min(720px, calc(100vw - 48px)); height: min(600px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.15s ease both; box-shadow: 0 24px 64px rgba(0,0,0,0.6); }
|
||||
.sidebar { width: 168px; flex-shrink: 0; background: var(--bg-base); border-right: 1px solid var(--border-dim); padding: var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); overflow-y: auto; }
|
||||
.modal-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 0 var(--sp-2) var(--sp-3); }
|
||||
.nav { display: flex; flex-direction: column; gap: 1px; }
|
||||
.nav-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.nav-item:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.nav-item.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
.content-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.content-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.content-body { flex: 1; overflow-y: auto; }
|
||||
|
||||
.panel { display: flex; flex-direction: column; gap: var(--sp-1); padding: var(--sp-4) var(--sp-6); }
|
||||
.section { display: flex; flex-direction: column; gap: 1px; border-bottom: 1px solid var(--border-dim); padding-bottom: var(--sp-4); margin-bottom: var(--sp-2); }
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-3) var(--sp-3) var(--sp-2); }
|
||||
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); cursor: default; transition: background var(--t-fast); }
|
||||
.toggle-row:hover { background: var(--bg-raised); }
|
||||
.toggle-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; margin-right: var(--sp-4); }
|
||||
.toggle-label { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.toggle-desc { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
|
||||
.toggle { position: relative; width: 32px; height: 18px; border-radius: var(--radius-full); border: none; background: var(--bg-overlay); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); border: 1px solid var(--border-strong); }
|
||||
.toggle.on { background: var(--accent); border-color: var(--accent); }
|
||||
.toggle-thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
|
||||
.toggle.on .toggle-thumb { transform: translateX(14px); background: var(--bg-void); }
|
||||
|
||||
.step-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
.step-row:hover { background: var(--bg-raised); }
|
||||
.step-controls { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
.step-btn { font-family: var(--font-ui); font-size: var(--text-sm); width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; }
|
||||
.step-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.step-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); min-width: 40px; text-align: center; }
|
||||
|
||||
.select-wrap { position: relative; flex-shrink: 0; }
|
||||
.select-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; cursor: pointer; min-width: 130px; transition: border-color var(--t-base); }
|
||||
.select-btn:hover { border-color: var(--border-strong); }
|
||||
.select-caret { color: var(--text-faint); transition: transform var(--t-base); flex-shrink: 0; margin-left: auto; }
|
||||
.select-caret.open { transform: rotate(180deg); }
|
||||
.select-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 100%; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.4); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.select-option { display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.select-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.select-option.active { color: var(--accent-fg); background: var(--accent-muted); }
|
||||
|
||||
.text-input { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; width: 200px; transition: border-color var(--t-base); flex-shrink: 0; }
|
||||
.text-input:focus { border-color: var(--border-strong); }
|
||||
|
||||
.danger-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--color-error); background: none; color: var(--color-error); cursor: pointer; flex-shrink: 0; transition: background var(--t-base); }
|
||||
.danger-btn:hover:not(:disabled) { background: var(--color-error-bg); }
|
||||
.danger-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.scale-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
||||
.scale-slider { flex: 1; }
|
||||
.scale-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 40px; text-align: center; }
|
||||
.scale-hint { padding: 0 var(--sp-3) var(--sp-2); display: flex; gap: var(--sp-1); flex-wrap: wrap; }
|
||||
.scale-preset { font-family: var(--font-ui); font-size: var(--text-2xs); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.scale-preset:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.scale-preset.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
|
||||
/* Theme */
|
||||
.theme-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); }
|
||||
.theme-card { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); overflow: hidden; cursor: pointer; text-align: left; transition: border-color var(--t-base), box-shadow var(--t-base); position: relative; }
|
||||
.theme-card:hover { border-color: var(--border-strong); }
|
||||
.theme-card.active { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.theme-preview { height: 70px; overflow: hidden; }
|
||||
.theme-preview-bg { width: 100%; height: 100%; display: flex; }
|
||||
.theme-preview-sidebar { width: 20%; height: 100%; flex-shrink: 0; }
|
||||
.theme-preview-content { flex: 1; padding: 8px 6px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.theme-preview-accent { height: 6px; width: 50%; border-radius: 3px; }
|
||||
.theme-preview-text { height: 4px; width: 100%; border-radius: 2px; }
|
||||
.theme-card-info { padding: 8px 10px; display: flex; flex-direction: column; gap: 2px; }
|
||||
.theme-card-label { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.theme-card-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.theme-card-check { position: absolute; top: 6px; right: 6px; font-size: 10px; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 4px; }
|
||||
|
||||
/* Keybinds */
|
||||
.kb-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-3) var(--sp-2); }
|
||||
.reset-all-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.reset-all-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.kb-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-3); }
|
||||
.kb-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.kb-row { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.kb-row:hover { background: var(--bg-raised); }
|
||||
.kb-label { font-size: var(--text-sm); color: var(--text-secondary); flex: 1; }
|
||||
.kb-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.kb-bind { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-secondary); cursor: pointer; min-width: 90px; text-align: center; transition: border-color var(--t-base), color var(--t-base); }
|
||||
.kb-bind:hover { border-color: var(--border-strong); }
|
||||
.kb-bind.listening { border-color: var(--accent); color: var(--accent-fg); background: var(--accent-muted); animation: pulse 1s ease infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.6 } }
|
||||
.kb-reset { font-size: var(--text-sm); color: var(--text-faint); padding: 3px 6px; border-radius: var(--radius-sm); border: 1px solid transparent; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.kb-reset:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-dim); background: var(--bg-overlay); }
|
||||
.kb-reset:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Storage */
|
||||
.storage-loading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-3); }
|
||||
.storage-bar-wrap { padding: var(--sp-2) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.storage-bar { height: 6px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.storage-bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
.storage-bar-fill.warn { background: #d97706; }
|
||||
.storage-bar-fill.critical { background: var(--color-error); }
|
||||
.storage-bar-labels { display: flex; justify-content: space-between; }
|
||||
.storage-bar-used, .storage-bar-free { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.storage-legend { display: flex; flex-direction: column; gap: var(--sp-1); padding: 0 var(--sp-3); }
|
||||
.storage-legend-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.storage-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.storage-dot-manga { background: var(--accent); }
|
||||
.storage-dot-free { background: var(--bg-overlay); border: 1px solid var(--border-strong); }
|
||||
.storage-dot-app { background: var(--text-faint); }
|
||||
.storage-legend-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; }
|
||||
.storage-legend-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.storage-path-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-3) 0; word-break: break-all; }
|
||||
|
||||
/* Folders */
|
||||
.folder-create-row { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
|
||||
.folder-create-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
|
||||
.folder-create-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.folder-create-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.folder-list { display: flex; flex-direction: column; gap: 1px; padding: 0 var(--sp-3); }
|
||||
.folder-row { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.folder-row:hover { background: var(--bg-raised); }
|
||||
.folder-row-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.folder-row-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.folder-tab-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.folder-tab-toggle.on { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.folder-tab-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.folder-delete:hover:not(:disabled) { color: var(--color-error) !important; }
|
||||
|
||||
/* About */
|
||||
.about-block { padding: 0 var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.about-line { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder } from "../../store";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
|
||||
let manga: Manga | null = null;
|
||||
let chapters: Chapter[] = [];
|
||||
let loadingDetail = false;
|
||||
let loadingChapters = false;
|
||||
let togglingLib = false;
|
||||
let descExpanded = false;
|
||||
let folderOpen = false;
|
||||
let newFolderName = "";
|
||||
let creatingFolder = false;
|
||||
let queueingAll = false;
|
||||
let fetchError: string|null = null;
|
||||
let folderRef: HTMLDivElement;
|
||||
|
||||
let detailAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
|
||||
function close() {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
previewManga.set(null);
|
||||
manga = null; chapters = []; descExpanded = false;
|
||||
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
|
||||
}
|
||||
|
||||
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
||||
|
||||
$: displayManga = manga ?? $previewManga;
|
||||
$: totalCount = chapters.length;
|
||||
$: readCount = chapters.filter((c) => c.isRead).length;
|
||||
$: unreadCount = totalCount - readCount;
|
||||
$: downloadedCount = chapters.filter((c) => c.isDownloaded).length;
|
||||
$: bookmarkCount = chapters.filter((c) => c.isBookmarked).length;
|
||||
$: inLibrary = manga?.inLibrary ?? $previewManga?.inLibrary ?? false;
|
||||
$: scanlators = [...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))];
|
||||
$: uploadDates = chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d));
|
||||
$: firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null;
|
||||
$: lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null;
|
||||
$: statusLabel = displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null;
|
||||
$: assignedFolders = $previewManga ? $settings.folders.filter((f) => f.mangaIds.includes($previewManga!.id)) : [];
|
||||
|
||||
$: continueChapter = (() => {
|
||||
if (!chapters.length) return null;
|
||||
const inProgress = chapters.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||
if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` };
|
||||
const firstUnread = chapters.find((c) => !c.isRead);
|
||||
if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` };
|
||||
return { ch: chapters[0], label: "Read again" };
|
||||
})();
|
||||
|
||||
$: if ($previewManga) load($previewManga.id);
|
||||
|
||||
async function load(id: number) {
|
||||
detailAbort?.abort(); chapterAbort?.abort();
|
||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
||||
manga = null; chapters = []; descExpanded = false; fetchError = null;
|
||||
loadingDetail = true; loadingChapters = true;
|
||||
|
||||
(async (): Promise<Manga> => {
|
||||
const key = CACHE_KEYS.MANGA(id);
|
||||
if (cache.has(key)) return cache.get(key, () => Promise.resolve($previewManga as Manga)) as Promise<Manga>;
|
||||
try {
|
||||
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
|
||||
return d.fetchManga.manga;
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") throw e;
|
||||
const local = await gql<{ manga: Manga }>(GET_MANGA, { id }, dCtrl.signal).then((d) => d.manga);
|
||||
if (local) return local;
|
||||
throw new Error("Could not load manga details");
|
||||
}
|
||||
})().then((fullManga) => {
|
||||
if (dCtrl.signal.aborted) return;
|
||||
if (!cache.has(CACHE_KEYS.MANGA(id))) cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga));
|
||||
manga = fullManga; loadingDetail = false;
|
||||
}).catch((e) => {
|
||||
if (e?.name === "AbortError") return;
|
||||
manga = $previewManga as Manga;
|
||||
fetchError = "Could not load full details — showing cached data";
|
||||
loadingDetail = false;
|
||||
});
|
||||
|
||||
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, cCtrl.signal)
|
||||
.then(async (d) => {
|
||||
if (cCtrl.signal.aborted) return;
|
||||
let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
if (nodes.length === 0) {
|
||||
try {
|
||||
const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal);
|
||||
if (!cCtrl.signal.aborted) nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
} catch (e: any) { if (e?.name === "AbortError") return; }
|
||||
}
|
||||
if (!cCtrl.signal.aborted) chapters = nodes;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
|
||||
}
|
||||
|
||||
async function toggleLibrary() {
|
||||
if (!manga) return;
|
||||
togglingLib = true;
|
||||
const next = !manga.inLibrary;
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
|
||||
manga = { ...manga, inLibrary: next };
|
||||
cache.clear(CACHE_KEYS.MANGA(manga.id));
|
||||
cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(manga!));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
togglingLib = false;
|
||||
addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
queueingAll = true;
|
||||
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error);
|
||||
addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
|
||||
queueingAll = false;
|
||||
}
|
||||
|
||||
function openSeriesDetail() {
|
||||
if (!displayManga) return;
|
||||
activeManga.set(displayManga);
|
||||
navPage.set("library");
|
||||
close();
|
||||
}
|
||||
|
||||
function handleFolderCreate() {
|
||||
const name = newFolderName.trim();
|
||||
if (!name || !$previewManga) return;
|
||||
const id = addFolder(name);
|
||||
assignMangaToFolder(id, $previewManga.id);
|
||||
newFolderName = ""; creatingFolder = false;
|
||||
}
|
||||
|
||||
function handleFolderOutside(e: MouseEvent) {
|
||||
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
|
||||
}
|
||||
|
||||
$: if (folderOpen) setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
|
||||
else document.removeEventListener("mousedown", handleFolderOutside);
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
|
||||
onMount(() => window.addEventListener("keydown", onKey));
|
||||
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
|
||||
</script>
|
||||
|
||||
{#if $previewManga}
|
||||
<div class="backdrop" on:click={(e) => { if (e.target === e.currentTarget) close(); }}>
|
||||
<div class="modal" role="dialog" aria-label="Manga preview">
|
||||
|
||||
<!-- Cover column -->
|
||||
<div class="cover-col">
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl($previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
|
||||
{#if loadingDetail}
|
||||
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}>
|
||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
<button class="action-btn" on:click={openSeriesDetail}>
|
||||
<Books size={13} weight="light" /> Series Detail
|
||||
</button>
|
||||
<div class="folder-wrap" bind:this={folderRef}>
|
||||
<button class="action-btn" class:folder-active={assignedFolders.length > 0} on:click={() => folderOpen = !folderOpen}>
|
||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
||||
</button>
|
||||
{#if folderOpen}
|
||||
<div class="folder-menu">
|
||||
{#if $settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
|
||||
{#each $settings.folders as f}
|
||||
{@const isIn = $previewManga ? f.mangaIds.includes($previewManga.id) : false}
|
||||
<button class="folder-item" class:folder-item-on={isIn}
|
||||
on:click={() => $previewManga && (isIn ? removeMangaFromFolder(f.id, $previewManga.id) : assignMangaToFolder(f.id, $previewManga.id))}>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="folder-divider"></div>
|
||||
{#if creatingFolder}
|
||||
<div class="folder-create-row">
|
||||
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
|
||||
on:keydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
|
||||
use:focus />
|
||||
<button class="folder-ok" on:click={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="folder-new" on:click={() => creatingFolder = true}>+ New folder</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content column -->
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<div class="title-block">
|
||||
<h2 class="title">{displayManga?.title}</h2>
|
||||
{#if loadingDetail}
|
||||
<div class="sk-byline"></div>
|
||||
{:else if displayManga?.author || displayManga?.artist}
|
||||
<p class="byline">{[displayManga?.author, displayManga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="close-btn" on:click={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
|
||||
|
||||
<!-- Badges -->
|
||||
{#if loadingDetail}
|
||||
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
|
||||
{:else}
|
||||
<div class="badges">
|
||||
{#if statusLabel}<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>{/if}
|
||||
{#if displayManga?.source}<span class="badge">{displayManga.source.displayName}</span>{/if}
|
||||
{#if inLibrary}<span class="badge badge-accent">In Library</span>{/if}
|
||||
{#if !loadingChapters && unreadCount > 0}<span class="badge badge-unread">{unreadCount} unread</span>{/if}
|
||||
{#if !loadingChapters && bookmarkCount > 0}<span class="badge">{bookmarkCount} bookmarked</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chapter box -->
|
||||
<div class="chapter-box">
|
||||
{#if loadingChapters}
|
||||
<div class="chapter-loading">
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="chapter-loading-label">Loading chapters…</span>
|
||||
</div>
|
||||
{:else if totalCount > 0}
|
||||
<div class="chapter-meta">
|
||||
<span class="chapter-label">
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
|
||||
</span>
|
||||
{#if unreadCount > 0}
|
||||
<button class="dl-all-btn" on:click={downloadAll} disabled={queueingAll}>
|
||||
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
|
||||
{queueingAll ? "Queuing…" : "Download unread"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if readCount > 0}
|
||||
<div class="progress-track"><div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div></div>
|
||||
{/if}
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" on:click={() => { openReader(continueChapter!.ch, chapters); close(); }}>
|
||||
<Play size={12} weight="fill" />{continueChapter.label}
|
||||
</button>
|
||||
{/if}
|
||||
{:else if !loadingDetail}
|
||||
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{#if loadingDetail}
|
||||
<div class="sk-desc">
|
||||
<div class="sk-line" style="width:100%"></div>
|
||||
<div class="sk-line" style="width:88%"></div>
|
||||
<div class="sk-line" style="width:70%"></div>
|
||||
</div>
|
||||
{:else if displayManga?.description}
|
||||
<div class="desc-block">
|
||||
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
|
||||
{#if displayManga.description.length > 220}
|
||||
<button class="desc-toggle" on:click={() => descExpanded = !descExpanded}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<CaretDown size={10} weight="light" style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Genres -->
|
||||
{#if !loadingDetail && displayManga?.genre?.length}
|
||||
<div class="genres">
|
||||
{#each displayManga.genre as g}
|
||||
<button class="genre-tag" on:click={() => { genreFilter.set(g); navPage.set("explore"); close(); }}>{g}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Meta table -->
|
||||
{#if !loadingDetail}
|
||||
<div class="meta-table">
|
||||
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
|
||||
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if}
|
||||
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if}
|
||||
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if}
|
||||
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if}
|
||||
{#if !loadingChapters && firstUpload && lastUpload}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Published</span>
|
||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
||||
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<script context="module">
|
||||
function focus(node: HTMLElement) { node.focus(); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
||||
.cover-col::-webkit-scrollbar { display: none; }
|
||||
.cover-wrap { position: relative; width: 100%; }
|
||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.action-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
|
||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.action-btn.folder-active { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.folder-wrap { position: relative; width: 100%; }
|
||||
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
||||
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
|
||||
.folder-item { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
|
||||
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||
.folder-item.folder-item-on { color: var(--accent-fg); }
|
||||
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
|
||||
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
|
||||
.folder-input { flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); outline: none; min-width: 0; }
|
||||
.folder-input:focus { border-color: var(--border-focus); }
|
||||
.folder-ok { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; transition: color var(--t-base); }
|
||||
.folder-ok:disabled { opacity: 0.4; cursor: default; }
|
||||
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
.folder-new { padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; width: 100%; transition: color var(--t-fast); }
|
||||
.folder-new:hover { color: var(--accent-fg); }
|
||||
.content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||||
.content-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
|
||||
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
|
||||
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: thin; }
|
||||
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
||||
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
||||
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
||||
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
|
||||
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
|
||||
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
|
||||
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
||||
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.dl-all-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.read-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 8px var(--sp-4); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; align-self: flex-start; transition: filter var(--t-base); }
|
||||
.read-btn:hover { filter: brightness(1.1); }
|
||||
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
|
||||
.desc-toggle { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; align-self: flex-start; transition: color var(--t-base); }
|
||||
.desc-toggle:hover { color: var(--accent-fg); }
|
||||
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.genre-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); }
|
||||
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
|
||||
.meta-link:hover { opacity: 0.75; }
|
||||
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
|
||||
</style>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
|
||||
import { activeSource, activeManga, navPage, settings, addFolder, assignMangaToFolder } from "../../store";
|
||||
import type { Manga } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
|
||||
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
|
||||
|
||||
let mangas: Manga[] = [];
|
||||
let loading = true;
|
||||
let page = 1;
|
||||
let hasNextPage = false;
|
||||
let browseType: BrowseType = "POPULAR";
|
||||
let search = "";
|
||||
let searchInput = "";
|
||||
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
||||
|
||||
async function fetchMangas(type: BrowseType, p: number, q: string) {
|
||||
if (!$activeSource) return;
|
||||
loading = true; mangas = [];
|
||||
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA, { source: $activeSource.id, type, page: p, query: q || null }
|
||||
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
|
||||
.catch(console.error)
|
||||
.finally(() => loading = false);
|
||||
}
|
||||
|
||||
$: if ($activeSource) fetchMangas(browseType, page, search);
|
||||
|
||||
function submitSearch() {
|
||||
search = searchInput.trim();
|
||||
browseType = "SEARCH";
|
||||
page = 1;
|
||||
}
|
||||
|
||||
function setMode(mode: BrowseType) {
|
||||
if (mode === browseType) return;
|
||||
browseType = mode; search = ""; searchInput = ""; page = 1;
|
||||
}
|
||||
|
||||
function buildCtxItems(m: Manga): MenuEntry[] {
|
||||
return [
|
||||
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
|
||||
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
|
||||
.catch(console.error) },
|
||||
...($settings.folders.length > 0 ? [
|
||||
{ separator: true } as MenuEntry,
|
||||
...$settings.folders.map((f): MenuEntry => ({
|
||||
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $activeSource}
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<button class="back" on:click={() => activeSource.set(null)}>
|
||||
<ArrowLeft size={13} weight="light" /><span>Sources</span>
|
||||
</button>
|
||||
<span class="source-name">{$activeSource.displayName}</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="tabs">
|
||||
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
|
||||
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
|
||||
{mode.charAt(0) + mode.slice(1).toLowerCase()}
|
||||
</button>
|
||||
{/each}
|
||||
{#if search}<button class="tab active">Search</button>{/if}
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search source…" bind:value={searchInput}
|
||||
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-grid">
|
||||
{#each Array(18) as _}
|
||||
<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if mangas.length === 0}
|
||||
<div class="empty">No results.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each mangas as m (m.id)}
|
||||
<button class="card" on:click={() => { activeManga.set(m); navPage.set("library"); }}
|
||||
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
|
||||
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
|
||||
</div>
|
||||
<p class="title">{m.title}</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading && (page > 1 || hasNextPage)}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
|
||||
<Prev size={13} weight="light" /> Prev
|
||||
</button>
|
||||
<span class="page-num">{page}</span>
|
||||
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
|
||||
Next <Next size={13} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if ctx}
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); flex-shrink: 0; }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
.source-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
|
||||
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
|
||||
.tabs { display: flex; gap: 2px; }
|
||||
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 200px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
|
||||
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||
.card:hover .cover { filter: brightness(1.06); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
|
||||
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
|
||||
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
|
||||
.card-skeleton { padding: 0; }
|
||||
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
|
||||
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.page-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 12px; background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.page-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES } from "../../lib/queries";
|
||||
import { activeSource } from "../../store";
|
||||
import type { Source } from "../../lib/types";
|
||||
|
||||
let sources: Source[] = [];
|
||||
let loading = true;
|
||||
let lang = "all";
|
||||
let search = "";
|
||||
let expanded = new Set<string>();
|
||||
|
||||
onMount(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => sources = d.sources.nodes)
|
||||
.catch(console.error)
|
||||
.finally(() => loading = false);
|
||||
});
|
||||
|
||||
$: langs = ["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()];
|
||||
|
||||
$: filtered = sources.filter((src) => {
|
||||
if (src.id === "0") return false;
|
||||
const matchLang = lang === "all" || src.lang === lang;
|
||||
const matchSearch = src.name.toLowerCase().includes(search.toLowerCase()) || src.displayName.toLowerCase().includes(search.toLowerCase());
|
||||
return matchLang && matchSearch;
|
||||
});
|
||||
|
||||
$: groups = (() => {
|
||||
const map = new Map<string, { name: string; icon: string; sources: Source[] }>();
|
||||
for (const src of filtered) {
|
||||
if (!map.has(src.name)) map.set(src.name, { name: src.name, icon: src.iconUrl, sources: [] });
|
||||
map.get(src.name)!.sources.push(src);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
})();
|
||||
|
||||
function toggleGroup(name: string) {
|
||||
const next = new Set(expanded);
|
||||
next.has(name) ? next.delete(name) : next.add(name);
|
||||
expanded = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Sources</h1>
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lang-row">
|
||||
{#each langs as l}
|
||||
<button class="lang-btn" class:active={lang === l} on:click={() => lang = l}>
|
||||
{l === "all" ? "All" : l.toUpperCase()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="empty">No sources found.</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each groups as g}
|
||||
{@const single = g.sources.length === 1}
|
||||
{@const open = expanded.has(g.name)}
|
||||
<div>
|
||||
<button class="row" on:click={() => single ? activeSource.set(g.sources[0]) : toggleGroup(g.name)}>
|
||||
<img src={thumbUrl(g.icon)} alt={g.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} />
|
||||
<div class="info">
|
||||
<span class="name">{g.name}</span>
|
||||
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
|
||||
</div>
|
||||
<span class="arrow">
|
||||
{#if single}→{:else if open}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if !single && open}
|
||||
{#each g.sources as src}
|
||||
<button class="row row-indented" on:click={() => activeSource.set(src)}>
|
||||
<div class="indent-spacer"></div>
|
||||
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root { padding: var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-5); }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search::placeholder { color: var(--text-faint); }
|
||||
.search:focus { border-color: var(--border-strong); }
|
||||
.lang-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-bottom: var(--sp-4); }
|
||||
.lang-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.lang-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.lang-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
|
||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||
.row-indented { padding-left: var(--sp-5); }
|
||||
.indent-spacer { width: 32px; flex-shrink: 0; }
|
||||
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
||||
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.arrow { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||
.row:hover .arrow { opacity: 1; }
|
||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user