mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-15 10:19:55 -05:00
Fix: Updates Truncation & Library Patches
This commit is contained in:
@@ -105,6 +105,15 @@
|
||||
}
|
||||
|
||||
async function doRemove(m: Manga) {
|
||||
// Remove from every category first, then remove from library
|
||||
const catIds = libraryState.categories
|
||||
.filter(c => (libraryState.categoryMangaMap.get(c.id) ?? []).some(x => x.id === m.id))
|
||||
.map(c => c.id)
|
||||
if (catIds.length) {
|
||||
try {
|
||||
await getAdapter().updateMangaCategories(String(m.id), [], catIds)
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
await getAdapter().removeFromLibrary(String(m.id))
|
||||
libraryState.items = libraryState.items.filter(x => x.id !== m.id)
|
||||
await loadCategories()
|
||||
@@ -211,8 +220,17 @@
|
||||
async function onBulkRemove() {
|
||||
bulkWorking = true
|
||||
try {
|
||||
// For each selected manga, remove from all its categories first
|
||||
await Promise.allSettled(
|
||||
[...libraryState.selected].map(id => getAdapter().removeFromLibrary(String(id)))
|
||||
[...libraryState.selected].map(async (id) => {
|
||||
const catIds = libraryState.categories
|
||||
.filter(c => (libraryState.categoryMangaMap.get(c.id) ?? []).some(x => x.id === id))
|
||||
.map(c => c.id)
|
||||
if (catIds.length) {
|
||||
try { await getAdapter().updateMangaCategories(String(id), [], catIds) } catch {}
|
||||
}
|
||||
return getAdapter().removeFromLibrary(String(id))
|
||||
})
|
||||
)
|
||||
libraryState.items = libraryState.items.filter(m => !libraryState.selected.has(m.id))
|
||||
libraryState.exitSelect()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { BookOpen, CircleNotch, Download, Trash } from 'phosphor-svelte'
|
||||
import { BookOpen, CircleNotch, Download, Trash, CaretDown, CaretRight } from 'phosphor-svelte'
|
||||
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
|
||||
import type { RecentUpdate, UpdateGroup } from './lib/recentUpdates'
|
||||
|
||||
const BUNDLE_THRESHOLD = 3
|
||||
|
||||
interface Props {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
@@ -26,6 +28,44 @@
|
||||
onOpenUpdate, onOpenSeries, onEnqueue, onDeleteDownload,
|
||||
}: Props = $props()
|
||||
|
||||
// key = `${dayLabel}::${mangaId}`, tracks which bundles are expanded
|
||||
let expandedBundles: Record<string, boolean> = $state({})
|
||||
|
||||
function bundleKey(dayLabel: string, mangaId: number) {
|
||||
return `${dayLabel}::${mangaId}`
|
||||
}
|
||||
|
||||
function toggleBundle(key: string) {
|
||||
expandedBundles = { ...expandedBundles, [key]: !expandedBundles[key] }
|
||||
}
|
||||
|
||||
type SingleRow = { kind: 'single'; item: RecentUpdate }
|
||||
type BundleRow = { kind: 'bundle'; mangaId: number; items: RecentUpdate[]; key: string }
|
||||
type Row = SingleRow | BundleRow
|
||||
|
||||
// Within a day group, collapse consecutive runs of BUNDLE_THRESHOLD+ chapters from the same manga
|
||||
function bundleRows(dayLabel: string, items: RecentUpdate[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
let i = 0
|
||||
while (i < items.length) {
|
||||
const cur = items[i]
|
||||
const mangaId = cur.mangaId ?? cur.manga?.id
|
||||
if (mangaId == null) { rows.push({ kind: 'single', item: cur }); i++; continue }
|
||||
|
||||
let j = i + 1
|
||||
while (j < items.length && (items[j].mangaId ?? items[j].manga?.id) === mangaId) j++
|
||||
|
||||
const run = items.slice(i, j)
|
||||
if (run.length >= BUNDLE_THRESHOLD) {
|
||||
rows.push({ kind: 'bundle', mangaId, items: run, key: bundleKey(dayLabel, mangaId) })
|
||||
} else {
|
||||
for (const item of run) rows.push({ kind: 'single', item })
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const filteredGroups = $derived(updatesSearch.trim()
|
||||
? groups
|
||||
.map(g => ({
|
||||
@@ -43,6 +83,16 @@
|
||||
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`
|
||||
return 'Chapter'
|
||||
}
|
||||
|
||||
function bundleChapterRange(items: RecentUpdate[]): string {
|
||||
const nums = items
|
||||
.map(i => i.chapterNumber)
|
||||
.filter(n => Number.isFinite(n)) as number[]
|
||||
if (!nums.length) return `${items.length} chapters`
|
||||
const min = Math.min(...nums)
|
||||
const max = Math.max(...nums)
|
||||
return min === max ? `Ch. ${min}` : `Ch. ${min}–${max}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
@@ -124,50 +174,135 @@
|
||||
<div class="day-rule"></div>
|
||||
</div>
|
||||
<div class="updates-list">
|
||||
{#each items as item (item.id)}
|
||||
<div class="update-row" class:read={item.isRead}>
|
||||
<button class="thumb-btn" onclick={() => onOpenSeries(item)} title="View series">
|
||||
<Thumbnail
|
||||
src={item.manga?.thumbnailUrl ?? ''}
|
||||
alt={item.manga?.title ?? 'Series cover'}
|
||||
class="thumb"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="info-btn"
|
||||
onclick={() => onOpenUpdate(item)}
|
||||
disabled={openingId === item.id}
|
||||
>
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
||||
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
|
||||
{#each bundleRows(label, items) as row (row.kind === 'single' ? row.item.id : row.key)}
|
||||
|
||||
{#if row.kind === 'single'}
|
||||
{@const item = row.item}
|
||||
<div class="update-row" class:read={item.isRead}>
|
||||
<button class="thumb-btn" onclick={() => onOpenSeries(item)} title="View series">
|
||||
<Thumbnail
|
||||
src={item.manga?.thumbnailUrl ?? ''}
|
||||
alt={item.manga?.title ?? 'Series cover'}
|
||||
class="thumb"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="info-btn"
|
||||
onclick={() => onOpenUpdate(item)}
|
||||
disabled={openingId === item.id}
|
||||
>
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="series-title">{item.manga?.title ?? 'Unknown series'}</span>
|
||||
{#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}
|
||||
<div class="meta-row"><span>Resume p.{item.lastPageRead}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||
<div class="meta-row"><span>Resume p.{item.lastPageRead}</span></div>
|
||||
{/if}
|
||||
<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}
|
||||
<BookOpen size={14} weight="light" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{@const bundle = row}
|
||||
{@const expanded = expandedBundles[bundle.key] ?? false}
|
||||
{@const first = bundle.items[0]}
|
||||
{@const hasUnread = bundle.items.some(i => !i.isRead)}
|
||||
<div class="bundle" class:expanded>
|
||||
<!-- collapsed header -->
|
||||
<div class="bundle-header" class:read={!hasUnread}>
|
||||
<button class="thumb-btn" onclick={() => onOpenSeries(first)} title="View series">
|
||||
<Thumbnail
|
||||
src={first.manga?.thumbnailUrl ?? ''}
|
||||
alt={first.manga?.title ?? 'Series cover'}
|
||||
class="thumb"
|
||||
/>
|
||||
</button>
|
||||
<button class="bundle-summary" onclick={() => toggleBundle(bundle.key)}>
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="series-title">{first.manga?.title ?? 'Unknown series'}</span>
|
||||
{#if hasUnread}<span class="pill" title="Unread"></span>{/if}
|
||||
</div>
|
||||
<span class="chapter-title">{bundleChapterRange(bundle.items)}</span>
|
||||
<div class="meta-row"><span>{bundle.items.length} chapters</span></div>
|
||||
</div>
|
||||
<div class="row-end">
|
||||
<span class="caret">
|
||||
{#if expanded}
|
||||
<CaretDown size={13} weight="bold" />
|
||||
{:else}
|
||||
<CaretRight size={13} weight="bold" />
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</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}
|
||||
<BookOpen size={14} weight="light" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- expanded chapter list -->
|
||||
{#if expanded}
|
||||
<div class="bundle-items">
|
||||
{#each bundle.items as item (item.id)}
|
||||
<div class="update-row bundle-child" class:read={item.isRead}>
|
||||
<button
|
||||
class="info-btn"
|
||||
onclick={() => onOpenUpdate(item)}
|
||||
disabled={openingId === item.id}
|
||||
>
|
||||
<div class="update-info">
|
||||
<div class="title-row">
|
||||
<span class="chapter-title">{chapterLabel(item)}</span>
|
||||
{#if !item.isRead}<span class="pill" title="Unread"></span>{/if}
|
||||
</div>
|
||||
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
|
||||
<div class="meta-row"><span>Resume p.{item.lastPageRead}</span></div>
|
||||
{/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}
|
||||
<BookOpen size={14} weight="light" />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -287,6 +422,48 @@
|
||||
.dl-btn-delete { color: var(--color-error); }
|
||||
.dl-btn-delete:hover { background: var(--color-error-bg); }
|
||||
|
||||
/* ── Bundle styles ── */
|
||||
.bundle {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
.bundle.expanded { border-color: var(--border-strong); }
|
||||
|
||||
.bundle-header {
|
||||
display: flex; align-items: stretch;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.bundle-header.read { opacity: 0.5; }
|
||||
.bundle-header:has(.bundle-summary:hover) { background: var(--bg-elevated); }
|
||||
|
||||
.bundle-summary {
|
||||
flex: 1; min-width: 0; display: flex; align-items: center; gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-3); background: none; border: none;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
|
||||
.caret { color: var(--text-faint); display: flex; align-items: center; }
|
||||
|
||||
.bundle-items {
|
||||
border-top: 1px solid var(--border-dim);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.bundle-child {
|
||||
border-radius: 0; border: none;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay, var(--bg-elevated));
|
||||
padding-left: var(--sp-6);
|
||||
}
|
||||
.bundle-child:last-child { border-bottom: none; }
|
||||
.bundle-child:has(.info-btn:hover:not(:disabled)) {
|
||||
background: var(--bg-elevated);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.empty {
|
||||
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: var(--sp-2); color: var(--text-faint);
|
||||
|
||||
Reference in New Issue
Block a user