mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Recent Tab (Unread State) + Bug Fixes
This commit is contained in:
+12
-14
@@ -1,12 +1,13 @@
|
||||
import { detectAdapter } from '$lib/platform-adapters'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { initRequestManager } from '$lib/request-manager'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||
import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist'
|
||||
import { loadSettingsIntoState } from '$lib/state/settings.svelte'
|
||||
import { historyState } from '$lib/state/history.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import { detectAdapter } from '$lib/platform-adapters'
|
||||
import { initPlatformService } from '$lib/platform-service'
|
||||
import { initRequestManager } from '$lib/request-manager'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { configureAuth, probeServer } from '$lib/core/auth'
|
||||
import { loadSettings, loadLibrary } from '$lib/core/persistence/persist'
|
||||
import { loadSettingsIntoState, settingsState } from '$lib/state/settings.svelte'
|
||||
import { historyState } from '$lib/state/history.svelte'
|
||||
import { readerState } from '$lib/state/reader.svelte'
|
||||
import { seriesState } from '$lib/state/series.svelte'
|
||||
|
||||
const KEY_URL = 'moku_server_url'
|
||||
const KEY_AUTH = 'moku_auth_config'
|
||||
@@ -34,12 +35,11 @@ async function boot() {
|
||||
const [settingsData, libraryData] = await Promise.all([
|
||||
loadSettings(),
|
||||
loadLibrary(),
|
||||
loadUpdates(),
|
||||
])
|
||||
|
||||
await loadSettingsIntoState(settingsData.settings)
|
||||
|
||||
readerState.bookmarks = libraryData.bookmarks
|
||||
seriesState.bookmarks = libraryData.bookmarks
|
||||
readerState.markers = libraryData.markers
|
||||
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
|
||||
|
||||
@@ -49,8 +49,6 @@ async function boot() {
|
||||
|
||||
appState.serverUrl = savedUrl
|
||||
appState.authMode = savedAuth.mode
|
||||
appState.authUser = savedAuth.user ?? ''
|
||||
appState.authPass = savedAuth.pass ?? ''
|
||||
|
||||
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass)
|
||||
|
||||
@@ -63,7 +61,7 @@ async function boot() {
|
||||
})
|
||||
|
||||
const isTauri = platformAdapter.platform === 'tauri'
|
||||
const autoStartServer = settingsData.settings.autoStartServer ?? false
|
||||
const autoStartServer = settingsState.settings.autoStartServer
|
||||
|
||||
if (isTauri && autoStartServer) {
|
||||
appState.status = 'booting'
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
label: "New folder & add",
|
||||
icon: FolderSimplePlusIcon,
|
||||
onClick: async () => {
|
||||
const name = prompt("FolderIcon name:");
|
||||
const name = prompt("Folder name:");
|
||||
if (!name?.trim()) return;
|
||||
const cat = await getAdapter().createCategory(name.trim()).catch(console.error);
|
||||
if (cat) {
|
||||
|
||||
@@ -43,20 +43,20 @@
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
interface Props {
|
||||
mode?: 'loading' | 'idle' | 'locked'
|
||||
ringFull?: boolean
|
||||
failed?: boolean
|
||||
notConfigured?: boolean
|
||||
showCards?: boolean
|
||||
showFps?: boolean
|
||||
mode?: 'loading' | 'idle' | 'locked'
|
||||
ringFull?: boolean
|
||||
failed?: boolean
|
||||
notConfigured?: boolean
|
||||
showCards?: boolean
|
||||
showFps?: boolean
|
||||
showDevOverlay?: boolean
|
||||
pinLen?: number
|
||||
pinCorrect?: string
|
||||
onReady?: () => void
|
||||
onUnlock?: () => void
|
||||
onRetry?: () => void
|
||||
onBypass?: () => void
|
||||
onDismiss?: () => void
|
||||
pinLen?: number
|
||||
pinCorrect?: string
|
||||
onReady?: () => void
|
||||
onUnlock?: () => void
|
||||
onRetry?: () => void
|
||||
onBypass?: () => void
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -97,7 +97,7 @@
|
||||
setTimeout(() => cb?.(), EXIT_MS)
|
||||
}
|
||||
|
||||
let animFrame: number
|
||||
let animFrame = 0
|
||||
let animStart: number | null = null
|
||||
let animPhase = 1
|
||||
|
||||
@@ -117,19 +117,18 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (mode === 'loading' && !failed && !notConfigured && !ringFull) {
|
||||
if (!isTauri) return
|
||||
animStart = null
|
||||
animPhase = 1
|
||||
animFrame = requestAnimationFrame(animateRing)
|
||||
return () => cancelAnimationFrame(animFrame)
|
||||
}
|
||||
if (mode !== 'loading' || failed || notConfigured || ringFull || !isTauri) return
|
||||
animStart = null
|
||||
animPhase = 1
|
||||
animFrame = requestAnimationFrame(animateRing)
|
||||
return () => cancelAnimationFrame(animFrame)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
|
||||
cancelAnimationFrame(animFrame)
|
||||
ringProg = 1
|
||||
animFrame = 0
|
||||
ringProg = 1
|
||||
setTimeout(() => triggerExit(onReady), 650)
|
||||
})
|
||||
|
||||
@@ -179,6 +178,7 @@
|
||||
window.removeEventListener('touchstart', handler)
|
||||
}
|
||||
}
|
||||
|
||||
return () => clearInterval(iv)
|
||||
})
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin)
|
||||
const travel = vh + h + BUF
|
||||
cards.push({
|
||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
|
||||
w, h,
|
||||
lines: 1 + Math.floor(hash(seed + 7) * 3),
|
||||
alpha: cfg.alpha,
|
||||
@@ -307,11 +307,12 @@
|
||||
ctx.drawImage(vignette, 0, 0, cw, ch)
|
||||
}
|
||||
|
||||
let fps = 0, fpsFrames = 0, fpsLast = 0
|
||||
let fpsFrames = 0, fpsLast = -1
|
||||
function tickFps(now: number) {
|
||||
if (fpsLast < 0) { fpsLast = now; return }
|
||||
fpsFrames++
|
||||
if (now - fpsLast >= 500) {
|
||||
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
|
||||
const fps = Math.round(fpsFrames / ((now - fpsLast) / 1000))
|
||||
fpsFrames = 0
|
||||
fpsLast = now
|
||||
if (fpsEl) fpsEl.textContent = `${fps} fps`
|
||||
@@ -370,7 +371,7 @@
|
||||
function cleanup() {
|
||||
if (live) {
|
||||
live.stamps.forEach(c => { c.width = 0; c.height = 0 })
|
||||
live.vignette.width = 0
|
||||
live.vignette.width = 0
|
||||
live.vignette.height = 0
|
||||
live = null
|
||||
}
|
||||
@@ -444,14 +445,17 @@
|
||||
|
||||
document.addEventListener('visibilitychange', onVis)
|
||||
raf = requestAnimationFrame(frame)
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
cleanup()
|
||||
extraCleanup?.()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
if (isDev && mode === 'idle') {
|
||||
splashDevUnregister(el)
|
||||
devLiveCount = splashDevLiveCount()
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
cancelAnimationFrame(raf)
|
||||
cleanup()
|
||||
extraCleanup?.()
|
||||
document.removeEventListener('visibilitychange', onVis)
|
||||
if (isDev && mode === 'idle') {
|
||||
splashDevUnregister(el)
|
||||
devLiveCount = splashDevLiveCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -548,7 +552,7 @@
|
||||
|
||||
.logo-wrap { position:relative; width:72px; height:72px; display:flex; align-items:center; justify-content:center; }
|
||||
|
||||
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||
.pin-card { z-index:1; width:min(280px,calc(100vw - 48px)); background:var(--bg-surface); border:1px solid var(--border-base); border-radius:var(--radius-xl); padding:var(--sp-6) var(--sp-5); display:flex; flex-direction:column; align-items:center; gap:var(--sp-3); box-shadow:0 32px 80px rgba(0,0,0,0.75); animation:cardIn 0.38s cubic-bezier(0.22,1,0.36,1) 0.06s both; }
|
||||
.pin-card--leaving { animation:cardOut 0.28s cubic-bezier(0.4,0,1,1) both; }
|
||||
|
||||
.pin-label { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wider); text-transform:uppercase; margin:0; }
|
||||
|
||||
@@ -16,15 +16,22 @@
|
||||
let closeDialogOpen = $state(false)
|
||||
let closeRemember = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
isFullscreen = await win.isFullscreen()
|
||||
const unlistenResize = await win.onResized(async () => {
|
||||
onMount(() => {
|
||||
let unlistenResize: (() => void) | undefined
|
||||
let unlistenClose: (() => void) | undefined
|
||||
|
||||
win.isFullscreen().then(v => { isFullscreen = v })
|
||||
|
||||
win.onResized(async () => {
|
||||
isFullscreen = await win.isFullscreen()
|
||||
})
|
||||
const unlistenClose = await win.listen('tauri://close-requested', handleCloseRequested)
|
||||
}).then(u => { unlistenResize = u })
|
||||
|
||||
win.listen('tauri://close-requested', handleCloseRequested)
|
||||
.then(u => { unlistenClose = u })
|
||||
|
||||
return () => {
|
||||
unlistenResize()
|
||||
unlistenClose()
|
||||
unlistenResize?.()
|
||||
unlistenClose?.()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -56,6 +63,10 @@
|
||||
if (choice === 'tray') await doHide()
|
||||
else await doQuit()
|
||||
}
|
||||
|
||||
function onBackdropKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { closeDialogOpen = false; closeRemember = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isFullscreen}
|
||||
@@ -99,8 +110,21 @@
|
||||
{/if}
|
||||
|
||||
{#if closeDialogOpen}
|
||||
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
|
||||
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
class="close-backdrop"
|
||||
role="presentation"
|
||||
onclick={() => { closeDialogOpen = false; closeRemember = false }}
|
||||
onkeydown={onBackdropKey}
|
||||
>
|
||||
<div
|
||||
class="close-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Close Moku"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="close-header">
|
||||
<p class="close-title">Close Moku?</p>
|
||||
<p class="close-sub">Choose how the app should exit.</p>
|
||||
@@ -169,6 +193,7 @@
|
||||
0 24px 64px rgba(0,0,0,0.7),
|
||||
0 8px 24px rgba(0,0,0,0.4);
|
||||
animation: cdPop 0.22s cubic-bezier(0.16,1,0.3,1) both;
|
||||
outline: none;
|
||||
}
|
||||
@keyframes cdPop { from { opacity: 0; transform: scale(0.96) translateY(6px) } to { opacity: 1; transform: none } }
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
const CARD_COUNT = 18
|
||||
const CARD_W = 52
|
||||
const CARD_H = 72
|
||||
const CARD_RADIUS = 6
|
||||
const DRIFT_SPEED = 0.018
|
||||
|
||||
interface Card {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
rot: number
|
||||
vrot: number
|
||||
opacity: number
|
||||
scale: number
|
||||
hue: number
|
||||
}
|
||||
|
||||
function makeCard(w: number, h: number): Card {
|
||||
const side = Math.floor(Math.random() * 4)
|
||||
const margin = 80
|
||||
let x = 0, y = 0
|
||||
if (side === 0) { x = Math.random() * w; y = -margin }
|
||||
if (side === 1) { x = w + margin; y = Math.random() * h }
|
||||
if (side === 2) { x = Math.random() * w; y = h + margin }
|
||||
if (side === 3) { x = -margin; y = Math.random() * h }
|
||||
const cx = w / 2, cy = h / 2
|
||||
const dx = cx - x, dy = cy - y
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const spd = 0.12 + Math.random() * 0.1
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx: (dx / len) * spd * (0.3 + Math.random() * 0.4),
|
||||
vy: (dy / len) * spd * (0.3 + Math.random() * 0.4),
|
||||
rot: Math.random() * Math.PI * 2,
|
||||
vrot: (Math.random() - 0.5) * 0.006,
|
||||
opacity: 0.025 + Math.random() * 0.055,
|
||||
scale: 0.7 + Math.random() * 0.7,
|
||||
hue: 120 + Math.random() * 40,
|
||||
}
|
||||
}
|
||||
|
||||
function drawCard(ctx: CanvasRenderingContext2D, c: Card) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = c.opacity
|
||||
ctx.translate(c.x, c.y)
|
||||
ctx.rotate(c.rot)
|
||||
ctx.scale(c.scale, c.scale)
|
||||
|
||||
const w = CARD_W, h = CARD_H, r = CARD_RADIUS
|
||||
const x = -w / 2, y = -h / 2
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x + r, y)
|
||||
ctx.lineTo(x + w - r, y)
|
||||
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
|
||||
ctx.lineTo(x + w, y + h - r)
|
||||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
|
||||
ctx.lineTo(x + r, y + h)
|
||||
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
|
||||
ctx.lineTo(x, y + r)
|
||||
ctx.quadraticCurveTo(x, y, x + r, y)
|
||||
ctx.closePath()
|
||||
|
||||
ctx.strokeStyle = `hsla(${c.hue}, 28%, 62%, 0.9)`
|
||||
ctx.lineWidth = 1 / c.scale
|
||||
ctx.stroke()
|
||||
|
||||
const grad = ctx.createLinearGradient(x, y, x, y + h)
|
||||
grad.addColorStop(0, `hsla(${c.hue}, 20%, 40%, 0.18)`)
|
||||
grad.addColorStop(1, `hsla(${c.hue}, 20%, 20%, 0.06)`)
|
||||
ctx.fillStyle = grad
|
||||
ctx.fill()
|
||||
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function mountCardCanvas(canvas: HTMLCanvasElement) {
|
||||
const ctx = canvas.getContext('2d')!
|
||||
let cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth || 800, canvas.offsetHeight || 600))
|
||||
let raf = 0
|
||||
let running = true
|
||||
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = canvas.offsetWidth * dpr
|
||||
canvas.height = canvas.offsetHeight * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
cards = Array.from({ length: CARD_COUNT }, () => makeCard(canvas.offsetWidth, canvas.offsetHeight))
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!running) return
|
||||
const w = canvas.offsetWidth, h = canvas.offsetHeight
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
for (const c of cards) {
|
||||
c.x += c.vx
|
||||
c.y += c.vy
|
||||
c.rot += c.vrot
|
||||
const pad = 120
|
||||
if (c.x < -pad || c.x > w + pad || c.y < -pad || c.y > h + pad) {
|
||||
Object.assign(c, makeCard(w, h))
|
||||
}
|
||||
drawCard(ctx, c)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(resize)
|
||||
ro.observe(canvas)
|
||||
resize()
|
||||
tick()
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
running = false
|
||||
cancelAnimationFrame(raf)
|
||||
ro.disconnect()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function ringGeometry(r: number, pad: number) {
|
||||
const size = (r + pad) * 2
|
||||
const c = size / 2
|
||||
const circ = 2 * Math.PI * r
|
||||
return { size, c, circ }
|
||||
}
|
||||
|
||||
const RING_STEPS = [
|
||||
{ target: 0.15, duration: 400 },
|
||||
{ target: 0.45, duration: 800 },
|
||||
{ target: 0.72, duration: 600 },
|
||||
{ target: 0.88, duration: 1000 },
|
||||
{ target: 0.96, duration: 700 },
|
||||
]
|
||||
|
||||
export function animateRingProgress(onProgress: (p: number) => void): () => void {
|
||||
let current = 0.025
|
||||
let stepIdx = 0
|
||||
let start = performance.now()
|
||||
let raf = 0
|
||||
let stopped = false
|
||||
|
||||
function ease(t: number) {
|
||||
return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
if (stopped) return
|
||||
if (stepIdx >= RING_STEPS.length) return
|
||||
|
||||
const step = RING_STEPS[stepIdx]
|
||||
const elapsed = now - start
|
||||
const t = Math.min(elapsed / step.duration, 1)
|
||||
const from = stepIdx === 0 ? 0.025 : RING_STEPS[stepIdx - 1].target
|
||||
current = from + (step.target - from) * ease(t)
|
||||
onProgress(current)
|
||||
|
||||
if (t >= 1) {
|
||||
stepIdx++
|
||||
start = now
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => { stopped = true; cancelAnimationFrame(raf) }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
|
||||
import { CircleNotchIcon, ArrowClockwiseIcon, XIcon } from "phosphor-svelte";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import { longPress } from "$lib/core/ui/touchscreen";
|
||||
import type { DownloadQueueItem } from "$lib/types/api";
|
||||
@@ -78,12 +78,12 @@
|
||||
<div class="actions">
|
||||
{#if isError}
|
||||
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
|
||||
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwiseIcon size={11} weight="bold" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isActive}
|
||||
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
|
||||
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
|
||||
{#if isRemoving}<CircleNotchIcon size={11} weight="light" class="anim-spin" />{:else}<XIcon size={12} weight="light" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import { CircleNotchIcon } from "phosphor-svelte";
|
||||
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
|
||||
import type { DownloadQueueItem } from "$lib/types/api";
|
||||
|
||||
@@ -9,16 +9,14 @@
|
||||
isRunning: boolean;
|
||||
dequeueing: Set<number>;
|
||||
selected: Set<number>;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onReorder: (chapterId: number, dir: "up" | "down") => void;
|
||||
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
onRemove: (chapterId: number) => void;
|
||||
onRetry: (chapterId: number) => void;
|
||||
onSelect: (chapterId: number, e: MouseEvent) => void;
|
||||
}
|
||||
|
||||
const {
|
||||
queue, loading, isRunning, dequeueing, selected,
|
||||
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
|
||||
onRemove, onRetry, onSelect,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
@@ -54,8 +52,6 @@
|
||||
isSelected={selected.has(item.chapter.id)}
|
||||
{onRemove}
|
||||
{onRetry}
|
||||
{onReorder}
|
||||
{onReorderEdge}
|
||||
{onSelect}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -165,10 +165,8 @@
|
||||
isRunning={downloadStore.isRunning}
|
||||
dequeueing={downloadStore.dequeueing}
|
||||
selected={downloadStore.selected}
|
||||
onRemove={(id) => downloadStore.dequeue(id)}
|
||||
onRetry={(id) => downloadStore.retryOne(id)}
|
||||
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
|
||||
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
|
||||
onRemove={(id: number) => downloadStore.dequeue(id)}
|
||||
onRetry={(id: number) => downloadStore.retryOne(id)}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
@@ -219,7 +217,7 @@
|
||||
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
|
||||
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
|
||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
|
||||
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; appearance: textfield; }
|
||||
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
|
||||
.move-input:focus { color: var(--text-primary); }
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
|
||||
|
||||
const isLocal = pkgName === '__local__';
|
||||
const isLocal = $derived(pkgName === '__local__');
|
||||
|
||||
let groups: SourceLibrary[] = $state([]);
|
||||
let sourceNodes: SourceNode[] = $state([]);
|
||||
@@ -409,7 +409,7 @@
|
||||
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
|
||||
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
|
||||
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
|
||||
.card.anims .card-title { transition: color var(--t-base); }
|
||||
|
||||
.card-skeleton { padding: 0; }
|
||||
|
||||
@@ -17,7 +17,7 @@ export type SourceNode = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
isConfigurable: boolean;
|
||||
extension: { pkgName: string };
|
||||
extension?: { pkgName: string };
|
||||
};
|
||||
|
||||
export function libraryByExtension(
|
||||
|
||||
@@ -112,12 +112,10 @@
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
entries[i] = { ...entries[i], status: "searching" };
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
|
||||
});
|
||||
const results = d.fetchSourceManga.mangas
|
||||
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
||||
.sort((a, b) => b.similarity - a.similarity);
|
||||
const mangas = await getAdapter().searchManga(entries[i].manga.title, target.id);
|
||||
const results = mangas
|
||||
.map((m: Manga) => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
|
||||
.sort((a: { manga: Manga; similarity: number }, b: { manga: Manga; similarity: number }) => b.similarity - a.similarity);
|
||||
|
||||
if (results.length > 0 && results[0].similarity > 0.3) {
|
||||
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
|
||||
@@ -147,17 +145,15 @@
|
||||
for (const entry of toMigrate) {
|
||||
const idx = entries.indexOf(entry);
|
||||
try {
|
||||
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
|
||||
const newChaps = d.fetchChapters.chapters;
|
||||
const newChaps = await getAdapter().fetchChapters(String(entry.match!.id));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
const toMarkBookmarked: number[] = [];
|
||||
const toMarkRead: number[] = [];
|
||||
|
||||
for (const nc of newChaps) {
|
||||
const oldIdx = entries[idx].manga;
|
||||
if (oldIdx) {
|
||||
toMarkRead.push(nc.id);
|
||||
}
|
||||
// LibraryManga has no chapter detail — use unreadCount as a proxy:
|
||||
// if unreadCount < total fetched, the user had read some, so carry them all over.
|
||||
const hadReads = entries[idx].manga.unreadCount < newChaps.length;
|
||||
if (hadReads) {
|
||||
for (const nc of newChaps) toMarkRead.push(nc.id);
|
||||
}
|
||||
|
||||
if (toMarkRead.length)
|
||||
@@ -183,7 +179,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
|
||||
<div class="overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }} onkeydown={(e) => { if (e.key === "Escape" && phase !== "migrating") onClose(); }}>
|
||||
<div class="modal">
|
||||
|
||||
<div class="modal-header">
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
|
||||
import { openReaderForChapter } from '$lib/state/series.svelte'
|
||||
import { historyState } from '$lib/state/history.svelte'
|
||||
import type { ReadSession } from '$lib/types/history'
|
||||
import HeroStage from '$lib/components/home/HeroStage.svelte'
|
||||
@@ -107,7 +108,7 @@
|
||||
heroAllChapters = all
|
||||
const lastReadIdx = heroEntry
|
||||
? all.findLastIndex(c => c.id === heroEntry!.endChapterId)
|
||||
: all.findLastIndex(c => c.isRead)
|
||||
: all.findLastIndex(c => c.read)
|
||||
const startIdx = Math.max(0, lastReadIdx)
|
||||
heroChapters = all.slice(startIdx, startIdx + 5)
|
||||
} catch {
|
||||
@@ -129,7 +130,7 @@
|
||||
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
|
||||
if (!heroEntry) return
|
||||
const target = heroAllChapters.find(c => c.id === heroEntry!.endChapterId) ?? heroAllChapters[0]
|
||||
if (target) openChapter(target)
|
||||
if (target) openReaderForChapter(target, heroManga ?? null)
|
||||
}
|
||||
|
||||
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack, tick } from "svelte";
|
||||
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
|
||||
import type { PageStyle } from "$lib/state/reader.svelte";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { app, appState } from "$lib/state/app.svelte";
|
||||
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
|
||||
@@ -11,7 +12,7 @@
|
||||
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
|
||||
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
|
||||
import { historyState } from "$lib/state/history.svelte";
|
||||
import { setPreviewManga } from "$lib/state/series.svelte";
|
||||
import { setPreviewManga, seriesState } from "$lib/state/series.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { setReading, clearReading } from "$lib/core/discord";
|
||||
import { revokeBlobUrl, cancelQueuedFetches, preloadBlobUrls } from "$lib/core/cache/imageCache";
|
||||
@@ -55,9 +56,16 @@
|
||||
|
||||
const currentBookmark = $derived(
|
||||
readerState.activeManga
|
||||
? readerState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
||||
? seriesState.bookmarks.find(b => b.mangaId === readerState.activeManga!.id)
|
||||
: undefined
|
||||
);
|
||||
const currentGroup = $derived.by(() => {
|
||||
const group = style === "double" && readerState.pageGroups.length
|
||||
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||
: [readerState.pageNumber];
|
||||
return rtl ? [...group].reverse() : group;
|
||||
});
|
||||
|
||||
const isBookmarked = $derived(
|
||||
!!currentBookmark &&
|
||||
currentBookmark.chapterId === displayChapter?.id &&
|
||||
@@ -102,13 +110,6 @@
|
||||
effectiveReaderSettings.optimizeContrast && "optimize-contrast",
|
||||
].filter(Boolean).join(" "));
|
||||
|
||||
const currentGroup = $derived.by(() => {
|
||||
const group = style === "double" && readerState.pageGroups.length
|
||||
? (readerState.pageGroups.find(g => g.includes(readerState.pageNumber)) ?? [readerState.pageNumber])
|
||||
: [readerState.pageNumber];
|
||||
return rtl ? [...group].reverse() : group;
|
||||
});
|
||||
|
||||
const sliderPage = $derived.by(() => {
|
||||
if (style === "double" && readerState.pageGroups.length)
|
||||
return readerState.pageGroups.findIndex(g => g.includes(readerState.pageNumber)) + 1;
|
||||
@@ -240,7 +241,7 @@
|
||||
lastPage: () => lastPage,
|
||||
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
|
||||
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] as PageStyle }); },
|
||||
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
|
||||
openSettings: () => { app.setSettingsOpen(true); },
|
||||
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
|
||||
@@ -255,11 +256,11 @@
|
||||
},
|
||||
chapterNext: () => {
|
||||
const ch = rtl ? adjacent.prev : adjacent.next;
|
||||
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeChapterList); }
|
||||
if (ch) { maybeMarkCurrentRead(); readerState.openReader(ch, readerState.activeManga); }
|
||||
},
|
||||
chapterPrev: () => {
|
||||
const ch = rtl ? adjacent.next : adjacent.prev;
|
||||
if (ch) readerState.openReader(ch, readerState.activeChapterList);
|
||||
if (ch) readerState.openReader(ch, readerState.activeManga);
|
||||
},
|
||||
closePopovers: () => readerState.closeAllPopovers(),
|
||||
getKeybinds: () => settingsState.settings.keybinds ?? DEFAULT_KEYBINDS,
|
||||
@@ -269,7 +270,7 @@
|
||||
|
||||
function captureCurrentReaderSettings(): ReaderSettings {
|
||||
return {
|
||||
pageStyle: style,
|
||||
pageStyle: style as PageStyle,
|
||||
fitMode: fit,
|
||||
readingDirection: (settingsState.settings.readingDirection ?? "ltr") as ReaderSettings["readingDirection"],
|
||||
readerZoom: zoom,
|
||||
@@ -463,9 +464,7 @@
|
||||
if (!hasNavigated) return;
|
||||
if (style === "longstrip" && visibleChapterId && chapterId !== visibleChapterId) return;
|
||||
if (settingsState.settings.autoBookmark ?? true) {
|
||||
const existing = readerState.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
|
||||
if (existing) readerState.removeBookmark(existing.chapterId);
|
||||
readerState.addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||
seriesState.setBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
|
||||
}
|
||||
if (style !== "longstrip" && (settingsState.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
|
||||
|
||||
@@ -585,6 +584,7 @@
|
||||
resumePage={readerState.resumePage}
|
||||
resumeFading={readerState.resumeFading}
|
||||
{adjacent}
|
||||
{barPosition}
|
||||
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
|
||||
/>
|
||||
|
||||
@@ -673,4 +673,4 @@
|
||||
.root.bar-right :global(.viewer) { margin-right: 40px; }
|
||||
|
||||
.root.pinch-active :global(.viewer) { touch-action: none; }
|
||||
</style>
|
||||
</style>
|
||||
@@ -19,7 +19,7 @@ export function markChapterRead(id: number, markedRead: Set<number>) {
|
||||
const manga = readerState.activeManga;
|
||||
|
||||
if (manga && chapter) {
|
||||
readerState.addBookmark({
|
||||
seriesState.setBookmark({
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
@@ -86,9 +86,7 @@ export function toggleBookmark(chapter: typeof readerState.activeChapter, pageNu
|
||||
if (existing) {
|
||||
seriesState.removeBookmark(chapter.id);
|
||||
} else {
|
||||
const other = seriesState.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== chapter.id);
|
||||
if (other) seriesState.removeBookmark(other.chapterId);
|
||||
seriesState.addBookmark({
|
||||
seriesState.setBookmark({
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
|
||||
@@ -316,8 +316,8 @@
|
||||
{/if}
|
||||
|
||||
{#each flatPages as page, gi (page.chapterId + ":" + page.localIndex)}
|
||||
{@const src = (_version, resolvedSrc.get(gi))}
|
||||
{@const isLoaded = (_version, loadedSet.has(gi))}
|
||||
{@const src = _version >= 0 ? resolvedSrc.get(gi) : undefined}
|
||||
{@const isLoaded = _version >= 0 ? loadedSet.has(gi) : false}
|
||||
<div class="strip-slot" data-local-page={page.localIndex + 1} data-chapter={page.chapterId} style={getCachedAspect(page.url) != null ? `--aspect:${getCachedAspect(page.url)}` : undefined}>
|
||||
{#if isLoaded && src}
|
||||
<img
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { historyState } from '$lib/state/history.svelte'
|
||||
import { setActiveManga, openReaderForChapter, setPreviewManga } from '$lib/state/series.svelte'
|
||||
import { addToast } from '$lib/state/notifications.svelte'
|
||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||
import { groupByDay } from './lib/recentHistory'
|
||||
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
|
||||
import RecentToolbar from './RecentToolbar.svelte'
|
||||
@@ -27,6 +28,7 @@
|
||||
let updatesLoading: boolean = $state(true)
|
||||
let updatesError: string | null = $state(null)
|
||||
let openingId: number | null = $state(null)
|
||||
let enqueueing: Set<number> = $state(new Set())
|
||||
let updaterRunning: boolean = $state(false)
|
||||
let lastUpdatedTs: number | null = $state(null)
|
||||
let updaterFinishedJobs: number | null = $state(null)
|
||||
@@ -136,7 +138,7 @@
|
||||
if (nextCtrl.signal.aborted) return
|
||||
|
||||
updates = (updatesRes ?? [])
|
||||
.filter(item => item.manga?.inLibrary)
|
||||
.map(item => ({ ...item, isRead: item.read }))
|
||||
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a))
|
||||
} catch (e: any) {
|
||||
if (nextCtrl.signal.aborted) return
|
||||
@@ -191,6 +193,42 @@
|
||||
clearHistory()
|
||||
historyConfirmClear = false
|
||||
}
|
||||
|
||||
async function enqueueUpdate(item: RecentUpdate) {
|
||||
if (enqueueing.has(item.id)) return
|
||||
enqueueing = new Set(enqueueing).add(item.id)
|
||||
try {
|
||||
const allowed = await downloadStore.enqueue(item.id)
|
||||
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: item.name ?? 'Chapter' })
|
||||
} catch {
|
||||
addToast({ kind: 'error', title: 'Download failed', body: 'Could not queue chapter.' })
|
||||
} finally {
|
||||
enqueueing.delete(item.id)
|
||||
enqueueing = new Set(enqueueing)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownloaded(item: RecentUpdate) {
|
||||
try {
|
||||
await getAdapter().deleteDownloadedChapter(String(item.id))
|
||||
updates = updates.map(u => u.id === item.id ? { ...u, isDownloaded: false } : u)
|
||||
} catch {
|
||||
addToast({ kind: 'error', title: 'Delete failed', body: 'Could not delete download.' })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLibraryUpdate() {
|
||||
try {
|
||||
if (updaterRunning) {
|
||||
await getAdapter().stopLibraryUpdate()
|
||||
} else {
|
||||
await getAdapter().startLibraryUpdate()
|
||||
scheduleStatusPoll()
|
||||
}
|
||||
} catch (e: any) {
|
||||
addToast({ kind: 'error', title: 'Update error', body: e?.message ?? 'Failed' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root anim-fade-in">
|
||||
@@ -215,13 +253,16 @@
|
||||
error={updatesError}
|
||||
groups={updateGroups}
|
||||
{updatesSearch}
|
||||
totalCount={updates.length}
|
||||
totalCount={updates.filter(u => !u.isRead).length}
|
||||
{openingId}
|
||||
{enqueueing}
|
||||
{updaterRunning}
|
||||
{lastUpdatedLabel}
|
||||
{updaterProgressLabel}
|
||||
onOpenUpdate={openUpdate}
|
||||
onOpenSeries={(item) => setActiveManga(mangaStub(item))}
|
||||
onEnqueue={enqueueUpdate}
|
||||
onDeleteDownload={deleteDownloaded}
|
||||
/>
|
||||
{:else}
|
||||
<HistoryTab
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
}
|
||||
|
||||
let {
|
||||
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory, updatesLoading,
|
||||
onTabChange, onHistorySearchChange, onUpdatesSearchChange, onHistoryClear, onRefreshUpdates,
|
||||
tab, historySearch, updatesSearch, historyConfirmClear, hasHistory,
|
||||
updatesLoading,
|
||||
onTabChange, onHistorySearchChange, onUpdatesSearchChange,
|
||||
onHistoryClear, onRefreshUpdates,
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
@@ -57,7 +59,7 @@
|
||||
class="icon-btn"
|
||||
onclick={onRefreshUpdates}
|
||||
disabled={updatesLoading}
|
||||
title="Refresh updates"
|
||||
title="Reload update list"
|
||||
>
|
||||
{#if updatesLoading}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
@@ -79,19 +81,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="icon-btn"
|
||||
onclick={onRefreshUpdates}
|
||||
disabled={updatesLoading}
|
||||
title="Refresh library"
|
||||
>
|
||||
{#if updatesLoading}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
<ArrowsClockwise size={14} weight="bold" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if hasHistory}
|
||||
<button
|
||||
class="clear-btn"
|
||||
@@ -155,6 +144,8 @@
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
|
||||
.icon-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
.icon-btn.running { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); background: var(--color-error-bg); }
|
||||
.icon-btn.running:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
.search-wrap { position: relative; display: flex; align-items: center; }
|
||||
.search-wrap :global(.search-icon) { position: absolute; left: 8px; color: var(--text-faint); pointer-events: none; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, CircleNotch } from 'phosphor-svelte'
|
||||
import { BookOpen, CircleNotch, Download, Trash } from 'phosphor-svelte'
|
||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||
|
||||
@@ -10,17 +10,20 @@
|
||||
updatesSearch: string
|
||||
totalCount: number
|
||||
openingId: number | null
|
||||
enqueueing: Set<number>
|
||||
updaterRunning: boolean
|
||||
lastUpdatedLabel: string | null
|
||||
updaterProgressLabel: string | null
|
||||
onOpenUpdate: (item: RecentUpdate) => void
|
||||
onOpenSeries: (item: RecentUpdate) => void
|
||||
onEnqueue: (item: RecentUpdate) => void
|
||||
onDeleteDownload: (item: RecentUpdate) => void
|
||||
}
|
||||
|
||||
let {
|
||||
loading, error, groups, updatesSearch, totalCount, openingId,
|
||||
loading, error, groups, updatesSearch, totalCount, openingId, enqueueing,
|
||||
updaterRunning, lastUpdatedLabel, updaterProgressLabel,
|
||||
onOpenUpdate, onOpenSeries,
|
||||
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
|
||||
}: Props = $props()
|
||||
|
||||
const filteredGroups = $derived(updatesSearch.trim()
|
||||
@@ -63,7 +66,7 @@
|
||||
<div class="bar-sep"></div>
|
||||
{/if}
|
||||
{#if !loading && totalCount > 0}
|
||||
<span class="status-count">{totalCount} chapter{totalCount === 1 ? '' : 's'}</span>
|
||||
<span class="status-count">{totalCount} unread</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +141,7 @@
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
||||
{#if !item.isRead}<span class="pill">Unread</span>{/if}
|
||||
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
|
||||
</div>
|
||||
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||
@@ -146,6 +149,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="row-end">
|
||||
{#if enqueueing.has(item.id)}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else if item.isDownloaded}
|
||||
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); onDeleteDownload(item) }} title="Delete download">
|
||||
<Trash size={13} weight="light" />
|
||||
</button>
|
||||
{:else}
|
||||
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); onEnqueue(item) }} title="Download">
|
||||
<Download size={13} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if openingId === item.id}
|
||||
<CircleNotch size={14} weight="light" class="anim-spin" />
|
||||
{:else}
|
||||
@@ -258,12 +272,20 @@
|
||||
.chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); }
|
||||
.meta-row { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.pill {
|
||||
padding: 2px 6px; border-radius: var(--radius-full);
|
||||
background: var(--accent-muted); color: var(--accent-fg);
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); text-transform: uppercase; flex-shrink: 0;
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--color-success, #22c55e); flex-shrink: 0;
|
||||
}
|
||||
.row-end { color: var(--text-faint); display: flex; align-items: center; justify-content: center; width: 24px; flex-shrink: 0; }
|
||||
.row-end { color: var(--text-faint); display: flex; align-items: center; gap: var(--sp-1); justify-content: center; flex-shrink: 0; }
|
||||
|
||||
.dl-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 24px; height: 24px; border-radius: var(--radius-sm);
|
||||
border: none; background: none; color: var(--text-faint); cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.dl-btn-delete { color: var(--color-error); }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg); }
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
|
||||
@@ -25,7 +25,10 @@ export interface UpdateStatus {
|
||||
}
|
||||
|
||||
export function fetchedAtMs(item: Pick<RecentUpdate, 'fetchedAt'>): number {
|
||||
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now()
|
||||
if (!item.fetchedAt) return Date.now()
|
||||
const numeric = Number(item.fetchedAt)
|
||||
if (Number.isFinite(numeric)) return numeric * 1000
|
||||
const ts = new Date(item.fetchedAt).getTime()
|
||||
return Number.isFinite(ts) ? ts : Date.now()
|
||||
}
|
||||
|
||||
@@ -42,10 +45,18 @@ export function parseServerTimestamp(value: unknown): number | null {
|
||||
|
||||
export function groupUpdatesByDay(updates: RecentUpdate[]): UpdateGroup[] {
|
||||
const grouped: Record<string, RecentUpdate[]> = {}
|
||||
const order: Record<string, number> = {}
|
||||
for (const item of updates) {
|
||||
const label = dayLabel(fetchedAtMs(item))
|
||||
if (!grouped[label]) grouped[label] = []
|
||||
const ts = fetchedAtMs(item)
|
||||
const label = dayLabel(ts)
|
||||
if (!grouped[label]) {
|
||||
grouped[label] = []
|
||||
order[label] = ts
|
||||
}
|
||||
grouped[label].push(item)
|
||||
if (ts > order[label]) order[label] = ts
|
||||
}
|
||||
return Object.entries(grouped).map(([label, items]) => ({ label, items }))
|
||||
return Object.entries(grouped)
|
||||
.sort(([a], [b]) => order[b] - order[a])
|
||||
.map(([label, items]) => ({ label, items }))
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Download, CheckCircle, Circle, CircleNotch, Trash } from 'phosphor-svelte'
|
||||
import { Download, CheckSquare, Square, CircleNotch, Trash } from 'phosphor-svelte'
|
||||
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
import { longPress } from '$lib/core/ui/touchscreen'
|
||||
@@ -14,27 +14,39 @@
|
||||
enqueueing: Set<number>
|
||||
chapterPage: number
|
||||
totalPages: number
|
||||
scrollEl?: HTMLDivElement | null
|
||||
onOpen: (ch: Chapter, inProgress: boolean) => void
|
||||
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void
|
||||
onEnqueue: (ch: Chapter, e: MouseEvent) => void
|
||||
onDeleteDownload:(id: number) => void
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange:(n: number) => void
|
||||
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[]
|
||||
}
|
||||
|
||||
let {
|
||||
pageChapters, sortedChapters, viewMode, loadingChapters,
|
||||
selectedIds, enqueueing, chapterPage, totalPages,
|
||||
scrollEl = $bindable(null),
|
||||
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
|
||||
onPageChange, buildCtxItems,
|
||||
onPageChange, onPageSizeChange, buildCtxItems,
|
||||
}: Props = $props()
|
||||
|
||||
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null)
|
||||
let listEl: HTMLDivElement | null = $state(null)
|
||||
|
||||
const hasSelection = $derived(selectedIds.size > 0)
|
||||
|
||||
$effect(() => {
|
||||
if (!listEl || viewMode !== 'list') return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const firstRow = listEl!.querySelector('.ch-row') as HTMLElement | null
|
||||
const rowH = firstRow ? firstRow.offsetHeight : 37
|
||||
const n = Math.max(1, Math.floor(entry.contentRect.height / rowH))
|
||||
onPageSizeChange(n)
|
||||
})
|
||||
ro.observe(listEl)
|
||||
return () => ro.disconnect()
|
||||
})
|
||||
|
||||
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
|
||||
const [ch, idx] = param
|
||||
return longPress(node, {
|
||||
@@ -50,7 +62,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={scrollEl}>
|
||||
<div class={viewMode === 'grid' ? 'ch-grid' : 'ch-list'} bind:this={listEl}>
|
||||
{#if loadingChapters && sortedChapters.length === 0}
|
||||
{#if viewMode === 'grid'}
|
||||
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
|
||||
@@ -100,7 +112,7 @@
|
||||
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted } }}
|
||||
>
|
||||
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
|
||||
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
|
||||
{#if isSelected}<CheckSquare size={15} weight="fill" />{:else}<Square size={15} weight="light" />{/if}
|
||||
</button>
|
||||
<div class="ch-left">
|
||||
<span class="ch-name">{ch.name}</span>
|
||||
@@ -111,7 +123,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="ch-right">
|
||||
{#if ch.read}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
|
||||
{#if ch.read}<CheckSquare size={14} weight="light" class="read-icon" />{/if}
|
||||
{#if ch.downloaded}
|
||||
<div class="ch-dl-wrap">
|
||||
<Download size={13} weight="fill" class="ch-dl-icon" />
|
||||
@@ -145,38 +157,42 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.ch-list { flex: 1; overflow-y: auto; }
|
||||
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||
.ch-list { flex: 1; overflow: hidden; }
|
||||
.ch-grid { flex: 1; overflow: hidden; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
|
||||
|
||||
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
.ch-row { display: flex; align-items: center; padding: 8px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
|
||||
.ch-row:hover { background: var(--bg-raised); }
|
||||
.ch-row.read { opacity: 0.45; }
|
||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.ch-row.read { opacity: 0.5; }
|
||||
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
|
||||
:global(.read-icon) { color: var(--text-faint); }
|
||||
:global(.enqueue-icon) { color: var(--text-faint); }
|
||||
|
||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
|
||||
.ch-row:hover .dl-btn { opacity: 1; }
|
||||
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
|
||||
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
|
||||
.ch-row:hover .dl-btn-delete { opacity: 1; }
|
||||
.dl-btn-delete { color: var(--color-error) !important; }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
|
||||
|
||||
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
|
||||
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
|
||||
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
|
||||
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
|
||||
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
|
||||
.ch-dl-wrap { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
:global(.ch-dl-icon) { color: var(--text-faint); }
|
||||
|
||||
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
|
||||
.ch-row:hover .ch-check { opacity: 1; }
|
||||
.ch-check-visible { opacity: 1 !important; }
|
||||
.ch-check {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 20px; height: 20px; flex-shrink: 0;
|
||||
border-radius: var(--radius-sm); border: none; background: none;
|
||||
color: var(--text-faint); cursor: pointer; padding: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
transition: opacity var(--t-fast), transform var(--t-fast), color var(--t-fast);
|
||||
margin-right: -20px;
|
||||
}
|
||||
.ch-row:hover .ch-check { opacity: 1; transform: translateX(0); margin-right: 0; }
|
||||
.ch-check-visible { opacity: 1 !important; transform: translateX(0) !important; margin-right: 0 !important; }
|
||||
.ch-selected .ch-check { color: var(--accent-fg); }
|
||||
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
|
||||
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
|
||||
|
||||
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
|
||||
|
||||
@@ -184,8 +200,8 @@
|
||||
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
|
||||
.grid-cell-num { font-size: 10px; }
|
||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
|
||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
|
||||
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--text-faint); }
|
||||
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: var(--radius-sm); background: var(--accent-fg); }
|
||||
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
|
||||
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
|
||||
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
|
||||
|
||||
@@ -19,8 +19,6 @@
|
||||
sortMode: ChapterSortMode
|
||||
sortDir: ChapterSortDir
|
||||
viewMode: 'list' | 'grid'
|
||||
chapterPage: number
|
||||
totalPages: number
|
||||
downloadedCount: number
|
||||
totalCount: number
|
||||
deletingAll: boolean
|
||||
@@ -57,7 +55,7 @@
|
||||
|
||||
let {
|
||||
chapters, sortedChapters, sortMode, sortDir, viewMode,
|
||||
chapterPage, totalPages, downloadedCount, totalCount, deletingAll,
|
||||
downloadedCount, totalCount, deletingAll,
|
||||
hasSelection, selectedCount, continueChapter,
|
||||
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||
allCategories, mangaCategories, catsLoading, refreshing,
|
||||
@@ -277,9 +275,11 @@
|
||||
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
||||
<FolderOpen size={14} weight="light" />
|
||||
</button>
|
||||
{#if downloadedCount > 0}
|
||||
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
|
||||
<FolderOpen size={14} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="fp-wrap" bind:this={folderPickerRef}>
|
||||
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
|
||||
@@ -377,13 +377,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="pagination">
|
||||
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>←</button>
|
||||
<span class="page-num">{chapterPage} / {totalPages}</span>
|
||||
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}>→</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -572,17 +565,6 @@
|
||||
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
|
||||
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
|
||||
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.page-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint); background: none; cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.page-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.sel-count {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
|
||||
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
|
||||
|
||||
@@ -8,13 +8,17 @@
|
||||
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
|
||||
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
|
||||
} from 'phosphor-svelte'
|
||||
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
|
||||
|
||||
type MenuSeparator = { separator: true }
|
||||
type MenuItem = { label: string; icon?: any; onClick: () => void; danger?: boolean; disabled?: boolean; separator?: never; children?: MenuEntry[] }
|
||||
type MenuEntry = MenuItem | MenuSeparator
|
||||
import { getManga, getMangaList } from '$lib/request-manager/manga'
|
||||
import { markChapterRead, markChaptersRead, deleteDownloadedChapters, fetchChapters } from '$lib/request-manager/chapters'
|
||||
import { downloadStore } from '$lib/state/downloads.svelte'
|
||||
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
|
||||
import { saveScroll, getScroll } from '$lib/state/app.svelte'
|
||||
import { seriesState, openReaderForChapter, acknowledgeUpdate, addBookmark, clearMarkersForManga } from '$lib/state/series.svelte'
|
||||
import { updateSettings } from '$lib/state/settings.svelte'
|
||||
import { DEFAULT_MANGA_PREFS } from '$lib/state/series.svelte'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
import { addToast } from '$lib/state/notifications.svelte'
|
||||
@@ -33,8 +37,8 @@
|
||||
interface Props { mangaId: number }
|
||||
let { mangaId }: Props = $props()
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000
|
||||
let chaptersPerPage: number = $state(25)
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
const mangaCache: Map<number, { data: Manga; fetchedAt: number }> = new Map()
|
||||
|
||||
@@ -80,8 +84,8 @@
|
||||
const scanlatorBlacklist = $derived(get('scanlatorBlacklist') as string[])
|
||||
const scanlatorForce = $derived(get('scanlatorForce') as boolean)
|
||||
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE))
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE))
|
||||
const totalPages = $derived(Math.ceil(sortedChapters.length / chaptersPerPage))
|
||||
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * chaptersPerPage, chapterPage * chaptersPerPage))
|
||||
const readCount = $derived(sortedChapters.filter(c => c.read).length)
|
||||
const totalCount = $derived(sortedChapters.length)
|
||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0)
|
||||
@@ -94,11 +98,11 @@
|
||||
const bookmark = seriesState.bookmarks.find(b => b.mangaId === mangaId)
|
||||
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
|
||||
if (bookmarkedCh && !bookmarkedCh.read)
|
||||
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as const, resumePage: bookmark!.pageNumber }
|
||||
return { chapter: bookmarkedCh, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: bookmark!.pageNumber }
|
||||
const inProgress = asc.find(c => !c.read && (c.lastPageRead ?? 0) > 0)
|
||||
const firstUnread = asc.find(c => !c.read)
|
||||
const target = inProgress ?? firstUnread
|
||||
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as const, resumePage: null }
|
||||
if (target) return { chapter: target, type: (anyRead ? 'continue' : 'start') as 'continue' | 'start', resumePage: null }
|
||||
return { chapter: asc[0], type: 'reread' as const, resumePage: null }
|
||||
})())
|
||||
|
||||
@@ -140,8 +144,13 @@
|
||||
const completed = allCategories.find(c => c.name === 'Completed')
|
||||
if (!completed) return
|
||||
const inCompleted = mangaCategories.some(c => c.id === completed.id)
|
||||
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed]
|
||||
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
||||
if (allRead && !inCompleted) {
|
||||
await updateMangaCategories(String(id), [completed.id], []).catch(console.error)
|
||||
mangaCategories = [...mangaCategories, completed]
|
||||
} else if (!allRead && inCompleted) {
|
||||
await updateMangaCategories(String(id), [], [completed.id]).catch(console.error)
|
||||
mangaCategories = mangaCategories.filter(c => c.id !== completed.id)
|
||||
}
|
||||
}
|
||||
|
||||
function loadMangaData(id: number) {
|
||||
@@ -154,7 +163,6 @@
|
||||
loadingManga = false
|
||||
seriesState.setActiveManga(cached.data)
|
||||
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return
|
||||
// stale-while-revalidate: update cache + store in background
|
||||
getManga(id, ctrl.signal)
|
||||
.then(m => {
|
||||
if (ctrl.signal.aborted) return
|
||||
@@ -224,8 +232,8 @@
|
||||
const records = trackingState.recordsFor(id)
|
||||
if (!records.length) return
|
||||
const prefs = {
|
||||
sortMode: get('sortMode'),
|
||||
sortDir: get('sortDir'),
|
||||
sortMode: seriesState.settings.chapterSortMode,
|
||||
sortDir: seriesState.settings.chapterSortDir,
|
||||
preferredScanlator: get('preferredScanlator') as string,
|
||||
scanlatorFilter: scanlatorFilter,
|
||||
scanlatorBlacklist: scanlatorBlacklist,
|
||||
@@ -278,7 +286,7 @@
|
||||
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||
const ch = seriesState.chaptersFor(mangaId).find(c => c.id === chapterId)
|
||||
const currentPrefs = {
|
||||
sortMode: get('sortMode'), sortDir: get('sortDir'),
|
||||
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
|
||||
preferredScanlator: get('preferredScanlator') as string,
|
||||
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||
}
|
||||
@@ -310,7 +318,7 @@
|
||||
seriesState.patchChapters(mangaId, chaps => chaps.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c))
|
||||
checkAndMarkCompleted(mangaId, seriesState.chaptersFor(mangaId))
|
||||
const currentPrefs = {
|
||||
sortMode: get('sortMode'), sortDir: get('sortDir'),
|
||||
sortMode: seriesState.settings.chapterSortMode, sortDir: seriesState.settings.chapterSortDir,
|
||||
preferredScanlator: get('preferredScanlator') as string,
|
||||
scanlatorFilter, scanlatorBlacklist, scanlatorForce,
|
||||
}
|
||||
@@ -431,21 +439,8 @@
|
||||
openReaderForChapter(ch, manga)
|
||||
}
|
||||
|
||||
function handleContinue(cc: typeof continueChapter) {
|
||||
if (!cc) return
|
||||
if (cc.type === 'continue' && cc.resumePage && cc.resumePage > 1) {
|
||||
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
|
||||
if (!existing || existing.pageNumber < cc.resumePage) {
|
||||
addBookmark({
|
||||
mangaId,
|
||||
mangaTitle: manga!.title,
|
||||
thumbnailUrl: manga!.thumbnailUrl,
|
||||
chapterId: cc.chapter.id,
|
||||
chapterName: cc.chapter.name,
|
||||
pageNumber: cc.resumePage,
|
||||
})
|
||||
}
|
||||
}
|
||||
interface ContinueChapter { chapter: Chapter; type: 'start' | 'continue' | 'reread'; resumePage: number | null }
|
||||
function handleContinue(cc: ContinueChapter) {
|
||||
openReaderForChapter(cc.chapter, manga)
|
||||
}
|
||||
|
||||
@@ -472,7 +467,7 @@
|
||||
async function toggleCategory(cat: Category) {
|
||||
const inCat = mangaCategories.some(c => c.id === cat.id)
|
||||
try {
|
||||
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
||||
await updateMangaCategories(String(mangaId), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
|
||||
if (!inCat && !manga?.inLibrary) {
|
||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||
@@ -485,7 +480,7 @@
|
||||
if (!name) return
|
||||
try {
|
||||
const cat = await createCategoryReq(name)
|
||||
await updateMangaCategories(mangaId, [cat.id], [])
|
||||
await updateMangaCategories(String(mangaId), [cat.id], [])
|
||||
if (!manga?.inLibrary) {
|
||||
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
|
||||
if (manga) { manga = { ...manga, inLibrary: true }; seriesState.setActiveManga(manga) }
|
||||
@@ -514,7 +509,7 @@
|
||||
{loadingLinkList}
|
||||
{mangaCategories}
|
||||
{togglingLibrary}
|
||||
onRead={handleContinue}
|
||||
onRead={(ch) => handleContinue(ch)}
|
||||
onToggleLibrary={toggleLibrary}
|
||||
onDeleteAll={deleteAllDownloads}
|
||||
onMigrateOpen={() => migrateOpen = true}
|
||||
@@ -526,15 +521,13 @@
|
||||
onGenreClick={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
|
||||
/>
|
||||
|
||||
<div class="list-wrap">
|
||||
<div class="list-wrap" bind:this={chapterListEl}>
|
||||
<SeriesActions
|
||||
{chapters}
|
||||
{sortedChapters}
|
||||
sortMode={get('sortMode')}
|
||||
sortDir={get('sortDir')}
|
||||
sortMode={seriesState.settings.chapterSortMode}
|
||||
sortDir={seriesState.settings.chapterSortDir}
|
||||
{viewMode}
|
||||
{chapterPage}
|
||||
{totalPages}
|
||||
{downloadedCount}
|
||||
{totalCount}
|
||||
{deletingAll}
|
||||
@@ -564,8 +557,8 @@
|
||||
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
|
||||
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
|
||||
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
|
||||
onSortModeChange={(v) => set('sortMode', v)}
|
||||
onSortDirChange={(v) => set('sortDir', v)}
|
||||
onSortModeChange={(v) => updateSettings({ chapterSortMode: v })}
|
||||
onSortDirChange={(v) => updateSettings({ chapterSortDir: v })}
|
||||
onOpenFolder={() => manga && openMangaFolder(manga)}
|
||||
/>
|
||||
|
||||
@@ -578,12 +571,12 @@
|
||||
{enqueueing}
|
||||
{chapterPage}
|
||||
{totalPages}
|
||||
bind:scrollEl={chapterListEl}
|
||||
onOpen={openReaderWithAhead}
|
||||
onToggleSelect={toggleSelect}
|
||||
onEnqueue={enqueue}
|
||||
onDeleteDownload={deleteDownloaded}
|
||||
onPageChange={(p) => chapterPage = p}
|
||||
onPageSizeChange={(n) => { chaptersPerPage = n; chapterPage = Math.min(chapterPage, Math.ceil(sortedChapters.length / n) || 1) }}
|
||||
{buildCtxItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.type === 'reread' ? 'Read again'
|
||||
: continueChapter.type === 'start' ? 'Start reading'
|
||||
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ''}`}
|
||||
: `Continue · Ch.${continueChapter.chapter.chapterNumber}`}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
|
||||
export { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
|
||||
|
||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
|
||||
const prefs = settingsState.settings.mangaPrefs?.[mangaId] ?? {}
|
||||
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K]
|
||||
}
|
||||
|
||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
|
||||
updateSettings({
|
||||
mangaPrefs: {
|
||||
...settingsState.settings.mangaPrefs,
|
||||
[mangaId]: { ...(settingsState.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { X } from "phosphor-svelte";
|
||||
import { getPref, setPref } from "$lib/components/series/lib/mangaPrefs";
|
||||
import { getPref, setPref } from "$lib/state/series.svelte";
|
||||
import { settingsState } from "$lib/state/settings.svelte";
|
||||
import { libraryState } from "$lib/state/library.svelte";
|
||||
import { resolvedCover } from "$lib/core/cover/coverResolver";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
|
||||
import { setPref } from "$lib/components/series/lib/mangaPrefs";
|
||||
import { setPref } from "$lib/state/series.svelte";
|
||||
import { coverCandidatesSync, dedupeByImage } from "$lib/core/cover/coverResolver";
|
||||
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
|
||||
import type { Manga } from "$lib/types";
|
||||
|
||||
@@ -149,6 +149,10 @@ export function buildIssueUrl(
|
||||
alternatives: (fields as FeatureFields).alternatives,
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ ...common, ...specific })
|
||||
const merged: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries({ ...common, ...specific })) {
|
||||
if (v !== undefined) merged[k] = v
|
||||
}
|
||||
const params = new URLSearchParams(merged)
|
||||
return `${base}?${params.toString()}`
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
const name = newFolderName.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
const cat = await getAdapter().createCategory({ name })
|
||||
const cat = await getAdapter().createCategory(name)
|
||||
categories = [...categories, cat]
|
||||
newFolderName = ''
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
|
||||
@@ -65,7 +65,7 @@
|
||||
async function commitEdit() {
|
||||
if (editingId !== null && editingName.trim()) {
|
||||
try {
|
||||
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
|
||||
await (getAdapter() as any).updateCategory(editingId, { name: editingName.trim() })
|
||||
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
|
||||
async function deleteFolder(id: number) {
|
||||
try {
|
||||
await getAdapter().deleteCategory({ id })
|
||||
await getAdapter().deleteCategory(id)
|
||||
categories = categories.filter(c => c.id !== id)
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
|
||||
}
|
||||
@@ -85,7 +85,7 @@
|
||||
const next = !cat[flag]
|
||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
|
||||
try {
|
||||
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
|
||||
await (getAdapter() as any).updateCategories([id], { [flag]: next ? 'INCLUDE' : 'EXCLUDE' })
|
||||
} catch (e: any) {
|
||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
|
||||
catsError = e?.message ?? 'Failed to update folder'
|
||||
@@ -117,7 +117,7 @@
|
||||
const optimistic = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
|
||||
categories = optimistic
|
||||
const serverPosition = sToIdx + 1
|
||||
getAdapter().updateCategoryOrder({ id: fromNumId, position: serverPosition })
|
||||
getAdapter().updateCategoryOrder(fromNumId, serverPosition)
|
||||
.then((updated: Category[]) => {
|
||||
categories = [
|
||||
...zeroCat,
|
||||
@@ -189,6 +189,7 @@
|
||||
{#if isBuiltin || cat}
|
||||
<div
|
||||
class="s-folder-row"
|
||||
role="listitem"
|
||||
class:dragging={dragStrId === id}
|
||||
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
|
||||
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
|
||||
@@ -205,7 +206,7 @@
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
||||
<span class="s-folder-count">{cat?.mangas?.nodes?.length ?? 0} manga</span>
|
||||
<span class="s-folder-count">{cat?.mangas?.length ?? 0} manga</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
||||
@@ -235,16 +236,17 @@
|
||||
onblur={commitEdit} use:focusInput />
|
||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<div class="s-folder-identity" draggable="true"
|
||||
<div class="s-folder-identity" role="button" tabindex="0" draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, id)}
|
||||
ondragend={onDragEnd}>
|
||||
ondragend={onDragEnd}
|
||||
onkeydown={(e) => e.key === 'Enter' && startEdit(cat.id, cat.name)}>
|
||||
<span class="s-folder-icon">
|
||||
<FolderSimple size={14} weight="light" />
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
|
||||
<button class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</button>
|
||||
</div>
|
||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
<span class="s-folder-count">{cat.mangas?.length ?? 0} manga</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon"
|
||||
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||
@@ -332,6 +334,8 @@
|
||||
.s-folder-icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
padding: 1px;
|
||||
}
|
||||
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
|
||||
.s-folder-icon > :global(*:last-child) { opacity: 0; }
|
||||
|
||||
@@ -17,8 +17,9 @@ interface StoredVault {
|
||||
data: string
|
||||
}
|
||||
|
||||
function toB64(buf: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)))
|
||||
function toB64(buf: ArrayBuffer | Uint8Array): string {
|
||||
const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf)
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
function fromB64(s: string): Uint8Array {
|
||||
@@ -29,7 +30,7 @@ async function deriveKey(pin: string, salt: Uint8Array): Promise<CryptoKey> {
|
||||
const enc = new TextEncoder()
|
||||
const keyMat = await crypto.subtle.importKey('raw', enc.encode(pin), 'PBKDF2', false, ['deriveKey'])
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: SALT_ITERATIONS, hash: 'SHA-256' },
|
||||
{ name: 'PBKDF2', salt: salt.slice(), iterations: SALT_ITERATIONS, hash: 'SHA-256' },
|
||||
keyMat,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
@@ -74,11 +75,11 @@ export async function unlockVault(pin: string): Promise<VaultPayload | null> {
|
||||
if (!stored) return null
|
||||
|
||||
try {
|
||||
const key = await deriveKey(pin, fromB64(stored.salt))
|
||||
const key = await deriveKey(pin, fromB64(stored.salt).slice())
|
||||
const plain = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: fromB64(stored.iv) },
|
||||
{ name: 'AES-GCM', iv: fromB64(stored.iv).slice() },
|
||||
key,
|
||||
fromB64(stored.data),
|
||||
fromB64(stored.data).slice(),
|
||||
)
|
||||
return JSON.parse(new TextDecoder().decode(plain)) as VaultPayload
|
||||
} catch {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { libraryState } from "$lib/state/library.svelte";
|
||||
import { addToast } from "$lib/state/notifications.svelte";
|
||||
import { seriesState } from "$lib/state/series.svelte";
|
||||
import type { MangaFilters, MangaMeta } from "$lib/server-adapters/types";
|
||||
import type { Manga, Chapter, Category } from "$lib/types";
|
||||
import type { Manga, Category } from "$lib/types";
|
||||
|
||||
export async function loadLibrary(filters: MangaFilters = { inLibrary: true }) {
|
||||
libraryState.loading = true;
|
||||
@@ -36,28 +36,12 @@ export async function updateManga(id: number, patch: { inLibrary?: boolean }): P
|
||||
if (patch.inLibrary === false) await getAdapter().removeFromLibrary(String(id));
|
||||
}
|
||||
|
||||
export async function loadManga(id: string) {
|
||||
seriesState.loading = true;
|
||||
seriesState.error = null;
|
||||
try {
|
||||
seriesState.current = await getAdapter().getManga(id);
|
||||
} catch (e) {
|
||||
seriesState.error = String(e);
|
||||
} finally {
|
||||
seriesState.loading = false;
|
||||
}
|
||||
export async function loadManga(id: string): Promise<Manga> {
|
||||
return getAdapter().getManga(id);
|
||||
}
|
||||
|
||||
export async function fetchManga(id: string) {
|
||||
seriesState.loading = true;
|
||||
seriesState.error = null;
|
||||
try {
|
||||
seriesState.current = await getAdapter().fetchManga(id);
|
||||
} catch (e) {
|
||||
seriesState.error = String(e);
|
||||
} finally {
|
||||
seriesState.loading = false;
|
||||
}
|
||||
export async function fetchManga(id: string): Promise<Manga> {
|
||||
return getAdapter().fetchManga(id);
|
||||
}
|
||||
|
||||
export async function searchManga(query: string, sourceId?: string) {
|
||||
@@ -84,12 +68,12 @@ export async function removeFromLibrary(mangaId: string) {
|
||||
|
||||
export async function updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
|
||||
await getAdapter().updateMangaMeta(id, meta);
|
||||
if (String(seriesState.current?.id) === id) await loadManga(id);
|
||||
if (String(seriesState.activeManga?.id) === id) seriesState.setActiveManga(await getAdapter().getManga(id));
|
||||
}
|
||||
|
||||
export async function deleteMangaMeta(id: string, key: string) {
|
||||
await getAdapter().deleteMangaMeta(id, key);
|
||||
if (String(seriesState.current?.id) === id) await loadManga(id);
|
||||
if (String(seriesState.activeManga?.id) === id) seriesState.setActiveManga(await getAdapter().getManga(id));
|
||||
}
|
||||
|
||||
export async function refreshLibrary() {
|
||||
|
||||
@@ -20,9 +20,9 @@ export const GET_CHAPTER = `
|
||||
|
||||
export const GET_RECENTLY_UPDATED = `
|
||||
query GetRecentlyUpdated {
|
||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
|
||||
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300, filter: { inLibrary: { equalTo: true } }) {
|
||||
nodes {
|
||||
id name chapterNumber sourceOrder isRead lastPageRead mangaId fetchedAt
|
||||
id name chapterNumber sourceOrder isRead isDownloaded lastPageRead mangaId fetchedAt
|
||||
manga { id title thumbnailUrl inLibrary }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,9 +270,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||
async getChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }, signal
|
||||
)
|
||||
return data.chapters.nodes.map(mapChapter)
|
||||
}
|
||||
@@ -291,9 +291,9 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
return data.fetchChapterPages.pages.map((url, index) => ({ index, url }))
|
||||
}
|
||||
|
||||
async fetchChapters(mangaId: string): Promise<Chapter[]> {
|
||||
async fetchChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ fetchChapters: { chapters: Record<string, unknown>[] } }>(
|
||||
FETCH_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
FETCH_CHAPTERS, { mangaId: Number(mangaId) }, signal
|
||||
)
|
||||
return data.fetchChapters.chapters.map(mapChapter)
|
||||
}
|
||||
@@ -491,6 +491,21 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(DELETE_CATEGORY, { id })
|
||||
}
|
||||
|
||||
async updateCategory(id: number, patch: { name?: string; includeInUpdate?: string; includeInDownload?: string }): Promise<Category> {
|
||||
const data = await this.gql<{ updateCategory: { category: Record<string, unknown> } }>(
|
||||
UPDATE_CATEGORY, { id, ...patch }
|
||||
)
|
||||
return mapCategory(data.updateCategory.category)
|
||||
}
|
||||
|
||||
async updateCategories(
|
||||
ids: number[],
|
||||
patch: { includeInUpdate?: 'INCLUDE' | 'EXCLUDE'; includeInDownload?: 'INCLUDE' | 'EXCLUDE' },
|
||||
): Promise<void> {
|
||||
// Suwayomi has no bulk-category-patch mutation; fan out individually.
|
||||
await Promise.all(ids.map(id => this.gql(UPDATE_CATEGORY, { id, ...patch })))
|
||||
}
|
||||
|
||||
async updateCategoryOrder(id: number, position: number): Promise<Category[]> {
|
||||
const data = await this.gql<{ updateCategoryOrder: { categories: Record<string, unknown>[] } }>(
|
||||
UPDATE_CATEGORY_ORDER, { id, position }
|
||||
@@ -685,9 +700,11 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
libraryUpdateStatus: {
|
||||
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number }
|
||||
}
|
||||
lastUpdateTimestamp: { timestamp: string } | null
|
||||
}>(LIBRARY_UPDATE_STATUS)
|
||||
const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo
|
||||
return { isRunning, finishedJobs, totalJobs }
|
||||
const lastUpdated = data.lastUpdateTimestamp ? Number(data.lastUpdateTimestamp.timestamp) : undefined
|
||||
return { isRunning, finishedJobs, totalJobs, lastUpdated }
|
||||
}
|
||||
|
||||
clearPageCache(chapterId?: number): void {
|
||||
|
||||
@@ -134,9 +134,9 @@ export const CREATE_CATEGORY = `
|
||||
`
|
||||
|
||||
export const UPDATE_CATEGORY = `
|
||||
mutation UpdateCategory($id: Int!, $name: String) {
|
||||
updateCategory(input: { id: $id, patch: { name: $name } }) {
|
||||
category { id name order }
|
||||
mutation UpdateCategory($id: Int!, $name: String, $includeInUpdate: IncludeOrExclude, $includeInDownload: IncludeOrExclude) {
|
||||
updateCategory(input: { id: $id, patch: { name: $name, includeInUpdate: $includeInUpdate, includeInDownload: $includeInDownload } }) {
|
||||
category { id name order includeInUpdate includeInDownload }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,126 +1,239 @@
|
||||
export type PlatformFeature =
|
||||
| 'server-management'
|
||||
| 'biometric-auth'
|
||||
| 'native-window'
|
||||
| 'filesystem'
|
||||
| 'app-updates'
|
||||
| 'discord-rpc'
|
||||
import type { DownloadStatus } from '$lib/types/api'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker, TrackRecord, Category } from '$lib/types'
|
||||
|
||||
export type Platform = 'tauri' | 'capacitor' | 'web'
|
||||
|
||||
export interface ServerLaunchConfig {
|
||||
binary?: string
|
||||
binaryArgs?: string
|
||||
webUiEnabled?: boolean
|
||||
export interface ServerConfig {
|
||||
baseUrl: string
|
||||
credentials?: { username: string; password: string }
|
||||
}
|
||||
|
||||
export interface DiscordAssets {
|
||||
largeImage?: string
|
||||
largeText?: string
|
||||
smallImage?: string
|
||||
smallText?: string
|
||||
export type ServerStatus = 'connected' | 'disconnected' | 'error'
|
||||
|
||||
export interface MangaFilters {
|
||||
inLibrary?: boolean
|
||||
status?: MangaStatus
|
||||
tags?: string[]
|
||||
unread?: boolean
|
||||
sourceId?: string
|
||||
}
|
||||
|
||||
export interface DiscordButton {
|
||||
label: string
|
||||
url: string
|
||||
export type MangaStatus =
|
||||
| 'ONGOING'
|
||||
| 'COMPLETED'
|
||||
| 'LICENSED'
|
||||
| 'PUBLISHING_FINISHED'
|
||||
| 'CANCELLED'
|
||||
| 'ON_HIATUS'
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
hasNextPage: boolean
|
||||
total?: number
|
||||
}
|
||||
|
||||
export interface DiscordPresence {
|
||||
state?: string
|
||||
details?: string
|
||||
assets?: DiscordAssets
|
||||
buttons?: DiscordButton[]
|
||||
timestamps?: { start?: number; end?: number }
|
||||
export interface MangaMeta {
|
||||
customTitle?: string
|
||||
customCover?: string
|
||||
notes?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AppUpdateInfo {
|
||||
version: string
|
||||
url: string
|
||||
notes: string
|
||||
export interface Page {
|
||||
index: number
|
||||
url: string
|
||||
imageData?: string
|
||||
}
|
||||
|
||||
export interface StorageInfo {
|
||||
manga_bytes: number
|
||||
total_bytes: number
|
||||
free_bytes: number
|
||||
path: string
|
||||
export interface AboutServer {
|
||||
name: string
|
||||
version: string
|
||||
buildType: string
|
||||
buildTime: number
|
||||
github: string
|
||||
discord: string
|
||||
}
|
||||
|
||||
export interface MigrateProgress {
|
||||
done: number
|
||||
total: number
|
||||
current: string
|
||||
export interface AboutWebUI {
|
||||
channel: string
|
||||
tag: string
|
||||
updateTimestamp: number
|
||||
}
|
||||
|
||||
export interface UpdateProgress {
|
||||
downloaded: number
|
||||
total: number | null
|
||||
export interface DownloadItem {
|
||||
chapterId: string
|
||||
mangaId: string
|
||||
chapterName: string
|
||||
mangaTitle: string
|
||||
thumbnailUrl?: string
|
||||
progress: number
|
||||
state: 'queued' | 'downloading' | 'finished' | 'error'
|
||||
}
|
||||
|
||||
export interface ReleaseInfo {
|
||||
tag_name: string
|
||||
name: string
|
||||
body: string
|
||||
published_at: string
|
||||
html_url: string
|
||||
export interface UpdateResult {
|
||||
mangaId: string
|
||||
newChapters: number
|
||||
}
|
||||
|
||||
export interface PlatformAdapter {
|
||||
readonly platform: Platform
|
||||
export interface LibraryUpdateProgress {
|
||||
isRunning: boolean
|
||||
finishedJobs: number
|
||||
totalJobs: number
|
||||
lastUpdated?: number
|
||||
}
|
||||
|
||||
init(): Promise<void>
|
||||
destroy(): Promise<void>
|
||||
isSupported(feature: PlatformFeature): boolean
|
||||
export interface ServerSecurity {
|
||||
authMode: string
|
||||
authUsername: string
|
||||
socksProxyEnabled: boolean
|
||||
socksProxyHost: string
|
||||
socksProxyPort: string
|
||||
socksProxyVersion: number
|
||||
socksProxyUsername: string
|
||||
flareSolverrEnabled: boolean
|
||||
flareSolverrUrl: string
|
||||
flareSolverrTimeout: number
|
||||
flareSolverrSessionName: string
|
||||
flareSolverrSessionTtl: number
|
||||
flareSolverrAsResponseFallback: boolean
|
||||
}
|
||||
|
||||
getAppDir(): Promise<string>
|
||||
export interface SetServerAuthInput {
|
||||
authMode: string
|
||||
authUsername: string
|
||||
authPassword: string
|
||||
}
|
||||
|
||||
loadStore(key: string): Promise<unknown>
|
||||
saveStore(key: string, value: unknown): Promise<void>
|
||||
export interface SetSocksProxyInput {
|
||||
socksProxyEnabled: boolean
|
||||
socksProxyHost: string
|
||||
socksProxyPort: string
|
||||
socksProxyVersion: number
|
||||
socksProxyUsername: string
|
||||
socksProxyPassword: string
|
||||
}
|
||||
|
||||
storeCredential(key: string, value: string): Promise<void>
|
||||
getCredential(key: string): Promise<string | null>
|
||||
authenticateBiometric(): Promise<boolean>
|
||||
export interface SetFlareSolverrInput {
|
||||
flareSolverrEnabled: boolean
|
||||
flareSolverrUrl: string
|
||||
flareSolverrTimeout: number
|
||||
flareSolverrSessionName: string
|
||||
flareSolverrSessionTtl: number
|
||||
flareSolverrAsResponseFallback: boolean
|
||||
}
|
||||
|
||||
readFile(path: string): Promise<Uint8Array>
|
||||
writeFile(path: string, data: Uint8Array): Promise<void>
|
||||
pickFolder(): Promise<string | null>
|
||||
checkPathExists(path: string): Promise<boolean>
|
||||
createDirectory(path: string): Promise<void>
|
||||
openPath(path: string): Promise<void>
|
||||
getDefaultDownloadsPath(): Promise<string>
|
||||
getStorageInfo(downloadsPath: string): Promise<StorageInfo>
|
||||
migrateDownloads(src: string, dst: string): Promise<void>
|
||||
getAutoBackupDir(): Promise<string>
|
||||
export interface TrackRecordPatch {
|
||||
status?: number
|
||||
score?: number
|
||||
lastChapterRead?: number
|
||||
startDate?: string
|
||||
finishDate?: string
|
||||
private?: boolean
|
||||
}
|
||||
|
||||
fetchImage(url: string, headers: Record<string, string>): Promise<Blob>
|
||||
export interface RestoreStatus {
|
||||
mangaProgress: number
|
||||
state: string
|
||||
totalManga: number
|
||||
}
|
||||
|
||||
launchServer(config: ServerLaunchConfig): Promise<void>
|
||||
stopServer(): Promise<void>
|
||||
getServerStatus(): Promise<'running' | 'stopped' | 'error'>
|
||||
export interface ValidateBackupResult {
|
||||
missingSources: { id: string; name: string }[]
|
||||
missingTrackers: { name: string }[]
|
||||
}
|
||||
|
||||
setTitle(title: string): Promise<void>
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
toggleFullscreen(): Promise<void>
|
||||
export interface ServerAdapter {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
getServerUrl(): string
|
||||
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
getManga(id: string, signal?: AbortSignal): Promise<Manga>
|
||||
getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>>
|
||||
getMangasByGenre(filter: Record<string, unknown>, first: number, offset: number, signal?: AbortSignal): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }>
|
||||
searchManga(query: string, sourceId?: string): Promise<Manga[]>
|
||||
searchSource(sourceId: string, query: string, page?: number, signal?: AbortSignal): Promise<PaginatedResult<Manga>>
|
||||
fetchManga(id: string): Promise<Manga>
|
||||
addToLibrary(mangaId: string): Promise<void>
|
||||
removeFromLibrary(mangaId: string): Promise<void>
|
||||
updateMangas(ids: string[], patch: { inLibrary?: boolean }): Promise<void>
|
||||
updateMangaMeta(id: string, meta: Partial<MangaMeta>): Promise<void>
|
||||
deleteMangaMeta(id: string, key: string): Promise<void>
|
||||
|
||||
getVersion(): Promise<string>
|
||||
openExternal(url: string): Promise<void>
|
||||
checkForAppUpdate(): Promise<AppUpdateInfo | null>
|
||||
installAppUpdate(tag: string): Promise<void>
|
||||
restartApp(): Promise<void>
|
||||
exitApp(): Promise<void>
|
||||
listReleases(): Promise<ReleaseInfo[]>
|
||||
getChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]>
|
||||
getChapter(id: string): Promise<Chapter>
|
||||
getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
|
||||
fetchChapters(mangaId: string, signal?: AbortSignal): Promise<Chapter[]>
|
||||
getRecentlyUpdated(): Promise<Chapter[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
markChaptersRead(ids: string[], read: boolean): Promise<void>
|
||||
updateChaptersProgress(ids: string[], patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void>
|
||||
deleteDownloadedChapters(ids: string[]): Promise<void>
|
||||
setChapterMeta(chapterId: string, key: string, value: string): Promise<void>
|
||||
deleteChapterMeta(chapterId: string, key: string): Promise<void>
|
||||
|
||||
clearMokuCache(): Promise<void>
|
||||
clearSuwayomiCache(): Promise<void>
|
||||
resetSuwayomiData(): Promise<void>
|
||||
getAboutServer(): Promise<AboutServer>
|
||||
getAboutWebUI(): Promise<AboutWebUI>
|
||||
|
||||
onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
|
||||
onUpdateLaunching(cb: () => void): Promise<() => void>
|
||||
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
|
||||
getDownloads(): Promise<DownloadItem[]>
|
||||
getDownloadStatus(): Promise<DownloadStatus>
|
||||
enqueueDownload(chapterId: string): Promise<void>
|
||||
enqueueDownloads(chapterIds: string[]): Promise<void>
|
||||
dequeueDownload(chapterId: string): Promise<void>
|
||||
dequeueDownloads(chapterIds: string[]): Promise<void>
|
||||
reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null>
|
||||
clearDownloads(): Promise<void>
|
||||
startDownloader(): Promise<DownloadStatus | null>
|
||||
stopDownloader(): Promise<DownloadStatus | null>
|
||||
|
||||
getExtensions(): Promise<Extension[]>
|
||||
installExtension(id: string): Promise<void>
|
||||
uninstallExtension(id: string): Promise<void>
|
||||
updateExtension(id: string): Promise<void>
|
||||
updateExtensions(ids: string[]): Promise<void>
|
||||
installExternalExtension(url: string): Promise<void>
|
||||
getExtensionRepos(): Promise<string[]>
|
||||
setExtensionRepos(repos: string[]): Promise<string[]>
|
||||
|
||||
getSources(): Promise<Source[]>
|
||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>
|
||||
getSourceSettings(sourceId: string): Promise<unknown[]>
|
||||
updateSourcePreference(sourceId: string, position: number, changeType: string, value: unknown): Promise<unknown[]>
|
||||
|
||||
getCategories(): Promise<Category[]>
|
||||
createCategory(name: string): Promise<Category>
|
||||
deleteCategory(id: number): Promise<void>
|
||||
updateCategoryOrder(id: number, position: number): Promise<Category[]>
|
||||
updateMangaCategories(mangaId: string, addTo: number[], removeFrom: number[]): Promise<void>
|
||||
updateMangasCategories(mangaIds: string[], addTo: number[], removeFrom: number[]): Promise<void>
|
||||
updateCategoryManga(categoryId: number): Promise<void>
|
||||
|
||||
getTrackers(): Promise<Tracker[]>
|
||||
getAllTrackerRecords(): Promise<unknown[]>
|
||||
getMangaTrackRecords(mangaId: string): Promise<unknown[]>
|
||||
searchTracker(trackerId: string, query: string): Promise<unknown[]>
|
||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>
|
||||
unlinkTracker(recordId: string): Promise<void>
|
||||
updateTrackRecord(recordId: string, patch: TrackRecordPatch): Promise<TrackRecord>
|
||||
fetchTrackRecord(recordId: string): Promise<TrackRecord>
|
||||
syncTracking(mangaId: string): Promise<void>
|
||||
loginTrackerOAuth(trackerId: string, callbackUrl: string): Promise<void>
|
||||
loginTrackerCredentials(trackerId: string, username: string, password: string): Promise<void>
|
||||
logoutTracker(trackerId: string): Promise<void>
|
||||
|
||||
getServerSecurity(): Promise<ServerSecurity>
|
||||
setServerAuth(input: SetServerAuthInput): Promise<void>
|
||||
setSocksProxy(input: SetSocksProxyInput): Promise<void>
|
||||
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
|
||||
|
||||
getDownloadsPath(): Promise<{ downloadsPath: string; localSourcePath: string }>
|
||||
setDownloadsPath(path: string): Promise<void>
|
||||
setLocalSourcePath(path: string): Promise<void>
|
||||
createBackup(): Promise<{ url: string }>
|
||||
restoreBackup(file: File): Promise<{ id: string; status: RestoreStatus }>
|
||||
validateBackup(file: File): Promise<ValidateBackupResult>
|
||||
pollRestoreStatus(id: string): Promise<RestoreStatus>
|
||||
clearCachedImages(opts: { cachedPages: boolean; cachedThumbnails: boolean; downloadedThumbnails: boolean }): Promise<void>
|
||||
|
||||
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
|
||||
stopLibraryUpdate(): Promise<void>
|
||||
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
||||
clearPageCache(chapterId?: number): void
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class HistoryStore {
|
||||
}
|
||||
|
||||
private async _persist() {
|
||||
const bookmarks = (await import('$lib/state/reader.svelte')).readerState.bookmarks
|
||||
const bookmarks = (await import('$lib/state/series.svelte')).seriesState.bookmarks
|
||||
const markers = (await import('$lib/state/reader.svelte')).readerState.markers
|
||||
await saveLibrary({
|
||||
sessions: this.sessions,
|
||||
|
||||
@@ -149,7 +149,7 @@ class LibraryState {
|
||||
|
||||
const f = this.tabFilters[tab] ?? {};
|
||||
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
|
||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.totalChapters ?? 0) > (m.unreadCount ?? 0));
|
||||
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapters?.totalCount ?? 0) > (m.unreadCount ?? 0));
|
||||
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
|
||||
if (f.bookmarked) items = items.filter(m => (m.bookmarkCount ?? 0) > 0);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Manga, Chapter } from "$lib/types";
|
||||
import type { BookmarkEntry, MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
import type { MarkerEntry, MarkerColor } from "$lib/types/history";
|
||||
import type { MangaPrefs, ReaderSettings, ReaderPreset } from "$lib/types/settings";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { seriesState } from "$lib/state/series.svelte";
|
||||
@@ -39,7 +39,6 @@ class ReaderState {
|
||||
|
||||
pageUrls = $state<string[]>([]);
|
||||
pageNumber = $state(1);
|
||||
bookmarks = $state<BookmarkEntry[]>([]);
|
||||
markers = $state<MarkerEntry[]>([]);
|
||||
|
||||
loading = $state(true);
|
||||
@@ -147,17 +146,6 @@ class ReaderState {
|
||||
this.markerEditId = "";
|
||||
}
|
||||
|
||||
addBookmark(entry: Omit<BookmarkEntry, "savedAt">) {
|
||||
this.bookmarks = [
|
||||
{ ...entry, savedAt: Date.now() },
|
||||
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
|
||||
].slice(0, 200);
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) {
|
||||
this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId);
|
||||
}
|
||||
|
||||
addMarker(entry: Omit<MarkerEntry, "id" | "createdAt">): string {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
this.markers = [...this.markers, { ...entry, id, createdAt: Date.now() }];
|
||||
|
||||
@@ -10,20 +10,18 @@ export type { BookmarkEntry, MarkerEntry, MarkerColor } from '$lib/types/history
|
||||
export type { MangaPrefs } from '$lib/types/settings'
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
sortMode: 'source',
|
||||
sortDir: 'asc',
|
||||
preferredScanlator: '',
|
||||
scanlatorFilter: [],
|
||||
scanlatorBlacklist: [],
|
||||
scanlatorForce: false,
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
maxKeepChapters: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: 'global',
|
||||
coverUrl: '',
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
maxKeepChapters: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: 'global',
|
||||
preferredScanlator: '',
|
||||
scanlatorFilter: [],
|
||||
scanlatorBlacklist: [],
|
||||
scanlatorForce: false,
|
||||
autoDownloadScanlators: [],
|
||||
}
|
||||
|
||||
const CHAPTER_TTL_MS = 2 * 60 * 1000
|
||||
@@ -36,24 +34,25 @@ class SeriesStore {
|
||||
markers = $state<MarkerEntry[]>([])
|
||||
acknowledgedUpdates = $state<Set<number>>(new Set())
|
||||
|
||||
#rawChapters = $state<Map<number, Chapter[]>>(new Map())
|
||||
#fetchedAt = new Map<number, number>()
|
||||
#abortCtrls = new Map<number, AbortController>()
|
||||
#loading = $state<Set<number>>(new Set())
|
||||
#errors = $state<Map<number, string>>(new Map())
|
||||
#rawChapters = $state<Map<number, Chapter[]>>(new Map())
|
||||
#fetchedAt = new Map<number, number>()
|
||||
#abortCtrls = new Map<number, AbortController>()
|
||||
#loading = $state<Set<number>>(new Set())
|
||||
#errors = $state<Map<number, string>>(new Map())
|
||||
|
||||
readonly activeChapterList = $derived.by(() => {
|
||||
const id = this.activeManga?.id
|
||||
if (id == null) return []
|
||||
const raw = this.#rawChapters.get(id) ?? []
|
||||
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
|
||||
const raw = this.#rawChapters.get(id) ?? []
|
||||
const prefs = settingsState.settings.mangaPrefs?.[id] ?? {}
|
||||
const globals = settingsState.settings
|
||||
return buildChapterList(raw, {
|
||||
sortMode: (prefs.sortMode ?? DEFAULT_MANGA_PREFS.sortMode) as MangaPrefs['sortMode'],
|
||||
sortDir: (prefs.sortDir ?? DEFAULT_MANGA_PREFS.sortDir) as MangaPrefs['sortDir'],
|
||||
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string,
|
||||
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[],
|
||||
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[],
|
||||
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean,
|
||||
sortMode: globals.chapterSortMode,
|
||||
sortDir: globals.chapterSortDir,
|
||||
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator),
|
||||
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter),
|
||||
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist),
|
||||
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,21 +64,21 @@ class SeriesStore {
|
||||
return buildChapterList(raw, {
|
||||
sortMode: 'source',
|
||||
sortDir: 'asc',
|
||||
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator) as string,
|
||||
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter) as string[],
|
||||
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist) as string[],
|
||||
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce) as boolean,
|
||||
preferredScanlator: (prefs.preferredScanlator ?? DEFAULT_MANGA_PREFS.preferredScanlator),
|
||||
scanlatorFilter: (prefs.scanlatorFilter ?? DEFAULT_MANGA_PREFS.scanlatorFilter),
|
||||
scanlatorBlacklist: (prefs.scanlatorBlacklist ?? DEFAULT_MANGA_PREFS.scanlatorBlacklist),
|
||||
scanlatorForce: (prefs.scanlatorForce ?? DEFAULT_MANGA_PREFS.scanlatorForce),
|
||||
})
|
||||
})
|
||||
|
||||
chaptersFor(mangaId: number): Chapter[] { return this.#rawChapters.get(mangaId) ?? [] }
|
||||
isLoadingChapters(mangaId: number) { return this.#loading.has(mangaId) }
|
||||
chapterError(mangaId: number) { return this.#errors.get(mangaId) ?? null }
|
||||
chaptersFor(mangaId: number): Chapter[] { return this.#rawChapters.get(mangaId) ?? [] }
|
||||
isLoadingChapters(mangaId: number) { return this.#loading.has(mangaId) }
|
||||
chapterError(mangaId: number) { return this.#errors.get(mangaId) ?? null }
|
||||
|
||||
async loadChapters(mangaId: number, { force = false } = {}): Promise<void> {
|
||||
const now = Date.now()
|
||||
const now = Date.now()
|
||||
const stalest = this.#fetchedAt.get(mangaId) ?? 0
|
||||
const fresh = !force && this.#rawChapters.has(mangaId) && now - stalest < CHAPTER_TTL_MS
|
||||
const fresh = !force && this.#rawChapters.has(mangaId) && now - stalest < CHAPTER_TTL_MS
|
||||
|
||||
if (fresh) return
|
||||
|
||||
@@ -127,13 +126,8 @@ class SeriesStore {
|
||||
this.#rawChapters = new Map(this.#rawChapters).set(mangaId, updater(current))
|
||||
}
|
||||
|
||||
setActiveManga(manga: Manga | null) {
|
||||
this.activeManga = manga
|
||||
}
|
||||
|
||||
setPreviewManga(manga: Manga | null) {
|
||||
this.previewManga = manga
|
||||
}
|
||||
setActiveManga(manga: Manga | null) { this.activeManga = manga }
|
||||
setPreviewManga(manga: Manga | null) { this.previewManga = manga }
|
||||
|
||||
openReaderForChapter(chapter: Chapter, manga?: Manga | null) {
|
||||
if (manga !== undefined) this.activeManga = manga
|
||||
@@ -189,9 +183,17 @@ class SeriesStore {
|
||||
].slice(0, 200)
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId) }
|
||||
clearBookmarks() { this.bookmarks = [] }
|
||||
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId) }
|
||||
/** Sets the single "resume" bookmark for a manga, replacing any bookmark
|
||||
* that exists for that manga in a different chapter. */
|
||||
setBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) {
|
||||
const other = this.bookmarks.find(b => b.mangaId === entry.mangaId && b.chapterId !== entry.chapterId)
|
||||
if (other) this.removeBookmark(other.chapterId)
|
||||
this.addBookmark(entry, label)
|
||||
}
|
||||
|
||||
removeBookmark(chapterId: number) { this.bookmarks = this.bookmarks.filter(b => b.chapterId !== chapterId) }
|
||||
clearBookmarks() { this.bookmarks = [] }
|
||||
getBookmark(chapterId: number) { return this.bookmarks.find(b => b.chapterId === chapterId) }
|
||||
|
||||
addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string {
|
||||
const id = Math.random().toString(36).slice(2)
|
||||
@@ -203,11 +205,11 @@ class SeriesStore {
|
||||
this.markers = this.markers.map(m => m.id === id ? { ...m, ...patch, updatedAt: Date.now() } : m)
|
||||
}
|
||||
|
||||
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id) }
|
||||
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page) }
|
||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId) }
|
||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId) }
|
||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId) }
|
||||
removeMarker(id: string) { this.markers = this.markers.filter(m => m.id !== id) }
|
||||
getMarkersForPage(chapterId: number, page: number) { return this.markers.filter(m => m.chapterId === chapterId && m.pageNumber === page) }
|
||||
getMarkersForChapter(chapterId: number) { return this.markers.filter(m => m.chapterId === chapterId) }
|
||||
getMarkersForManga(mangaId: number) { return this.markers.filter(m => m.mangaId === mangaId) }
|
||||
clearMarkersForManga(mangaId: number) { this.markers = this.markers.filter(m => m.mangaId !== mangaId) }
|
||||
|
||||
get settings() { return settingsState.settings }
|
||||
}
|
||||
@@ -215,21 +217,22 @@ class SeriesStore {
|
||||
export const seriesState = new SeriesStore()
|
||||
export const seriesStore = seriesState
|
||||
|
||||
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next) }
|
||||
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next) }
|
||||
export function openReaderForChapter(ch: Chapter, manga?: Manga | null) { seriesState.openReaderForChapter(ch, manga) }
|
||||
export function closeReader() { seriesState.closeReader() }
|
||||
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId) }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.addBookmark(entry, label) }
|
||||
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId) }
|
||||
export function clearBookmarks() { seriesState.clearBookmarks() }
|
||||
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId) }
|
||||
export function addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string { return seriesState.addMarker(entry) }
|
||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) { seriesState.updateMarker(id, patch) }
|
||||
export function removeMarker(id: string) { seriesState.removeMarker(id) }
|
||||
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page) }
|
||||
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId) }
|
||||
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId) }
|
||||
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId) }
|
||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key) }
|
||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, v: MangaPrefs[K]) { seriesState.setPref(mangaId, key, v) }
|
||||
export function setActiveManga(next: Manga | null) { seriesState.setActiveManga(next) }
|
||||
export function setPreviewManga(next: Manga | null) { seriesState.setPreviewManga(next) }
|
||||
export function openReaderForChapter(ch: Chapter, manga?: Manga | null) { seriesState.openReaderForChapter(ch, manga) }
|
||||
export function closeReader() { seriesState.closeReader() }
|
||||
export function acknowledgeUpdate(mangaId: number) { seriesState.acknowledgeUpdate(mangaId) }
|
||||
export function addBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.addBookmark(entry, label) }
|
||||
export function setBookmark(entry: Omit<BookmarkEntry, 'savedAt'>, label?: string) { seriesState.setBookmark(entry, label) }
|
||||
export function removeBookmark(chapterId: number) { seriesState.removeBookmark(chapterId) }
|
||||
export function clearBookmarks() { seriesState.clearBookmarks() }
|
||||
export function getBookmark(chapterId: number) { return seriesState.getBookmark(chapterId) }
|
||||
export function addMarker(entry: Omit<MarkerEntry, 'id' | 'createdAt'>): string { return seriesState.addMarker(entry) }
|
||||
export function updateMarker(id: string, patch: Partial<Pick<MarkerEntry, 'note' | 'color'>>) { seriesState.updateMarker(id, patch) }
|
||||
export function removeMarker(id: string) { seriesState.removeMarker(id) }
|
||||
export function getMarkersForPage(chapterId: number, page: number) { return seriesState.getMarkersForPage(chapterId, page) }
|
||||
export function getMarkersForChapter(chapterId: number) { return seriesState.getMarkersForChapter(chapterId) }
|
||||
export function getMarkersForManga(mangaId: number) { return seriesState.getMarkersForManga(mangaId) }
|
||||
export function clearMarkersForManga(mangaId: number) { seriesState.clearMarkersForManga(mangaId) }
|
||||
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] { return seriesState.getPref(mangaId, key) }
|
||||
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, v: MangaPrefs[K]) { seriesState.setPref(mangaId, key, v) }
|
||||
@@ -1,4 +1,5 @@
|
||||
export type { Manga, MangaDetail, Category, ChapterRef } from './manga'
|
||||
export type { Chapter } from './chapter'
|
||||
export type { Extension, Source } from './extension'
|
||||
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
|
||||
export type { Tracker, TrackRecord, TrackerStatus } from './tracking'
|
||||
export type { Settings, MangaPrefs, ContentLevel } from './settings'
|
||||
@@ -49,5 +49,8 @@ export interface Manga {
|
||||
firstUnreadChapter?: ChapterRef | null
|
||||
highestNumberedChapter?: ChapterRef | null
|
||||
|
||||
source?: { id: string; name: string; displayName: string } | null
|
||||
}
|
||||
source?: { id: string; name: string; displayName: string; isNsfw?: boolean } | null
|
||||
chapters?: { totalCount: number }
|
||||
}
|
||||
|
||||
export type MangaDetail = Manga
|
||||
Reference in New Issue
Block a user