Migrate remaining feature routes

This commit is contained in:
Zerebos
2026-05-23 16:37:09 -04:00
parent 3d6b6430ed
commit 68f25a2ea7
8 changed files with 2069 additions and 7 deletions
+3 -2
View File
@@ -2,7 +2,7 @@
import { page } from '$app/stores'
import {
House, Books, MagnifyingGlass,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, ClockCounterClockwise,
} from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
@@ -13,6 +13,7 @@
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
{ path: '/history', label: 'History', icon: ClockCounterClockwise },
] as const
const TAB_SIZE = 36
@@ -42,7 +43,7 @@
{#if activeIndex >= 0}
<div class="indicator" style="transform: translateX(-50%) translateY({indicatorY}px)"></div>
{/if}
{#each TABS as tab}
{#each TABS as tab (tab.path)}
<a
class="tab"
class:active={isActive(tab.path)}
+299 -1
View File
@@ -1,3 +1,301 @@
<script lang="ts">
import { onMount } from 'svelte'
import { BookOpen, Books, ClockCounterClockwise, DownloadSimple } from 'phosphor-svelte'
import { loadLibrary } from '$lib/request-manager/manga'
import { downloadCount } from '$lib/state/downloads.svelte'
import { historyState, initHistoryState } from '$lib/state/history.svelte'
import { libraryState } from '$lib/state/library.svelte'
</script>
const recentHistory = $derived(historyState.history.slice(0, 8))
const stats = $derived.by(() => [
{
label: 'Library Manga',
value: libraryState.items.length,
icon: Books,
},
{
label: 'Chapters Read',
value: historyState.readingStats.totalChaptersRead,
icon: BookOpen,
},
{
label: 'Active Downloads',
value: downloadCount,
icon: DownloadSimple,
},
{
label: 'Current Streak',
value: historyState.readingStats.currentStreakDays,
icon: ClockCounterClockwise,
suffix: 'days',
},
])
onMount(async () => {
await initHistoryState()
if (libraryState.items.length === 0) {
await loadLibrary({ inLibrary: true })
}
})
function formatTimestamp(value: number): string {
if (!value) return 'Unknown'
return new Date(value).toLocaleString()
}
</script>
<section class="home-page">
<header class="hero">
<div>
<p class="eyebrow">Dashboard</p>
<h1>Welcome back</h1>
<p class="subtitle">Quick read stats and recent progress across your library.</p>
</div>
<div class="shortcuts">
<a href="/library">Open Library</a>
<a href="/browse">Browse Sources</a>
<a href="/history">View History</a>
</div>
</header>
<section class="stats-grid" aria-label="Reading stats">
{#each stats as stat (stat.label)}
<article class="stat-card">
<div class="stat-icon"><stat.icon size={16} weight="bold" /></div>
<p class="stat-label">{stat.label}</p>
<p class="stat-value">
{stat.value}
{#if stat.suffix}
<span>{stat.suffix}</span>
{/if}
</p>
</article>
{/each}
</section>
<section class="recent-panel">
<div class="section-head">
<h2>Recent Activity</h2>
<a href="/history">Open full history</a>
</div>
{#if recentHistory.length === 0}
<div class="empty-state">No recent reading activity yet.</div>
{:else}
<ul class="recent-list">
{#each recentHistory as entry (`${entry.chapterId}-${entry.readAt}`)}
<li>
<a class="recent-row" href={`/series/${entry.mangaId}`}>
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName}</p>
</div>
<span class="time">{formatTimestamp(entry.readAt)}</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>
</section>
<style>
.home-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.hero {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background:
linear-gradient(135deg, color-mix(in srgb, var(--accent) 18%, transparent), transparent 58%),
var(--bg-raised);
padding: var(--sp-5);
}
.eyebrow {
margin: 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.hero h1 {
margin: var(--sp-1) 0 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: clamp(var(--text-2xl), 2.2vw, var(--text-3xl));
}
.subtitle {
margin: var(--sp-2) 0 0;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
max-width: 60ch;
}
.shortcuts {
display: flex;
flex-wrap: wrap;
align-items: start;
gap: var(--sp-2);
}
.shortcuts a,
.section-head a {
display: inline-flex;
align-items: center;
height: 32px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
padding: 0 10px;
text-decoration: none;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.stats-grid {
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.stat-card {
display: flex;
flex-direction: column;
gap: var(--sp-2);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-raised);
padding: var(--sp-3);
}
.stat-icon {
color: var(--accent-fg);
}
.stat-label {
margin: 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.stat-value {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
line-height: 1;
}
.stat-value span {
margin-left: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.recent-panel {
display: flex;
flex-direction: column;
gap: var(--sp-2);
min-height: 0;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
padding: var(--sp-4);
}
.section-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-2);
align-items: center;
}
.section-head h2 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-xl);
}
.recent-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.recent-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
text-decoration: none;
}
.row-main {
min-width: 0;
}
.title,
.meta,
.time {
margin: 0;
font-family: var(--font-ui);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-size: var(--text-sm);
}
.meta,
.time {
color: var(--text-faint);
font-size: var(--text-xs);
}
.empty-state {
display: grid;
place-items: center;
min-height: 120px;
border: 1px dashed var(--border-dim);
border-radius: var(--radius-lg);
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
</style>
+268 -1
View File
@@ -1 +1,268 @@
<p>browse</p>
<script lang="ts">
import { onMount } from 'svelte'
import { MagnifyingGlass, ArrowSquareOut } from 'phosphor-svelte'
import { loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
let query = $state('')
let language = $state('all')
let includeNsfw = $state(false)
const languages = $derived.by(() => {
const values: string[] = []
for (const source of extensionsState.sources) {
if (!values.includes(source.lang)) values.push(source.lang)
}
return values.sort((a, b) => a.localeCompare(b))
})
const filteredSources = $derived.by(() => {
const q = query.trim().toLowerCase()
return extensionsState.sources.filter(source => {
if (language !== 'all' && source.lang !== language) return false
if (!includeNsfw && source.isNsfw) return false
if (!q) return true
return (
source.displayName.toLowerCase().includes(q) ||
source.name.toLowerCase().includes(q)
)
})
})
onMount(async () => {
if (extensionsState.sources.length === 0) {
await loadSources()
}
})
</script>
<section class="browse-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Browse Sources</h1>
<p>{filteredSources.length} available</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Find source" bind:value={query} />
</label>
<label class="select-control">
<span>Language</span>
<select bind:value={language}>
<option value="all">All</option>
{#each languages as lang (lang)}
<option value={lang}>{lang.toUpperCase()}</option>
{/each}
</select>
</label>
<label class="checkbox-control">
<input type="checkbox" bind:checked={includeNsfw} />
<span>Include NSFW</span>
</label>
</div>
</header>
{#if extensionsState.error}
<div class="empty-state error-state">
<p>Unable to load sources.</p>
<small>{extensionsState.error}</small>
<button type="button" onclick={() => loadSources()}>Retry</button>
</div>
{:else if filteredSources.length === 0}
<div class="empty-state">No sources match the current filters.</div>
{:else}
<ul class="sources-grid">
{#each filteredSources as source (source.id)}
<li>
<a class="source-card" href={`/browse/${source.id}`}>
<div>
<p class="source-name">{source.displayName}</p>
<p class="source-meta">{source.lang.toUpperCase()} · {source.isNsfw ? 'NSFW' : 'Safe'}</p>
</div>
<ArrowSquareOut size={14} weight="bold" />
</a>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.browse-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 180px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
input,
select {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-primary);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.checkbox-control input {
width: 16px;
height: 16px;
margin: 0;
}
.sources-grid {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: var(--sp-3);
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.source-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-2);
min-height: 72px;
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
text-decoration: none;
transition: border-color var(--t-base), transform var(--t-base), color var(--t-base);
}
.source-card:hover {
border-color: var(--border-strong);
color: var(--text-primary);
transform: translateY(-1px);
}
.source-name {
margin: 0;
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.source-meta {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
.error-state button {
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
border-radius: var(--radius-md);
height: 30px;
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
+252
View File
@@ -0,0 +1,252 @@
<script lang="ts">
import { onMount } from 'svelte'
import { afterNavigate } from '$app/navigation'
import { ArrowLeft, ArrowsClockwise, CaretLeft, CaretRight, CircleNotch } from 'phosphor-svelte'
import { browseSource, loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import MangaCard from '$lib/ui/manga/MangaCard.svelte'
import type { PageProps } from './$types'
let { params }: PageProps = $props()
let page = $state(1)
let sourceId = $state('')
const currentSource = $derived(
extensionsState.sources.find(source => source.id === sourceId)
)
async function loadSourcePage(nextPage = 1) {
page = nextPage
await browseSource(sourceId, nextPage)
}
async function refresh() {
await loadSourcePage(page)
}
onMount(async () => {
sourceId = params.sourceId
if (extensionsState.sources.length === 0) {
await loadSources()
}
await loadSourcePage(1)
const unsubscribe = afterNavigate(() => {
if (params.sourceId === sourceId) return
sourceId = params.sourceId
void loadSourcePage(1)
})
return unsubscribe
})
</script>
<section class="browse-source-page">
<header class="toolbar">
<div class="title-wrap">
<a class="back-link" href="/browse">
<ArrowLeft size={14} weight="bold" />
All sources
</a>
<h1>{currentSource?.displayName ?? 'Source'}</h1>
<p>
{currentSource?.lang?.toUpperCase() ?? 'N/A'}
{#if currentSource?.isNsfw}
· NSFW
{/if}
</p>
</div>
<div class="controls">
<button type="button" class="icon-btn" onclick={refresh} disabled={extensionsState.browseLoading}>
{#if extensionsState.browseLoading}
<CircleNotch size={14} weight="light" class="spin" />
{:else}
<ArrowsClockwise size={14} weight="bold" />
{/if}
</button>
<div class="pager">
<button
type="button"
aria-label="Previous page"
onclick={() => loadSourcePage(Math.max(1, page - 1))}
disabled={page <= 1 || extensionsState.browseLoading}
>
<CaretLeft size={14} weight="bold" />
</button>
<span>Page {page}</span>
<button
type="button"
aria-label="Next page"
onclick={() => loadSourcePage(page + 1)}
disabled={!extensionsState.browseHasMore || extensionsState.browseLoading}
>
<CaretRight size={14} weight="bold" />
</button>
</div>
</div>
</header>
{#if extensionsState.browseError}
<div class="empty-state error-state">
<p>Unable to browse this source.</p>
<small>{extensionsState.browseError}</small>
<button type="button" onclick={refresh}>Retry</button>
</div>
{:else if extensionsState.browseLoading && extensionsState.browseResults.length === 0}
<div class="empty-state">Loading manga...</div>
{:else if extensionsState.browseResults.length === 0}
<div class="empty-state">No manga found on this page.</div>
{:else}
<div class="results">
{#each extensionsState.browseResults as manga (manga.id)}
<MangaCard manga={manga} href={`/series/${manga.id}`} />
{/each}
</div>
{/if}
</section>
<style>
.browse-source-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-4);
}
.title-wrap h1 {
margin: var(--sp-1) 0 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-muted);
text-decoration: none;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.icon-btn,
.pager button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
min-width: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
}
.icon-btn:disabled,
.pager button:disabled {
opacity: 0.4;
cursor: default;
}
.pager {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
}
.pager span {
min-width: 78px;
text-align: center;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.results {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-3);
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
.error-state button {
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
border-radius: var(--radius-md);
height: 30px;
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.spin {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
+300 -1
View File
@@ -1 +1,300 @@
<p>downloads</p>
<script lang="ts">
import { onMount } from 'svelte'
import { ArrowsClockwise, DownloadSimple, TrashSimple, XCircle } from 'phosphor-svelte'
import { clearDownloads, dequeueDownload, loadDownloads } from '$lib/request-manager/downloads'
import { activeDownloads, downloadCount, downloadsState, queuedDownloads } from '$lib/state/downloads.svelte'
let busy = $state(false)
onMount(async () => {
await loadDownloads()
})
async function refresh() {
busy = true
try {
await loadDownloads()
} finally {
busy = false
}
}
async function removeItem(chapterId: string) {
busy = true
try {
await dequeueDownload(chapterId)
} finally {
busy = false
}
}
async function clearAll() {
busy = true
try {
await clearDownloads()
} finally {
busy = false
}
}
function progressLabel(progress: number): string {
const pct = Math.round(Math.max(0, Math.min(1, progress)) * 100)
return `${pct}%`
}
</script>
<section class="downloads-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Downloads</h1>
<p>{downloadCount} total · {activeDownloads.length} active · {queuedDownloads.length} queued</p>
</div>
<div class="actions">
<button type="button" onclick={refresh} disabled={busy}>
<ArrowsClockwise size={14} weight="bold" /> Refresh
</button>
<button type="button" class="danger" onclick={clearAll} disabled={busy || downloadCount === 0}>
<TrashSimple size={14} weight="bold" /> Clear all
</button>
</div>
</header>
{#if downloadsState.error}
<div class="empty-state error-state">
<p>Unable to load downloads.</p>
<small>{downloadsState.error}</small>
<button type="button" onclick={refresh} disabled={busy}>Retry</button>
</div>
{:else if downloadsState.items.length === 0}
<div class="empty-state">
<DownloadSimple size={16} weight="light" />
Nothing in the queue.
</div>
{:else}
<ul class="downloads-list">
{#each downloadsState.items as item (item.chapterId)}
<li class="download-row" class:done={item.state === 'finished'} class:failed={item.state === 'error'}>
<div class="row-main">
<p class="title">{item.mangaTitle}</p>
<p class="meta">{item.chapterName}</p>
</div>
<div class="row-right">
<span class="state-pill">{item.state}</span>
<span class="progress-text">{progressLabel(item.progress)}</span>
<button
type="button"
class="icon-btn"
aria-label="Remove from queue"
onclick={() => removeItem(item.chapterId)}
disabled={busy}
>
<XCircle size={16} weight="bold" />
</button>
</div>
<div class="progress-track">
<div class="progress-fill" style={`width: ${progressLabel(item.progress)}`}></div>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.downloads-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.actions {
display: inline-flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.actions button,
.error-state button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 12px;
cursor: pointer;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.actions .danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.actions button:disabled,
.icon-btn:disabled,
.error-state button:disabled {
opacity: 0.5;
cursor: default;
}
.downloads-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.download-row {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--sp-2) var(--sp-3);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.download-row.done {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border-dim));
}
.download-row.failed {
border-color: color-mix(in srgb, var(--color-error) 40%, var(--border-dim));
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-right {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.state-pill {
border-radius: 999px;
border: 1px solid var(--border-dim);
padding: 3px 8px;
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.progress-text {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
min-width: 34px;
text-align: right;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-faint);
cursor: pointer;
}
.progress-track {
grid-column: 1 / -1;
height: 6px;
border-radius: 999px;
background: var(--bg-overlay);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: var(--accent);
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
flex-direction: column;
text-align: center;
padding: var(--sp-4);
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+350 -1
View File
@@ -1 +1,350 @@
<p>extensions</p>
<script lang="ts">
import { onMount } from 'svelte'
import { ArrowsClockwise, DownloadSimple, TrashSimple, ArrowFatUp, MagnifyingGlass } from 'phosphor-svelte'
import {
installExtension,
loadExtensions,
uninstallExtension,
updateExtension,
} from '$lib/request-manager/extensions'
import { extensionsState, filteredExtensions } from '$lib/state/extensions.svelte'
let busyIds = $state<string[]>([])
const languageOptions = $derived.by(() => {
const values: string[] = []
for (const extension of extensionsState.items) {
if (!values.includes(extension.lang)) values.push(extension.lang)
}
return values.sort((a, b) => a.localeCompare(b))
})
onMount(async () => {
await loadExtensions()
})
function isBusy(id: string): boolean {
return busyIds.includes(id)
}
function addBusy(id: string) {
if (busyIds.includes(id)) return
busyIds = [...busyIds, id]
}
function removeBusy(id: string) {
busyIds = busyIds.filter(value => value !== id)
}
async function refresh() {
await loadExtensions()
}
async function install(id: string) {
addBusy(id)
try {
await installExtension(id)
} finally {
removeBusy(id)
}
}
async function uninstall(id: string) {
addBusy(id)
try {
await uninstallExtension(id)
} finally {
removeBusy(id)
}
}
async function update(id: string) {
addBusy(id)
try {
await updateExtension(id)
} finally {
removeBusy(id)
}
}
function clearFilters() {
extensionsState.filter.query = ''
extensionsState.filter.installed = false
extensionsState.filter.language = 'all'
}
</script>
<section class="extensions-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Extensions</h1>
<p>{filteredExtensions.length} shown · {extensionsState.items.length} total</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Find extension" bind:value={extensionsState.filter.query} />
</label>
<label class="select-control">
<span>Language</span>
<select bind:value={extensionsState.filter.language}>
<option value="all">All</option>
{#each languageOptions as lang (lang)}
<option value={lang}>{lang.toUpperCase()}</option>
{/each}
</select>
</label>
<label class="checkbox-control">
<input type="checkbox" bind:checked={extensionsState.filter.installed} />
<span>Installed only</span>
</label>
<button type="button" onclick={clearFilters}>Clear</button>
<button type="button" onclick={refresh} disabled={extensionsState.loading}>
<ArrowsClockwise size={14} weight="bold" />
</button>
</div>
</header>
{#if extensionsState.error}
<div class="empty-state error-state">
<p>Unable to load extensions.</p>
<small>{extensionsState.error}</small>
<button type="button" onclick={refresh}>Retry</button>
</div>
{:else if extensionsState.loading && extensionsState.items.length === 0}
<div class="empty-state">Loading extensions...</div>
{:else if filteredExtensions.length === 0}
<div class="empty-state">No extensions match the current filters.</div>
{:else}
<ul class="extensions-list">
{#each filteredExtensions as extension (extension.id)}
<li class="extension-row">
<div class="row-main">
<p class="title">{extension.name}</p>
<p class="meta">
{extension.lang.toUpperCase()} · v{extension.versionName}
{#if extension.isObsolete}
· Obsolete
{/if}
</p>
</div>
<div class="row-actions">
{#if extension.hasUpdate}
<button type="button" onclick={() => update(extension.id)} disabled={isBusy(extension.id)}>
<ArrowFatUp size={14} weight="bold" /> Update
</button>
{/if}
{#if extension.isInstalled}
<button type="button" class="danger" onclick={() => uninstall(extension.id)} disabled={isBusy(extension.id)}>
<TrashSimple size={14} weight="bold" /> Remove
</button>
{:else}
<button type="button" onclick={() => install(extension.id)} disabled={isBusy(extension.id)}>
<DownloadSimple size={14} weight="bold" /> Install
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.extensions-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.search input,
.select-control select,
.controls button {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.search input {
min-width: 180px;
color: var(--text-primary);
}
.controls button {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.checkbox-control {
display: inline-flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 10px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.extensions-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.extension-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-actions {
display: inline-flex;
gap: var(--sp-2);
flex-shrink: 0;
}
.row-actions button,
.error-state button {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
cursor: pointer;
}
.row-actions .danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+296
View File
@@ -0,0 +1,296 @@
<script lang="ts">
import { onMount } from 'svelte'
import { BookOpen, BookmarkSimple, TrashSimple, MagnifyingGlass } from 'phosphor-svelte'
import {
clearHistory,
historyState,
historyStatus,
initHistoryState,
removeBookmark,
} from '$lib/state/history.svelte'
let tab = $state<'history' | 'bookmarks'>('history')
let query = $state('')
const filteredHistory = $derived.by(() => {
const q = query.trim().toLowerCase()
if (!q) return historyState.history
return historyState.history.filter(entry =>
entry.mangaTitle.toLowerCase().includes(q) || entry.chapterName.toLowerCase().includes(q)
)
})
const filteredBookmarks = $derived.by(() => {
const q = query.trim().toLowerCase()
if (!q) return historyState.bookmarks
return historyState.bookmarks.filter(entry =>
entry.mangaTitle.toLowerCase().includes(q) || entry.chapterName.toLowerCase().includes(q)
)
})
onMount(async () => {
await initHistoryState()
})
function formatTimestamp(value: number): string {
if (!value) return 'Unknown'
return new Date(value).toLocaleString()
}
</script>
<section class="history-page">
<header class="toolbar">
<div class="title-wrap">
<h1>History</h1>
<p>
{historyState.history.length} reads ·
{historyState.bookmarks.length} bookmarks ·
{historyState.readingStats.totalChaptersRead} chapters completed
</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Filter history" bind:value={query} />
</label>
<div class="tabs">
<button type="button" class:active={tab === 'history'} onclick={() => (tab = 'history')}>
<BookOpen size={14} weight="bold" /> Reads
</button>
<button type="button" class:active={tab === 'bookmarks'} onclick={() => (tab = 'bookmarks')}>
<BookmarkSimple size={14} weight="bold" /> Bookmarks
</button>
</div>
<button
type="button"
class="danger"
onclick={() => clearHistory()}
disabled={historyState.history.length === 0}
>
<TrashSimple size={14} weight="bold" /> Clear reads
</button>
</div>
</header>
{#if historyStatus.loading}
<div class="empty-state">Loading history...</div>
{:else if historyStatus.error}
<div class="empty-state error-state">
<p>Unable to load local history data.</p>
<small>{historyStatus.error}</small>
</div>
{:else if tab === 'history' && filteredHistory.length === 0}
<div class="empty-state">No reading history matches your filter.</div>
{:else if tab === 'bookmarks' && filteredBookmarks.length === 0}
<div class="empty-state">No bookmarks match your filter.</div>
{:else if tab === 'history'}
<ul class="entry-list">
{#each filteredHistory as entry (`h-${entry.chapterId}-${entry.readAt}`)}
<li class="entry-row">
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName}</p>
</div>
<span class="time">{formatTimestamp(entry.readAt)}</span>
</li>
{/each}
</ul>
{:else}
<ul class="entry-list">
{#each filteredBookmarks as entry (`b-${entry.chapterId}-${entry.savedAt}`)}
<li class="entry-row">
<div class="row-main">
<p class="title">{entry.mangaTitle}</p>
<p class="meta">{entry.chapterName} · page {entry.pageNumber}</p>
</div>
<div class="row-actions">
<span class="time">{formatTimestamp(entry.savedAt)}</span>
<button type="button" onclick={() => removeBookmark(entry.chapterId)}>Remove</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.history-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search {
display: flex;
flex-direction: column;
gap: 6px;
}
.search span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.search input,
.danger,
.tabs button,
.row-actions button {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.search input {
min-width: 180px;
color: var(--text-primary);
}
.tabs {
display: inline-flex;
gap: var(--sp-1);
}
.tabs button,
.danger,
.row-actions button {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.tabs button.active {
background: var(--accent-muted);
border-color: var(--accent-dim);
color: var(--accent-fg);
}
.danger {
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 32%, var(--border-dim));
}
.entry-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.entry-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta,
.time {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-actions {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
</style>
+301 -1
View File
@@ -1 +1,301 @@
<p>tracking</p>
<script lang="ts">
import { onMount } from 'svelte'
import { ArrowsClockwise, MagnifyingGlass } from 'phosphor-svelte'
import { loadTrackers, syncTracking } from '$lib/request-manager/tracking'
import { trackingState } from '$lib/state/tracking.svelte'
import type { Tracker, TrackRecord } from '$lib/types/tracking'
let query = $state('')
let trackerFilter = $state<'all' | 'connected'>('all')
const visibleTrackers = $derived.by(() => {
if (trackerFilter === 'connected') {
return trackingState.trackers.filter(tracker => tracker.isLoggedIn)
}
return trackingState.trackers
})
const records = $derived.by(() => {
const q = query.trim().toLowerCase()
const list: Array<{ tracker: Tracker; record: TrackRecord }> = []
for (const tracker of visibleTrackers) {
for (const record of tracker.trackRecords?.nodes ?? []) {
if (
q &&
!record.title.toLowerCase().includes(q) &&
!tracker.name.toLowerCase().includes(q)
) {
continue
}
list.push({ tracker, record })
}
}
return list.sort((a, b) => a.record.title.localeCompare(b.record.title))
})
onMount(async () => {
await loadTrackers()
})
async function refresh() {
await loadTrackers()
}
async function syncRecord(record: TrackRecord) {
if (!record.libraryId) return
await syncTracking(record.libraryId)
}
function statusLabel(tracker: Tracker, statusValue: number): string {
return tracker.statuses.find(status => status.value === statusValue)?.name ?? 'Unknown'
}
</script>
<section class="tracking-page">
<header class="toolbar">
<div class="title-wrap">
<h1>Tracking</h1>
<p>{visibleTrackers.length} trackers · {records.length} records</p>
</div>
<div class="controls">
<label class="search">
<span><MagnifyingGlass size={14} weight="light" /> Search</span>
<input type="search" placeholder="Find tracked title" bind:value={query} />
</label>
<label class="select-control">
<span>View</span>
<select bind:value={trackerFilter}>
<option value="all">All trackers</option>
<option value="connected">Connected only</option>
</select>
</label>
<button type="button" onclick={refresh} disabled={trackingState.loading || trackingState.syncing}>
<ArrowsClockwise size={14} weight="bold" />
</button>
</div>
</header>
{#if trackingState.error}
<div class="empty-state error-state">
<p>Unable to load tracking data.</p>
<small>{trackingState.error}</small>
<button type="button" onclick={refresh}>Retry</button>
</div>
{:else if trackingState.loading && trackingState.trackers.length === 0}
<div class="empty-state">Loading trackers...</div>
{:else if records.length === 0}
<div class="empty-state">No tracked entries match the current filters.</div>
{:else}
<ul class="records-list">
{#each records as item (`${item.tracker.id}:${item.record.id}`)}
<li class="record-row">
<div class="row-main">
<p class="title">{item.record.title}</p>
<p class="meta">
{item.tracker.name} · {statusLabel(item.tracker, item.record.status)}
· {item.record.lastChapterRead}/{item.record.totalChapters || '?'}
</p>
</div>
<div class="row-actions">
{#if item.record.remoteUrl}
<a class="link-btn" href={item.record.remoteUrl} target="_blank" rel="noreferrer">Open</a>
{/if}
<button
type="button"
onclick={() => syncRecord(item.record)}
disabled={!item.record.libraryId || trackingState.syncing}
>
Sync
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.tracking-page {
display: flex;
flex-direction: column;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-6);
overflow: auto;
}
.toolbar {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: var(--sp-3);
}
.title-wrap h1 {
margin: 0;
color: var(--text-primary);
font-family: var(--font-display);
font-size: var(--text-2xl);
}
.title-wrap p {
margin: var(--sp-1) 0 0;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--sp-2);
}
.search,
.select-control {
display: flex;
flex-direction: column;
gap: 6px;
}
.search span,
.select-control span {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.search input,
.select-control select,
.controls button {
height: 34px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.search input {
min-width: 180px;
color: var(--text-primary);
}
.controls button {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.records-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.record-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
}
.row-main {
min-width: 0;
}
.title,
.meta {
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title {
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.meta {
color: var(--text-faint);
font-family: var(--font-ui);
font-size: var(--text-xs);
}
.row-actions {
display: inline-flex;
gap: var(--sp-2);
flex-shrink: 0;
}
.row-actions button,
.row-actions .link-btn,
.error-state button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
padding: 0 10px;
font-family: var(--font-ui);
font-size: var(--text-xs);
text-decoration: none;
}
.row-actions button:disabled {
opacity: 0.5;
cursor: default;
}
.empty-state {
display: grid;
place-items: center;
min-height: 220px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
background: var(--bg-raised);
color: var(--text-muted);
font-family: var(--font-ui);
font-size: var(--text-sm);
}
.error-state {
gap: 8px;
padding: var(--sp-4);
text-align: center;
}
.error-state p,
.error-state small {
margin: 0;
}
</style>