Files
Mangalord/frontend/src/lib/components/AddToCollectionModal.svelte
MechaCat02 92db3ce324
All checks were successful
deploy / test-backend (pull_request) Successful in 28m26s
deploy / test-frontend (pull_request) Successful in 10m22s
deploy / build-and-push (pull_request) Has been skipped
deploy / deploy (pull_request) Has been skipped
feat(ui): present page-action editors as sheets on mobile
The Add-tags and Add-to-collection editors opened as centered Modals on
every form factor, so a mobile user went action-Sheet → centered Modal — an
inconsistent hop, and AddTagsSheet's docstring promised a sheet it never
got. Introduce AdaptiveDialog, which renders a bottom Sheet under 640px and
a centered Modal above it behind one shared contract (open / title / onClose
/ children / testid). Route both editors through it:

- AddToCollectionModal now wraps AdaptiveDialog (both its call sites — manga
  detail and the reader — adapt automatically).
- The reader hosts AddTagsSheet in an AdaptiveDialog instead of a Modal, so
  its "slots into a Sheet (mobile) or Modal (desktop)" contract is now true.

Modal/Sheet already share an API, so the switch is transparent: the dialog
keeps the same role, title, testid, and close affordance. Desktop is
unchanged.

AdaptiveDialog has its own unit test (Sheet on mobile, Modal on desktop);
an e2e proves the mobile long-press → Add-tag flow now opens a Sheet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:21:10 +02:00

325 lines
9.9 KiB
Svelte

<script lang="ts">
import AdaptiveDialog from './AdaptiveDialog.svelte';
import {
addMangaToCollection,
createCollection,
listMyCollections,
getMyCollectionsContaining,
removeMangaFromCollection,
type CollectionSummary
} from '$lib/api/collections';
import {
addPageToCollection,
getMyCollectionsContainingPage,
removePageFromCollection
} from '$lib/api/page_collections';
import Plus from '@lucide/svelte/icons/plus';
/**
* Discriminated-union target so the same modal serves both the
* manga-detail "Add to collection" flow and the reader's page
* context menu. The list / pre-check / toggle / create-and-add
* scaffolding is identical for both — only the leaf API call
* differs, dispatched through `loadContaining` / `addTo` /
* `removeFrom` below.
*/
export type Target =
| { kind: 'manga'; id: string }
| { kind: 'page'; id: string };
let {
open,
target,
onClose
}: {
open: boolean;
target: Target;
onClose: () => void;
} = $props();
let collections = $state<CollectionSummary[]>([]);
let containingIds = $state<Set<string>>(new Set());
let busyIds = $state<Set<string>>(new Set());
let newName = $state('');
let creating = $state(false);
let loading = $state(false);
let error: string | null = $state(null);
// Refetch on open and whenever the target changes (e.g., the user
// right-clicks a different page while the modal is in flight). The
// dependency on `target.id` is explicit so the $effect re-runs.
$effect(() => {
if (open) {
// Touching target.id wires the reactive dependency.
void target.id;
void load();
}
});
async function loadContaining(t: Target): Promise<string[]> {
return t.kind === 'manga'
? getMyCollectionsContaining(t.id)
: getMyCollectionsContainingPage(t.id);
}
async function addTo(collectionId: string, t: Target): Promise<void> {
return t.kind === 'manga'
? addMangaToCollection(collectionId, t.id)
: addPageToCollection(collectionId, t.id);
}
async function removeFrom(collectionId: string, t: Target): Promise<void> {
return t.kind === 'manga'
? removeMangaFromCollection(collectionId, t.id)
: removePageFromCollection(collectionId, t.id);
}
async function load() {
loading = true;
error = null;
try {
const [page, ids] = await Promise.all([
listMyCollections({ limit: 200 }),
loadContaining(target)
]);
collections = page.items;
containingIds = new Set(ids);
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
function withAdd<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.add(v);
return n;
}
function withDelete<T>(s: Set<T>, v: T): Set<T> {
const n = new Set(s);
n.delete(v);
return n;
}
async function toggle(collection: CollectionSummary) {
if (busyIds.has(collection.id)) return;
const wasIn = containingIds.has(collection.id);
containingIds = wasIn
? withDelete(containingIds, collection.id)
: withAdd(containingIds, collection.id);
busyIds = withAdd(busyIds, collection.id);
try {
if (wasIn) {
await removeFrom(collection.id, target);
// manga_count drifts when pages are toggled — the
// backend doesn't track page_count yet, so we only
// adjust the count for the manga path. Page targets
// leave manga_count as-is.
if (target.kind === 'manga') {
collection.manga_count = Math.max(0, collection.manga_count - 1);
}
} else {
await addTo(collection.id, target);
if (target.kind === 'manga') {
collection.manga_count += 1;
}
}
} catch (e) {
containingIds = wasIn
? withAdd(containingIds, collection.id)
: withDelete(containingIds, collection.id);
error = (e as Error).message;
} finally {
busyIds = withDelete(busyIds, collection.id);
}
}
async function createAndAdd() {
const name = newName.trim();
if (!name || creating) return;
creating = true;
error = null;
try {
const created = await createCollection({ name });
await addTo(created.id, target);
collections = [
{
...created,
manga_count: target.kind === 'manga' ? 1 : 0,
sample_covers: []
},
...collections
];
containingIds = new Set([...containingIds, created.id]);
newName = '';
} catch (e) {
error = (e as Error).message;
} finally {
creating = false;
}
}
function onCreateSubmit(e: SubmitEvent) {
e.preventDefault();
void createAndAdd();
}
</script>
<AdaptiveDialog {open} {onClose} title="Add to collection" size="md" testid="add-to-collection-modal">
{#if loading}
<p class="status">Loading your collections…</p>
{:else if error}
<p class="error" role="alert" data-testid="add-to-collection-error">{error}</p>
{:else if collections.length === 0}
<p class="status" data-testid="no-collections">
You don't have any collections yet. Create one below to get started.
</p>
{:else}
<ul class="collection-list">
{#each collections as c (c.id)}
{@const checked = containingIds.has(c.id)}
{@const busy = busyIds.has(c.id)}
<li>
<label class="row" class:checked>
<input
type="checkbox"
{checked}
disabled={busy}
onchange={() => toggle(c)}
data-testid={`collection-toggle-${c.id}`}
/>
<span class="row-label">
<span class="row-name">{c.name}</span>
{#if target.kind === 'manga'}
<!-- The count is the manga count only.
Suppress it on page targets to
avoid the rot of showing "0
mangas" beside a collection that
has 12 saved pages — the user
would read the modal as broken.
A backend page_count is a
follow-up. -->
<span class="row-count">
{c.manga_count}
{c.manga_count === 1 ? 'manga' : 'mangas'}
</span>
{/if}
</span>
</label>
</li>
{/each}
</ul>
{/if}
<form
class="create-form"
onsubmit={onCreateSubmit}
action="javascript:void(0)"
>
<input
type="text"
bind:value={newName}
maxlength="64"
placeholder="Create new collection"
aria-label="New collection name"
data-testid="new-collection-name"
/>
<button
type="submit"
class="create-btn"
disabled={!newName.trim() || creating}
data-testid="create-collection-btn"
>
<Plus size={14} aria-hidden="true" />
<span>{creating ? 'Creating…' : 'Create + add'}</span>
</button>
</form>
</AdaptiveDialog>
<style>
.status {
color: var(--text-muted);
}
.error {
color: var(--danger);
margin: 0 0 var(--space-2);
}
.collection-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-1);
max-height: 16rem;
overflow-y: auto;
}
.row {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition);
}
.row:hover {
background: var(--surface-elevated);
}
.row.checked {
background: var(--primary-soft-bg);
}
.row-label {
display: flex;
flex-direction: column;
min-width: 0;
}
.row-name {
color: var(--text);
font-weight: var(--weight-medium);
}
.row-count {
color: var(--text-muted);
font-size: var(--font-xs);
}
.create-form {
display: flex;
gap: var(--space-2);
align-items: center;
padding-top: var(--space-3);
border-top: 1px solid var(--border);
}
.create-form input {
flex: 1;
min-width: 0;
}
.create-btn {
display: inline-flex;
align-items: center;
gap: var(--space-1);
background: var(--primary);
color: var(--primary-contrast);
border: 1px solid var(--primary);
padding: 0 var(--space-3);
height: 36px;
white-space: nowrap;
}
.create-btn:hover:not(:disabled) {
background: var(--primary-hover);
border-color: var(--primary-hover);
}
</style>