mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Migrate remaining feature routes
This commit is contained in:
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user