The remove buttons on a collection's cards were revealed only on hover / focus-within (opacity 0 → 1), so on touch they were invisible and undiscoverable. Show them persistently at the 640px breakpoint and enlarge the hit area from 24px to 32px (kept below the full 44px floor because the button floats over a 4-up cover thumbnail). Test pins the CSS contract (jsdom can't evaluate @media): the mobile block makes `.remove` opaque. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
444 lines
13 KiB
Svelte
444 lines
13 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 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>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
onclick={startEdit}
|
|
aria-label="Edit collection"
|
|
title="Edit"
|
|
data-testid="collection-edit-open"
|
|
>
|
|
<Pencil size={16} aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn danger"
|
|
onclick={onDeleteCollection}
|
|
aria-label="Delete collection"
|
|
title="Delete"
|
|
data-testid="collection-delete"
|
|
>
|
|
<Trash2 size={16} aria-hidden="true" />
|
|
</button>
|
|
</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;
|
|
}
|
|
}
|
|
|
|
.icon-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
background: transparent;
|
|
color: var(--text-muted);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
|
|
.icon-btn:hover {
|
|
background: var(--surface-elevated);
|
|
color: var(--text);
|
|
}
|
|
|
|
.icon-btn.danger:hover {
|
|
color: var(--danger);
|
|
}
|
|
</style>
|