[FA-24] Reading lists
This commit is contained in:
@@ -0,0 +1,368 @@
|
||||
<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}
|
||||
Reference in New Issue
Block a user