420 lines
12 KiB
Svelte
420 lines
12 KiB
Svelte
<script lang="ts">
|
|
import { goto } from '$app/navigation';
|
|
import {
|
|
deleteCollection,
|
|
removeMangaFromCollection,
|
|
updateCollection
|
|
} from '$lib/api/collections';
|
|
import {
|
|
removePageFromCollection,
|
|
type CollectionPageItem
|
|
} from '$lib/api/page_collections';
|
|
import type { Manga } from '$lib/api/client';
|
|
import { fileUrl } from '$lib/api/client';
|
|
import MangaCard from '$lib/components/MangaCard.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
|
import Pencil from '@lucide/svelte/icons/pencil';
|
|
import Check from '@lucide/svelte/icons/check';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
import X from '@lucide/svelte/icons/x';
|
|
|
|
let { data } = $props();
|
|
// svelte-ignore state_referenced_locally
|
|
let collection = $state({ ...data.collection });
|
|
// svelte-ignore state_referenced_locally
|
|
let mangas = $state<Manga[]>([...data.mangas]);
|
|
// svelte-ignore state_referenced_locally
|
|
let pages = $state<CollectionPageItem[]>([...data.pages]);
|
|
|
|
let editing = $state(false);
|
|
let editName = $state('');
|
|
let editDescription = $state('');
|
|
let editError: string | null = $state(null);
|
|
let editBusy = $state(false);
|
|
|
|
function startEdit() {
|
|
editName = collection.name;
|
|
editDescription = collection.description ?? '';
|
|
editError = null;
|
|
editing = true;
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (editBusy) return;
|
|
editBusy = true;
|
|
editError = null;
|
|
try {
|
|
const updated = await updateCollection(collection.id, {
|
|
name: editName.trim(),
|
|
description: editDescription.trim() || null
|
|
});
|
|
collection = updated;
|
|
editing = false;
|
|
} catch (e) {
|
|
editError = (e as Error).message;
|
|
} finally {
|
|
editBusy = false;
|
|
}
|
|
}
|
|
|
|
async function onDeleteCollection() {
|
|
if (!confirm(`Delete collection "${collection.name}"? This cannot be undone.`)) {
|
|
return;
|
|
}
|
|
try {
|
|
await deleteCollection(collection.id);
|
|
goto('/collections');
|
|
} catch (e) {
|
|
editError = (e as Error).message;
|
|
}
|
|
}
|
|
|
|
async function onRemoveManga(m: Manga) {
|
|
const snapshot = mangas;
|
|
mangas = mangas.filter((x) => x.id !== m.id);
|
|
try {
|
|
await removeMangaFromCollection(collection.id, m.id);
|
|
} catch (e) {
|
|
mangas = snapshot;
|
|
editError = (e as Error).message;
|
|
}
|
|
}
|
|
|
|
async function onRemovePage(p: CollectionPageItem) {
|
|
const snapshot = pages;
|
|
pages = pages.filter((x) => x.page_id !== p.page_id);
|
|
try {
|
|
await removePageFromCollection(collection.id, p.page_id);
|
|
} catch (e) {
|
|
pages = snapshot;
|
|
editError = (e as Error).message;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Mangalord | {collection.name}</title>
|
|
</svelte:head>
|
|
|
|
<nav class="back">
|
|
<a href="/collections" class="back-link">
|
|
<ArrowLeft size={16} aria-hidden="true" />
|
|
<span>All collections</span>
|
|
</a>
|
|
</nav>
|
|
|
|
<header class="overview">
|
|
{#if editing}
|
|
<form
|
|
class="edit-form"
|
|
onsubmit={(e) => {
|
|
e.preventDefault();
|
|
void saveEdit();
|
|
}}
|
|
action="javascript:void(0)"
|
|
>
|
|
<input
|
|
type="text"
|
|
bind:value={editName}
|
|
maxlength="64"
|
|
required
|
|
aria-label="Collection name"
|
|
data-testid="collection-edit-name"
|
|
/>
|
|
<textarea
|
|
bind:value={editDescription}
|
|
rows="2"
|
|
maxlength="1024"
|
|
placeholder="Description (optional)"
|
|
aria-label="Collection description"
|
|
data-testid="collection-edit-description"
|
|
></textarea>
|
|
<div class="edit-actions">
|
|
<button
|
|
type="submit"
|
|
class="primary"
|
|
disabled={!editName.trim() || editBusy}
|
|
data-testid="collection-edit-save"
|
|
>
|
|
<Check size={14} aria-hidden="true" />
|
|
<span>Save</span>
|
|
</button>
|
|
<button type="button" onclick={() => (editing = false)} disabled={editBusy}>
|
|
<X size={14} aria-hidden="true" />
|
|
<span>Cancel</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
{:else}
|
|
<div class="title-row">
|
|
<h1 data-testid="collection-name">{collection.name}</h1>
|
|
<IconButton
|
|
onclick={startEdit}
|
|
aria-label="Edit collection"
|
|
title="Edit"
|
|
data-testid="collection-edit-open"
|
|
>
|
|
<Pencil size={16} aria-hidden="true" />
|
|
</IconButton>
|
|
<IconButton
|
|
variant="danger"
|
|
onclick={onDeleteCollection}
|
|
aria-label="Delete collection"
|
|
title="Delete"
|
|
data-testid="collection-delete"
|
|
>
|
|
<Trash2 size={16} aria-hidden="true" />
|
|
</IconButton>
|
|
</div>
|
|
{#if collection.description}
|
|
<p class="description" data-testid="collection-description">
|
|
{collection.description}
|
|
</p>
|
|
{/if}
|
|
{/if}
|
|
{#if editError}
|
|
<p class="error" role="alert">{editError}</p>
|
|
{/if}
|
|
</header>
|
|
|
|
{#if mangas.length === 0 && pages.length === 0}
|
|
<p class="status" data-testid="collection-empty">
|
|
This collection is empty.
|
|
</p>
|
|
{/if}
|
|
|
|
{#if mangas.length > 0}
|
|
<section aria-labelledby="mangas-heading">
|
|
<h2 id="mangas-heading" class="section-heading">Mangas</h2>
|
|
<ul class="manga-grid" data-testid="collection-manga-list">
|
|
{#each mangas as m (m.id)}
|
|
<li class="card-with-remove">
|
|
<MangaCard manga={m} testid={`collection-manga-${m.id}`} />
|
|
<button
|
|
type="button"
|
|
class="remove"
|
|
onclick={() => onRemoveManga(m)}
|
|
aria-label={`Remove ${m.title} from collection`}
|
|
title="Remove from collection"
|
|
data-testid={`collection-remove-manga-${m.id}`}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
|
|
{#if pages.length > 0}
|
|
<section aria-labelledby="pages-heading">
|
|
<h2 id="pages-heading" class="section-heading">Pages</h2>
|
|
<ul class="page-grid" data-testid="collection-page-list">
|
|
{#each pages as p (p.page_id)}
|
|
<li class="card-with-remove">
|
|
<a
|
|
class="page-card"
|
|
href={`/manga/${p.manga_id}/chapter/${p.chapter_id}?page=${p.page_number}`}
|
|
data-testid={`collection-page-${p.page_id}`}
|
|
>
|
|
<img
|
|
src={fileUrl(p.storage_key)}
|
|
alt={`${p.manga_title} chapter ${p.chapter_number} page ${p.page_number}`}
|
|
class="page-thumb"
|
|
loading="lazy"
|
|
/>
|
|
<span class="page-meta">
|
|
<span class="page-title">{p.manga_title}</span>
|
|
<span class="page-breadcrumb">
|
|
Ch. {p.chapter_number} · page {p.page_number}
|
|
</span>
|
|
</span>
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="remove"
|
|
onclick={() => onRemovePage(p)}
|
|
aria-label={`Remove ${p.manga_title} page ${p.page_number} from collection`}
|
|
title="Remove from collection"
|
|
data-testid={`collection-remove-page-${p.page_id}`}
|
|
>
|
|
<X size={14} aria-hidden="true" />
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/if}
|
|
|
|
<style>
|
|
.back {
|
|
margin-bottom: var(--space-3);
|
|
}
|
|
|
|
.back-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-1);
|
|
color: var(--text-muted);
|
|
font-size: var(--font-sm);
|
|
}
|
|
|
|
.overview {
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
|
|
.title-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.title-row h1 {
|
|
margin: 0;
|
|
flex: 1;
|
|
}
|
|
|
|
.description {
|
|
color: var(--text-muted);
|
|
margin: var(--space-2) 0 0;
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.edit-form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.edit-actions {
|
|
display: flex;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
.primary {
|
|
background: var(--primary);
|
|
color: var(--primary-contrast);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.primary:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
border-color: var(--primary-hover);
|
|
}
|
|
|
|
.error {
|
|
color: var(--danger);
|
|
margin: var(--space-2) 0 0;
|
|
}
|
|
|
|
.status {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
/* `.manga-grid` is a shared global (see lib/styles/tokens.css). */
|
|
|
|
.section-heading {
|
|
margin: var(--space-5) 0 var(--space-3);
|
|
font-size: var(--font-lg);
|
|
}
|
|
|
|
.section-heading:first-of-type {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.page-grid {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
gap: var(--space-3);
|
|
}
|
|
|
|
.page-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-1);
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.page-card:hover {
|
|
border-color: var(--primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.page-thumb {
|
|
width: 100%;
|
|
aspect-ratio: 2 / 3;
|
|
object-fit: cover;
|
|
background: var(--surface-elevated);
|
|
}
|
|
|
|
.page-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
padding: var(--space-2);
|
|
}
|
|
|
|
.page-title {
|
|
font-weight: var(--weight-medium);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.page-breadcrumb {
|
|
color: var(--text-muted);
|
|
font-size: var(--font-xs);
|
|
}
|
|
|
|
.card-with-remove {
|
|
position: relative;
|
|
list-style: none;
|
|
}
|
|
|
|
.remove {
|
|
position: absolute;
|
|
top: var(--space-1);
|
|
right: var(--space-1);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 24px;
|
|
height: 24px;
|
|
padding: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
color: white;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
opacity: 0;
|
|
transition: opacity var(--transition);
|
|
}
|
|
|
|
.card-with-remove:hover .remove,
|
|
.card-with-remove:focus-within .remove {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Touch has no hover, so the reveal-on-hover treatment leaves the
|
|
remove buttons invisible and undiscoverable. Show them persistently
|
|
and enlarge the hit area on phones. (Capped well below 44px because
|
|
the button floats over a 4-up cover thumbnail — a full touch-floor
|
|
square would swallow the cover.) */
|
|
@media (max-width: 640px) {
|
|
.remove {
|
|
opacity: 1;
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
}
|
|
</style>
|