Files
FictionArchive/fictionarchive-web-astro/src/lib/components/AddToReadingListButton.svelte
gamer147 48ee43c4f6
All checks were successful
CI / build-backend (pull_request) Successful in 1m32s
CI / build-frontend (pull_request) Successful in 42s
[FA-24] Reading lists
2026-01-19 22:06:34 -05:00

369 lines
10 KiB
Svelte

<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Input } from '$lib/components/ui/input';
import { client } from '$lib/graphql/client';
import {
GetReadingListsWithItemsDocument,
AddToReadingListDocument,
RemoveFromReadingListDocument,
CreateReadingListDocument,
type GetReadingListsWithItemsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated } from '$lib/auth/authStore';
import ListPlus from '@lucide/svelte/icons/list-plus';
import Plus from '@lucide/svelte/icons/plus';
import Check from '@lucide/svelte/icons/check';
import Loader2 from '@lucide/svelte/icons/loader-2';
interface Props {
novelId: number;
size?: 'default' | 'sm' | 'icon';
}
let { novelId, size = 'default' }: Props = $props();
type ReadingList = GetReadingListsWithItemsQuery['readingLists'][0];
// State
let popoverOpen = $state(false);
let readingLists: ReadingList[] = $state([]);
let fetching = $state(false);
let error: string | null = $state(null);
// Track which lists the novel is in (by list ID)
let novelInLists = new SvelteSet<number>();
// Track loading state for individual list toggles
let loadingListIds = new SvelteSet<number>();
// Quick-create state
let showQuickCreate = $state(false);
let newListName = $state('');
let creatingList = $state(false);
let createError: string | null = $state(null);
// Fetch reading lists when popover opens
$effect(() => {
if (popoverOpen && $isAuthenticated) {
fetchReadingLists();
}
});
// Reset quick-create form when popover closes
$effect(() => {
if (!popoverOpen) {
showQuickCreate = false;
newListName = '';
createError = null;
}
});
async function fetchReadingLists() {
fetching = true;
error = null;
try {
const result = await client.query(GetReadingListsWithItemsDocument, {}).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data) {
readingLists = result.data.readingLists;
// Build the set of list IDs that contain this novel
const inLists = new SvelteSet<number>();
for (const list of readingLists) {
if (list.items.some((item) => item.novelId === novelId)) {
inLists.add(list.id);
}
}
novelInLists = inLists;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reading lists';
} finally {
fetching = false;
}
}
async function toggleNovelInList(listId: number) {
const isInList = novelInLists.has(listId);
loadingListIds = new SvelteSet([...loadingListIds, listId]);
try {
if (isInList) {
// Remove from list
const result = await client
.mutation(RemoveFromReadingListDocument, {
input: { listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeFromReadingList?.errors?.length) {
error = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove from list';
return;
}
if (result.data?.removeFromReadingList?.readingListPayload?.success) {
// Update local state
novelInLists = new SvelteSet([...novelInLists].filter((id) => id !== listId));
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? { ...list, itemCount: list.itemCount - 1, items: list.items.filter((i) => i.novelId !== novelId) }
: list
);
}
} else {
// Add to list
const result = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.addToReadingList?.errors?.length) {
error = result.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
if (result.data?.addToReadingList?.readingListPayload?.success) {
// Update local state
novelInLists = new SvelteSet([...novelInLists, listId]);
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? {
...list,
itemCount: list.itemCount + 1,
items: [...list.items, { novelId, order: list.itemCount, addedTime: new Date().toISOString() }]
}
: list
);
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
loadingListIds = new SvelteSet([...loadingListIds].filter((id) => id !== listId));
}
}
async function createListAndAdd() {
if (!newListName.trim()) {
createError = 'Name is required';
return;
}
creatingList = true;
createError = null;
try {
const result = await client
.mutation(CreateReadingListDocument, {
input: {
name: newListName.trim(),
description: null
}
})
.toPromise();
if (result.error) {
createError = result.error.message;
return;
}
if (result.data?.createReadingList?.errors?.length) {
createError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create list';
return;
}
const newList = result.data?.createReadingList?.readingListPayload?.readingList;
if (newList) {
// Now add the novel to the new list
const addResult = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: newList.id, novelId }
})
.toPromise();
if (addResult.error) {
createError = addResult.error.message;
return;
}
if (addResult.data?.addToReadingList?.errors?.length) {
createError = addResult.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
// Add the new list to our local state
const fullNewList: ReadingList = {
...newList,
itemCount: 1,
items: [{ novelId, order: 0, addedTime: new Date().toISOString() }]
};
readingLists = [...readingLists, fullNewList];
novelInLists = new SvelteSet([...novelInLists, newList.id]);
// Reset quick-create form
showQuickCreate = false;
newListName = '';
}
} catch (e) {
createError = e instanceof Error ? e.message : 'An error occurred';
} finally {
creatingList = false;
}
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
createListAndAdd();
}
}
// Compute if novel is in any list for button state
let isInAnyList = $derived(novelInLists.size > 0);
</script>
{#if $isAuthenticated}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isInAnyList ? 'default' : 'outline'}
{size}
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
{...props}
>
<ListPlus class="h-4 w-4" />
{#if size !== 'icon'}
<span>{isInAnyList ? 'In Lists' : 'Add to List'}</span>
{/if}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Add to Reading List</h4>
<p class="text-sm text-muted-foreground">
Select lists to add or remove this novel.
</p>
</div>
{#if fetching}
<div class="flex items-center justify-center py-4">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if error}
<p class="text-sm text-destructive">{error}</p>
{:else if readingLists.length === 0 && !showQuickCreate}
<div class="text-center py-4">
<p class="text-sm text-muted-foreground mb-3">No reading lists yet</p>
<Button size="sm" variant="outline" onclick={() => (showQuickCreate = true)}>
<Plus class="h-4 w-4 mr-1" />
Create your first list
</Button>
</div>
{:else}
<!-- Reading lists -->
<div class="space-y-1 max-h-[200px] overflow-y-auto">
{#each readingLists as list (list.id)}
{@const isInList = novelInLists.has(list.id)}
{@const isLoading = loadingListIds.has(list.id)}
<button
type="button"
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left hover:bg-accent transition-colors disabled:opacity-50"
onclick={() => toggleNovelInList(list.id)}
disabled={isLoading}
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border {isInList
? 'bg-primary border-primary'
: 'border-input'}"
>
{#if isLoading}
<Loader2 class="h-3 w-3 animate-spin text-primary-foreground" />
{:else if isInList}
<Check class="h-3 w-3 text-primary-foreground" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{list.name}</div>
<div class="text-xs text-muted-foreground">
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
</div>
</div>
</button>
{/each}
</div>
<!-- Quick-create section -->
{#if showQuickCreate}
<div class="border-t pt-3 space-y-2">
<div class="flex gap-2">
<Input
type="text"
placeholder="New list name"
bind:value={newListName}
disabled={creatingList}
onkeydown={handleKeyDown}
class="flex-1"
/>
<Button size="sm" onclick={createListAndAdd} disabled={creatingList || !newListName.trim()}>
{#if creatingList}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
Add
{/if}
</Button>
</div>
{#if createError}
<p class="text-xs text-destructive">{createError}</p>
{/if}
</div>
{:else}
<div class="border-t pt-3">
<Button
size="sm"
variant="ghost"
class="w-full justify-start"
onclick={() => (showQuickCreate = true)}
>
<Plus class="h-4 w-4 mr-2" />
Create new list
</Button>
</div>
{/if}
{/if}
</div>
</PopoverContent>
</Popover>
</div>
{/if}