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>
325 lines
9.9 KiB
Svelte
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>
|