369 lines
10 KiB
Svelte
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}
|