mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Chore: Standardized UI & Revamped Series-Detail
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
import Home from "../pages/Home.svelte";
|
||||
import Library from "../pages/Library.svelte";
|
||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||
import History from "../pages/History.svelte";
|
||||
import RecentActivity from "./RecentActivity.svelte";
|
||||
import Search from "../pages/Search.svelte";
|
||||
import Discover from "../pages/Discover.svelte";
|
||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
||||
@@ -25,7 +25,7 @@
|
||||
{:else if store.navPage === "search"}
|
||||
<Search />
|
||||
{:else if store.navPage === "history"}
|
||||
<History />
|
||||
<RecentActivity />
|
||||
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
|
||||
<GenreDrillPage />
|
||||
{:else if store.navPage === "explore" || store.navPage === "sources"}
|
||||
|
||||
@@ -79,11 +79,11 @@
|
||||
}
|
||||
|
||||
const filtered = $derived(search.trim()
|
||||
? store..filter((e) =>
|
||||
? store.history.filter((e) =>
|
||||
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: store.);
|
||||
: store.history);
|
||||
|
||||
const sessions = $derived(buildSessions(filtered));
|
||||
|
||||
@@ -97,10 +97,16 @@
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
});
|
||||
|
||||
// Resume: navigate to the manga's SeriesDetail (which will pick up from
|
||||
// activeChapterList once chapters load). We can't hold a stale chapter list
|
||||
// here — SeriesDetail fetches fresh chapters itself.
|
||||
function resume(session: Session) {
|
||||
const ch = store..find((c) => c.id === session.latestChapterId);
|
||||
if (ch && store..length > 0) openReader(ch, );
|
||||
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
||||
setActiveManga({
|
||||
id: session.mangaId,
|
||||
title: session.mangaTitle,
|
||||
thumbnailUrl: session.thumbnailUrl,
|
||||
inLibrary: false,
|
||||
} as any);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
@@ -111,17 +117,17 @@
|
||||
|
||||
<div class="root">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="header">
|
||||
<span class="heading">History</span>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search store.…" bind:value={search} />
|
||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
||||
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
|
||||
</div>
|
||||
{#if store..length > 0}
|
||||
{#if store.history.length > 0}
|
||||
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
|
||||
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
|
||||
title={confirmClear ? "Click again to confirm" : "Clear history"}>
|
||||
<Trash size={14} weight="light" />
|
||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
||||
</button>
|
||||
@@ -129,44 +135,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if store..totalChaptersRead > 0}
|
||||
{#if store.readingStats.totalChaptersRead > 0}
|
||||
<div class="stats-bar">
|
||||
<div class="stat-group">
|
||||
<Fire size={13} weight="fill" class="stat-fire" />
|
||||
<span class="stat-val accent">{store..currentStreakDays}</span>
|
||||
<span class="stat-val accent">{store.readingStats.currentStreakDays}</span>
|
||||
<span class="stat-label">day streak</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalChaptersRead}</span>
|
||||
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
||||
<span class="stat-label">chapters</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<Clock size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
|
||||
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
||||
<span class="stat-label">read time</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
|
||||
<span class="stat-val">{store..totalMangaRead}</span>
|
||||
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
||||
<span class="stat-label">series</span>
|
||||
</div>
|
||||
<div class="stat-sep"></div>
|
||||
<div class="stat-group">
|
||||
<span class="stat-val muted">{store..longestStreakDays}d</span>
|
||||
<span class="stat-val muted">{store.readingStats.longestStreakDays}d</span>
|
||||
<span class="stat-label">best streak</span>
|
||||
</div>
|
||||
<span class="stats-note">Stats are preserved when you clear the feed</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if store..length === 0}
|
||||
{#if store.history.length === 0}
|
||||
<div class="empty">
|
||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No reading store.</p>
|
||||
<p class="empty-text">No reading history yet</p>
|
||||
<p class="empty-hint">Chapters you read will appear here</p>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
@@ -223,16 +229,16 @@
|
||||
<style>
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
.page-header {
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
.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-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
.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 26px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
|
||||
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 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); }
|
||||
.search-clear { position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Downloads</h1>
|
||||
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
|
||||
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
|
||||
@@ -89,6 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="status-bar">
|
||||
<div class="status-dot" class:active={isRunning}></div>
|
||||
<span class="status-text">
|
||||
@@ -139,18 +141,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div><!-- .content -->
|
||||
</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); }
|
||||
.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-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); 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-2); }
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); margin-bottom: var(--sp-4); }
|
||||
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
|
||||
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: 0.4 } }
|
||||
|
||||
@@ -130,7 +130,18 @@
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">Extensions</h1>
|
||||
<div class="header-actions">
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
|
||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
|
||||
<GitBranch size={14} weight="light" />
|
||||
</button>
|
||||
@@ -201,19 +212,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="controls">
|
||||
<div class="tabs">
|
||||
{#each FILTERS as f}
|
||||
<button class="tab" class:active={filter === f.id} onclick={() => 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>
|
||||
@@ -282,7 +281,8 @@
|
||||
|
||||
<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; }
|
||||
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
|
||||
.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); }
|
||||
@@ -309,11 +309,11 @@
|
||||
.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); }
|
||||
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||
.tab { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); }
|
||||
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
|
||||
.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); }
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
|
||||
import { thumbUrl, gql } from "../../lib/client";
|
||||
import { GET_CHAPTERS } from "../../lib/queries";
|
||||
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
|
||||
import type { HistoryEntry } from "../../store/state.svelte";
|
||||
|
||||
let search = $state("");
|
||||
let confirmClearAll = $state(false);
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "Just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 7) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function dayLabel(ts: number): string {
|
||||
const d = new Date(ts), now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) return "Today";
|
||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||
const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7);
|
||||
if (d > weekAgo) return d.toLocaleDateString("en-US", { weekday: "long" });
|
||||
return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
|
||||
}
|
||||
|
||||
function formatReadTime(mins: number): string {
|
||||
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||
if (mins < 60) return `${Math.round(mins)}m`;
|
||||
const h = Math.floor(mins / 60), r = mins % 60;
|
||||
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||
const d = Math.floor(h / 24), rh = h % 24;
|
||||
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||
}
|
||||
|
||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||
|
||||
interface Session {
|
||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
||||
firstChapterName: string; chapterCount: number; readAt: number;
|
||||
}
|
||||
|
||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
||||
if (!entries.length) return [];
|
||||
const sessions: Session[] = [];
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const anchor = entries[i];
|
||||
const group: HistoryEntry[] = [anchor];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
||||
else break;
|
||||
}
|
||||
const latest = group[0], oldest = group[group.length - 1];
|
||||
sessions.push({ mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl, latestChapterId: latest.chapterId, latestChapterName: latest.chapterName, latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName, chapterCount: group.length, readAt: latest.readAt });
|
||||
i = j;
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const filtered = $derived(search.trim()
|
||||
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
||||
: store.history);
|
||||
|
||||
const sessions = $derived(buildSessions(filtered));
|
||||
|
||||
const groups = $derived((() => {
|
||||
const map = new Map<string, Session[]>();
|
||||
for (const s of sessions) {
|
||||
const l = dayLabel(s.readAt);
|
||||
if (!map.has(l)) map.set(l, []);
|
||||
map.get(l)!.push(s);
|
||||
}
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
})());
|
||||
|
||||
const stats = $derived({
|
||||
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
|
||||
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
|
||||
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
|
||||
});
|
||||
|
||||
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
|
||||
|
||||
async function resume(session: Session) {
|
||||
try {
|
||||
const d = await gql<{ chapters: { nodes: any[] } }>(GET_CHAPTERS, { mangaId: session.mangaId });
|
||||
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||
const ch = chapters.find(c => c.id === session.latestChapterId) ?? chapters[0];
|
||||
if (ch) openReader(ch, chapters);
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="header">
|
||||
<h1 class="heading">History</h1>
|
||||
<div class="header-right">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||
<input class="search" placeholder="Search store.history…" bind:value={search} />
|
||||
{#if search}
|
||||
<button class="search-clear" onclick={() => search = ""}>
|
||||
<XIcon size={10} weight="bold" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if store.history.length > 0}
|
||||
{#if confirmClearAll}
|
||||
<div class="confirm-row">
|
||||
<span class="confirm-label">Clear all activity?</span>
|
||||
<button class="confirm-yes" onclick={doConfirmClear}>Clear</button>
|
||||
<button class="confirm-no" onclick={() => confirmClearAll = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="clear-btn" onclick={() => confirmClearAll = true} title="Clear all activity">
|
||||
<Trash size={13} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-bar">
|
||||
<span class="stat-item"><span class="stat-val">{stats.uniqueChapters}</span><span class="stat-label">chapters</span></span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
|
||||
{#if store.readingStats.currentStreakDays > 0}
|
||||
<span class="stat-sep"></span>
|
||||
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if store.history.length === 0}
|
||||
<div class="empty">
|
||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No reading history yet</p>
|
||||
<p class="empty-hint">Chapters you read will appear here</p>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
<div class="empty">
|
||||
<Books size={28} weight="light" class="empty-icon" />
|
||||
<p class="empty-text">No results for "{search}"</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each groups as { label, items } (label)}
|
||||
<div class="group">
|
||||
<p class="group-label">
|
||||
<span>{label}</span>
|
||||
<span class="group-count">{items.length}</span>
|
||||
</p>
|
||||
{#each items as session (session.latestChapterId + ":" + session.readAt)}
|
||||
<div class="row-wrap">
|
||||
<button class="row" onclick={() => resume(session)}>
|
||||
<div class="thumb-wrap">
|
||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" loading="lazy" decoding="async" />
|
||||
{#if session.chapterCount > 1}
|
||||
<span class="session-badge">{session.chapterCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="info">
|
||||
<span class="manga-title">{session.mangaTitle}</span>
|
||||
<span class="chapter-name">
|
||||
{#if session.chapterCount > 1}
|
||||
<span class="chapter-range">{session.firstChapterName}<span class="range-sep">→</span>{session.latestChapterName}</span>
|
||||
{:else}
|
||||
{session.latestChapterName}
|
||||
{#if session.latestPageNumber > 1}<span class="page-badge">p.{session.latestPageNumber}</span>{/if}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<span class="time">{timeAgo(session.readAt)}</span>
|
||||
<Play size={11} weight="fill" class="play-icon" />
|
||||
</button>
|
||||
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
|
||||
<XIcon size={9} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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-right { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.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 28px 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); }
|
||||
.search-clear { position: absolute; right: 7px; display: flex; align-items: center; justify-content: center; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
|
||||
.search-clear:hover { color: var(--text-muted); }
|
||||
.confirm-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.confirm-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.confirm-yes { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-error); background: var(--color-error-bg); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
|
||||
.confirm-yes:hover { filter: brightness(1.15); }
|
||||
.confirm-no { font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: background var(--t-base); }
|
||||
.confirm-no:hover { background: var(--bg-raised); color: var(--text-muted); }
|
||||
.clear-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.stats-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; }
|
||||
.stat-item { display: flex; align-items: baseline; gap: 4px; }
|
||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.stat-sep { width: 1px; height: 10px; background: var(--border-dim); flex-shrink: 0; }
|
||||
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-6); scrollbar-width: none; }
|
||||
.list::-webkit-scrollbar { display: none; }
|
||||
.group { margin-bottom: var(--sp-4); }
|
||||
.group-label { 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-wider); text-transform: uppercase; padding: var(--sp-1) var(--sp-2) var(--sp-2); }
|
||||
.group-count { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); padding: 1px 5px; border-radius: var(--radius-full); letter-spacing: 0; text-transform: none; }
|
||||
.row-wrap { display: flex; align-items: center; border-radius: var(--radius-md); transition: background var(--t-fast); }
|
||||
.row-wrap:hover { background: var(--bg-raised); }
|
||||
.row-wrap:hover .row-delete { opacity: 1; }
|
||||
.row { flex: 1; display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; min-width: 0; }
|
||||
.row:hover :global(.play-icon) { opacity: 1; }
|
||||
.row-delete { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-base), color var(--t-base), background var(--t-base); margin-right: var(--sp-1); }
|
||||
.row-delete:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||
.thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
||||
.session-badge { position: absolute; bottom: -4px; right: -6px; background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: 600; padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none; }
|
||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||
.manga-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chapter-name { font-size: var(--text-sm); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
|
||||
.chapter-range { display: flex; align-items: center; gap: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted); }
|
||||
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||
:global(.empty-icon) { color: var(--text-faint); }
|
||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -238,6 +238,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(12) as _}
|
||||
@@ -275,6 +276,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div><!-- .content -->
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -286,11 +288,12 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root { position: relative; padding: var(--sp-5) var(--sp-6); overflow-y: auto; height: 100%; animation: fadeIn 0.14s ease both; will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.root { position: relative; display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
|
||||
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
|
||||
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
|
||||
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
|
||||
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-4); gap: var(--sp-4); flex-wrap: wrap; }
|
||||
.header { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); gap: var(--sp-4); flex-wrap: wrap; flex-shrink: 0; }
|
||||
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
||||
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
|
||||
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; }
|
||||
|
||||
@@ -1077,7 +1077,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-5) var(--sp-6) var(--sp-3);
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
@@ -17,30 +17,29 @@
|
||||
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
|
||||
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
|
||||
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingManga: boolean = $state(false);
|
||||
let loadingChapters: boolean = $state(true);
|
||||
let enqueueing: Set<number> = $state(new Set());
|
||||
let dlOpen: boolean = $state(false);
|
||||
let detailsOpen: boolean = $state(false);
|
||||
let togglingLibrary: boolean = $state(false);
|
||||
let chapterPage: number = $state(1);
|
||||
let manga: Manga | null = $state(null);
|
||||
let chapters: Chapter[] = $state([]);
|
||||
let loadingManga: boolean = $state(false);
|
||||
let loadingChapters: boolean = $state(true);
|
||||
let enqueueing: Set<number> = $state(new Set());
|
||||
let dlOpen: boolean = $state(false);
|
||||
let detailsOpen: boolean = $state(false);
|
||||
let togglingLibrary: boolean = $state(false);
|
||||
let chapterPage: number = $state(1);
|
||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
|
||||
let jumpOpen: boolean = $state(false);
|
||||
let jumpInput: string = $state("");
|
||||
let viewMode: "list" | "grid" = $state("list");
|
||||
let deletingAll: boolean = $state(false);
|
||||
let refreshing: boolean = $state(false);
|
||||
let descExpanded: boolean = $state(false);
|
||||
let genresExpanded: boolean = $state(false);
|
||||
let folderPickerOpen: boolean = $state(false);
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
let migrateOpen: boolean = $state(false);
|
||||
let jumpOpen: boolean = $state(false);
|
||||
let jumpInput: string = $state("");
|
||||
let viewMode: "list" | "grid" = $state("list");
|
||||
let deletingAll: boolean = $state(false);
|
||||
let refreshing: boolean = $state(false);
|
||||
let genresExpanded: boolean = $state(false);
|
||||
let folderPickerOpen: boolean = $state(false);
|
||||
let folderCreating: boolean = $state(false);
|
||||
let folderNewName: string = $state("");
|
||||
let rangeFrom: string = $state("");
|
||||
let rangeTo: string = $state("");
|
||||
let showRange: boolean = $state(false);
|
||||
let migrateOpen: boolean = $state(false);
|
||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
@@ -89,9 +88,9 @@
|
||||
return { chapter: asc[0], type: "reread" as const };
|
||||
})());
|
||||
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
|
||||
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
const hasFolders = $derived(assignedFolders.length > 0);
|
||||
|
||||
function loadManga(id: number) {
|
||||
mangaAbort?.abort();
|
||||
@@ -298,19 +297,19 @@
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
|
||||
// ── Series link ───────────────────────────────────────────────────────────
|
||||
// ── Series link ────────────────────────────────────────────────────────────
|
||||
|
||||
const linkedIds = $derived(
|
||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||
);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
|
||||
@@ -336,15 +335,18 @@
|
||||
{#if store.activeManga}
|
||||
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
|
||||
|
||||
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
|
||||
<div class="sidebar">
|
||||
<button class="back" onclick={() => setActiveManga(null)}>
|
||||
<ArrowLeft size={13} weight="light" /> Back
|
||||
</button>
|
||||
|
||||
<!-- Zone 1: Cover -->
|
||||
<div class="cover-wrap">
|
||||
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
|
||||
</div>
|
||||
|
||||
<!-- Zone 2: Meta -->
|
||||
{#if loadingManga}
|
||||
<div class="meta-skeleton">
|
||||
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
|
||||
@@ -361,27 +363,46 @@
|
||||
{/if}
|
||||
{#if manga?.genre?.length}
|
||||
<div class="genres">
|
||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
|
||||
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
|
||||
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
|
||||
{/each}
|
||||
{#if manga.genre.length > 5}
|
||||
{#if manga.genre.length > 3}
|
||||
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
|
||||
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
|
||||
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if manga?.description}
|
||||
<div class="desc-wrap">
|
||||
<p class="desc" class:expanded={descExpanded}>{manga.description}</p>
|
||||
{#if manga.description.length > 120}
|
||||
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>{descExpanded ? "Less" : "More"}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="desc">{manga.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Zone 3: Primary CTA + library action -->
|
||||
<div class="cta-section">
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
{#if manga?.realUrl}
|
||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
||||
<ArrowSquareOut size={13} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zone 4: Progress -->
|
||||
{#if totalCount > 0}
|
||||
<div class="progress-section">
|
||||
<div class="progress-header">
|
||||
@@ -392,49 +413,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<button class="library-btn" class:active={manga?.inLibrary} onclick={toggleLibrary} disabled={togglingLibrary || loadingManga}>
|
||||
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
|
||||
{manga?.inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
{#if manga?.realUrl}
|
||||
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
|
||||
<ArrowSquareOut size={13} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if continueChapter}
|
||||
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, sortedChapters)}>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === "continue"
|
||||
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
|
||||
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="tools-row">
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:tool-btn-active={linkedIds.length > 0}
|
||||
onclick={openLinkPicker}
|
||||
title="Series Link"
|
||||
>
|
||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||
<span>Series Link</span>
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
onclick={() => trackingOpen = true}
|
||||
title="Tracking"
|
||||
>
|
||||
<ChartLineUp size={12} weight="light" />
|
||||
<span>Tracking</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
|
||||
|
||||
<!-- Zone 5: Details accordion (source info + all secondary actions) -->
|
||||
{#if !loadingManga && manga?.source}
|
||||
<div class="details-section">
|
||||
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}>
|
||||
@@ -447,20 +426,38 @@
|
||||
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
|
||||
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
|
||||
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
|
||||
<button class="migrate-btn" onclick={() => migrateOpen = true}>
|
||||
<ArrowsClockwise size={12} weight="light" /> Switch source
|
||||
</button>
|
||||
{#if downloadedCount > 0}
|
||||
<button class="delete-all-btn" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="detail-action-btn" onclick={() => migrateOpen = true}>
|
||||
<ArrowsClockwise size={12} weight="light" /> Switch Source
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="detail-action-btn"
|
||||
class:detail-action-active={linkedIds.length > 0}
|
||||
onclick={openLinkPicker}
|
||||
>
|
||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
|
||||
</button>
|
||||
<button
|
||||
class="detail-action-btn"
|
||||
onclick={() => trackingOpen = true}
|
||||
>
|
||||
<ChartLineUp size={12} weight="light" /> Tracking
|
||||
</button>
|
||||
{#if downloadedCount > 0}
|
||||
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
|
||||
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
|
||||
<div class="list-wrap">
|
||||
<div class="list-header">
|
||||
<div class="list-header-left">
|
||||
@@ -468,7 +465,8 @@
|
||||
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
|
||||
{sortDir === "desc" ? "Newest first" : "Oldest first"}
|
||||
</button>
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"}>
|
||||
<!-- View toggle: icon reflects current state -->
|
||||
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
|
||||
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -477,6 +475,7 @@
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<!-- Folder picker -->
|
||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
|
||||
@@ -510,32 +509,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if chapters.length > 1}
|
||||
<div class="jump-wrap">
|
||||
{#if !jumpOpen}
|
||||
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
|
||||
{:else}
|
||||
<div class="jump-row">
|
||||
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} use:focusOnMount
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Escape") { jumpOpen = false; return; }
|
||||
if (e.key === "Enter") {
|
||||
const num = parseFloat(jumpInput);
|
||||
if (!isNaN(num)) {
|
||||
const target = sortedChapters.find(c => c.chapterNumber === num)
|
||||
?? sortedChapters.reduce((best, c) => Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best, sortedChapters[0]);
|
||||
if (target) openReader(target, sortedChapters);
|
||||
}
|
||||
jumpOpen = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button class="jump-cancel" onclick={() => jumpOpen = false}>✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Download dropdown -->
|
||||
{#if chapters.length > 0}
|
||||
<div class="dl-wrap" bind:this={dlDropRef}>
|
||||
<button class="icon-btn" onclick={() => dlOpen = !dlOpen}>
|
||||
@@ -610,7 +584,7 @@
|
||||
{:else if viewMode === "grid"}
|
||||
{#each sortedChapters as ch, i}
|
||||
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:bookmarked={ch.isBookmarked}
|
||||
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress}
|
||||
onclick={() => openReader(ch, sortedChapters)}
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
|
||||
title={ch.name}>
|
||||
@@ -723,11 +697,17 @@
|
||||
|
||||
<style>
|
||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
|
||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
.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); }
|
||||
.back:hover { color: var(--text-secondary); }
|
||||
|
||||
/* Zone 1: Cover */
|
||||
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.cover { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
/* Zone 2: Meta */
|
||||
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.sk-line { border-radius: var(--radius-sm); }
|
||||
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
@@ -741,17 +721,13 @@
|
||||
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.genre-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.desc-wrap { display: flex; flex-direction: column; gap: 2px; }
|
||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.desc.expanded { -webkit-line-clamp: unset; display: block; overflow: visible; }
|
||||
.desc-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
|
||||
.desc-toggle:hover { opacity: 1; }
|
||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
/* Description clamped — no expand in 240px sidebar */
|
||||
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
|
||||
/* Zone 3: CTA */
|
||||
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md); background: var(--accent); border: 1px solid var(--accent); color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { opacity: 0.88; }
|
||||
.actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.library-btn { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); background: var(--bg-raised); transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1; }
|
||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
@@ -759,7 +735,32 @@
|
||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.external-link.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
/* Zone 4: Progress */
|
||||
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.progress-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
|
||||
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
|
||||
|
||||
/* Zone 5: Details accordion */
|
||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||
.details-toggle:hover { color: var(--text-muted); }
|
||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
||||
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
|
||||
.detail-action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
.detail-action-danger { color: var(--color-error); }
|
||||
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
|
||||
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Series link modal ───────────────────────────────────────────────────── */
|
||||
.link-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; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||
@@ -783,25 +784,8 @@
|
||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.tools-row { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.tool-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; justify-content: center; padding: 6px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.tool-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.tool-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.tool-btn-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
||||
.details-section { display: flex; flex-direction: column; gap: 2px; }
|
||||
.details-toggle { display: flex; align-items: center; justify-content: space-between; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base); }
|
||||
.details-toggle:hover { color: var(--text-muted); }
|
||||
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
|
||||
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
|
||||
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
|
||||
.migrate-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: 5px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
|
||||
.delete-all-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.delete-all-btn:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
||||
.delete-all-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Chapter list ────────────────────────────────────────────────────────── */
|
||||
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
@@ -811,6 +795,8 @@
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Folder picker ───────────────────────────────────────────────────────── */
|
||||
.fp-wrap { position: relative; }
|
||||
.fp-menu { position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px; 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.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
@@ -828,14 +814,8 @@
|
||||
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
|
||||
.fp-new { width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs); color: var(--text-faint); background: none; border: none; cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast); }
|
||||
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
|
||||
.jump-wrap { position: relative; }
|
||||
.jump-toggle { 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); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.jump-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.jump-row { display: flex; align-items: center; gap: 4px; }
|
||||
.jump-input { width: 64px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; }
|
||||
.jump-input:focus { border-color: var(--border-focus); }
|
||||
.jump-cancel { font-size: 12px; color: var(--text-faint); padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
|
||||
.jump-cancel:hover { color: var(--text-muted); }
|
||||
|
||||
/* ── Download dropdown ───────────────────────────────────────────────────── */
|
||||
.dl-wrap { position: relative; }
|
||||
.dl-dropdown { position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200; box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: scaleIn 0.1s ease both; transform-origin: top right; }
|
||||
.dl-section-label { padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||
@@ -859,12 +839,16 @@
|
||||
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
|
||||
.dl-range-go { padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; }
|
||||
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Pagination ──────────────────────────────────────────────────────────── */
|
||||
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.page-btn { 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); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
/* ── Chapter rows ────────────────────────────────────────────────────────── */
|
||||
.ch-list { flex: 1; overflow-y: auto; }
|
||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
@@ -889,6 +873,7 @@
|
||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
// Mutation state
|
||||
let updatingId: number | null = $state(null);
|
||||
let syncingId: number | null = $state(null);
|
||||
// Chapter editing: recordId → draft value
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -187,14 +190,40 @@
|
||||
setActiveManga(record.manga as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
function openChapterEditor(record: FlatRecord) {
|
||||
editingChapter = record.id;
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function cancelChapterEditor() {
|
||||
editingChapter = null;
|
||||
}
|
||||
|
||||
async function submitChapter(record: FlatRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
if (val === record.lastChapterRead) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div class="page-header">
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="page-title">Tracking</h1>
|
||||
<h1 class="heading">Tracking</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||
@@ -383,16 +412,68 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
{#if progress !== null}
|
||||
<div class="record-progress">
|
||||
<!-- Progress / Chapter editor -->
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
use:focusEl
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
{/if}
|
||||
<div class="chapter-editor-actions">
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if progress !== null}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to edit"
|
||||
>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="record-progress clickable no-total" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
>
|
||||
<span class="progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"}
|
||||
</span>
|
||||
<span class="progress-edit-hint">✎</span>
|
||||
</div>
|
||||
{:else if record.lastChapterRead > 0}
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} read</span>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@@ -408,20 +489,20 @@
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.page-header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5) var(--sp-3); }
|
||||
.page-title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
|
||||
.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; align-items: center; gap: var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: 0 var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 8px 12px; border-bottom: 2px solid transparent; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border-radius: 0; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent) !important; }
|
||||
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); }
|
||||
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
|
||||
|
||||
@@ -516,6 +597,32 @@
|
||||
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress.clickable:hover .progress-label { color: var(--text-muted); }
|
||||
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); }
|
||||
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
|
||||
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
||||
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { 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: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
|
||||
<script module>
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="lang-row">
|
||||
{#each langs as l}
|
||||
<button class="lang-btn" class:active={lang === l} onclick={() => lang = l}>
|
||||
@@ -95,18 +96,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div><!-- .content -->
|
||||
</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); }
|
||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); 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; }
|
||||
.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-row { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.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); }
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
let binding: boolean = $state(false);
|
||||
let updatingRecord: number | null = $state(null);
|
||||
let syncing: number | null = $state(null);
|
||||
let editingChapter: number | null = $state(null);
|
||||
let chapterDraft: number = $state(0);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -197,6 +199,30 @@
|
||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
||||
}
|
||||
|
||||
function openChapterEditor(record: TrackRecord) {
|
||||
editingChapter = record.id;
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function cancelChapterEditor() { editingChapter = null; }
|
||||
|
||||
async function submitChapter(record: TrackRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
if (val === record.lastChapterRead) return;
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, lastChapterRead: val }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
|
||||
@@ -342,15 +368,65 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if record.totalChapters > 0}
|
||||
<div class="record-progress">
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-editor-label">Chapter read</span>
|
||||
<div class="chapter-input-wrap">
|
||||
<input
|
||||
type="number"
|
||||
class="chapter-input"
|
||||
min="0"
|
||||
max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") cancelChapterEditor();
|
||||
}}
|
||||
use:autoFocus
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input
|
||||
type="range"
|
||||
class="chapter-slider"
|
||||
min="0"
|
||||
max={record.totalChapters}
|
||||
step="1"
|
||||
bind:value={chapterDraft}
|
||||
/>
|
||||
{/if}
|
||||
<div class="chapter-editor-actions">
|
||||
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
|
||||
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if record.totalChapters > 0}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to edit"
|
||||
>
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint">✎</span></span>
|
||||
<div class="record-progress-track">
|
||||
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if record.lastChapterRead > 0}
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} read</span>
|
||||
{:else}
|
||||
<div class="record-progress clickable" role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
title="Click to set chapter"
|
||||
>
|
||||
<span class="record-progress-label">
|
||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
@@ -509,9 +585,27 @@
|
||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
||||
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
|
||||
.record-progress.clickable:hover { background: var(--bg-overlay); }
|
||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); }
|
||||
.record-progress.clickable:hover .edit-hint { opacity: 1; }
|
||||
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
/* Chapter editor */
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); }
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
|
||||
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; }
|
||||
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
|
||||
.chapter-save-btn:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); 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); }
|
||||
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
|
||||
Reference in New Issue
Block a user