[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}
|
||||
@@ -19,11 +19,10 @@
|
||||
description="Explore and read archived novels."
|
||||
/>
|
||||
<NavigationCard
|
||||
href="/lists"
|
||||
href="/reading-lists"
|
||||
icon={List}
|
||||
title="Reading Lists"
|
||||
description="Organize stories into custom collections."
|
||||
disabled
|
||||
/>
|
||||
<NavigationCard
|
||||
href="/recommendations"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import * as NavigationMenu from '$lib/components/ui/navigation-menu';
|
||||
import AuthenticationDisplay from './AuthenticationDisplay.svelte';
|
||||
import SearchBar from './SearchBar.svelte';
|
||||
import { isAuthenticated } from '$lib/auth/authStore';
|
||||
|
||||
let pathname = $state(typeof window !== 'undefined' ? window.location.pathname : '/');
|
||||
|
||||
@@ -24,6 +25,11 @@
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link href="/novels" active={isActive('/novels')}>Novels</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{#if $isAuthenticated}
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Link href="/reading-lists" active={isActive('/reading-lists')}>Reading Lists</NavigationMenu.Link>
|
||||
</NavigationMenu.Item>
|
||||
{/if}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
<div class="flex-1"></div>
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
|
||||
import { sanitizeHtml } from '$lib/utils/sanitize';
|
||||
import ChapterBookmarkButton from './ChapterBookmarkButton.svelte';
|
||||
import AddToReadingListButton from './AddToReadingListButton.svelte';
|
||||
// Direct imports for faster builds
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
@@ -491,6 +492,7 @@
|
||||
<Trash2 class="h-3 w-3" />
|
||||
Delete
|
||||
</Button>
|
||||
<AddToReadingListButton novelId={novel.id} />
|
||||
{/if}
|
||||
{#if refreshSuccess}
|
||||
<Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30">
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { onMount } from 'svelte';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import {
|
||||
GetReadingListDocument,
|
||||
NovelsDocument,
|
||||
RemoveFromReadingListDocument,
|
||||
ReorderReadingListItemDocument,
|
||||
type GetReadingListQuery,
|
||||
type NovelsQuery
|
||||
} from '$lib/graphql/__generated__/graphql';
|
||||
import { isAuthenticated, login } from '$lib/auth/authStore';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import ArrowUp from '@lucide/svelte/icons/arrow-up';
|
||||
import ArrowDown from '@lucide/svelte/icons/arrow-down';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
|
||||
interface Props {
|
||||
listId: string;
|
||||
}
|
||||
|
||||
let { listId }: Props = $props();
|
||||
|
||||
type ReadingList = NonNullable<GetReadingListQuery['readingList']>;
|
||||
type ReadingListItem = ReadingList['items'][number];
|
||||
type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
|
||||
|
||||
// State
|
||||
let readingList: ReadingList | null = $state(null);
|
||||
let novels = new SvelteMap<number, NovelNode>();
|
||||
let fetching = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Operation state
|
||||
let reordering = $state(false);
|
||||
let removing: number | null = $state(null);
|
||||
let operationError: string | null = $state(null);
|
||||
|
||||
// Derived: sorted items by order
|
||||
const sortedItems = $derived(
|
||||
readingList?.items ? [...readingList.items].sort((a, b) => a.order - b.order) : []
|
||||
);
|
||||
|
||||
async function fetchReadingList() {
|
||||
fetching = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const id = parseInt(listId, 10);
|
||||
if (isNaN(id)) {
|
||||
error = 'Invalid reading list ID';
|
||||
fetching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await client.query(GetReadingListDocument, { id }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data?.readingList) {
|
||||
error = 'Reading list not found';
|
||||
return;
|
||||
}
|
||||
|
||||
readingList = result.data.readingList;
|
||||
|
||||
// Fetch novel details for all items
|
||||
if (readingList.items.length > 0) {
|
||||
await fetchNovels(readingList.items.map((item) => item.novelId));
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNovels(novelIds: number[]) {
|
||||
if (novelIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.query(NovelsDocument, {
|
||||
first: novelIds.length,
|
||||
where: { id: { in: novelIds } }
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.data?.novels?.edges) {
|
||||
for (const edge of result.data.novels.edges) {
|
||||
novels.set(edge.node.id, edge.node);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical: novels just won't show extra details
|
||||
}
|
||||
}
|
||||
|
||||
async function moveItem(item: ReadingListItem, direction: 'up' | 'down') {
|
||||
if (!readingList || reordering) return;
|
||||
|
||||
const currentIndex = sortedItems.findIndex((i) => i.novelId === item.novelId);
|
||||
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= sortedItems.length) return;
|
||||
|
||||
const targetItem = sortedItems[targetIndex];
|
||||
const newOrder = targetItem.order;
|
||||
|
||||
reordering = true;
|
||||
operationError = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(ReorderReadingListItemDocument, {
|
||||
input: {
|
||||
readingListId: readingList.id,
|
||||
novelId: item.novelId,
|
||||
newOrder
|
||||
}
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
operationError = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.reorderReadingListItem?.errors?.length) {
|
||||
operationError = result.data.reorderReadingListItem.errors[0]?.message ?? 'Failed to reorder';
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the list to get updated order
|
||||
await fetchReadingList();
|
||||
} catch (e) {
|
||||
operationError = e instanceof Error ? e.message : 'Failed to reorder';
|
||||
} finally {
|
||||
reordering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(novelId: number) {
|
||||
if (!readingList || removing !== null) return;
|
||||
|
||||
removing = novelId;
|
||||
operationError = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(RemoveFromReadingListDocument, {
|
||||
input: {
|
||||
listId: readingList.id,
|
||||
novelId
|
||||
}
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
operationError = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.removeFromReadingList?.errors?.length) {
|
||||
operationError = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local state
|
||||
if (readingList) {
|
||||
readingList = {
|
||||
...readingList,
|
||||
items: readingList.items.filter((item) => item.novelId !== novelId),
|
||||
itemCount: readingList.itemCount - 1
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
operationError = e instanceof Error ? e.message : 'Failed to remove';
|
||||
} finally {
|
||||
removing = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($isAuthenticated) {
|
||||
fetchReadingList();
|
||||
} else {
|
||||
fetching = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fetch when auth changes
|
||||
$effect(() => {
|
||||
if ($isAuthenticated) {
|
||||
fetchReadingList();
|
||||
} else {
|
||||
readingList = null;
|
||||
novels.clear();
|
||||
fetching = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Back Navigation -->
|
||||
<Button variant="ghost" href="/reading-lists" class="gap-2 -ml-2">
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
Back to Reading Lists
|
||||
</Button>
|
||||
|
||||
{#if !$isAuthenticated}
|
||||
<!-- Auth gate - sign in prompt -->
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<div class="text-center space-y-4">
|
||||
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Sign in to view Reading Lists</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Sign in to view and manage your reading lists.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={login}>
|
||||
<LogIn class="mr-2 h-4 w-4" />
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if fetching}
|
||||
<!-- Loading state -->
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div
|
||||
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
|
||||
aria-label="Loading reading list"
|
||||
></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<Card class="border-destructive/40 bg-destructive/5">
|
||||
<CardContent class="py-8">
|
||||
<div class="text-center">
|
||||
<p class="text-destructive text-lg font-medium">
|
||||
{error === 'Reading list not found' ? 'Reading List Not Found' : 'Error Loading Reading List'}
|
||||
</p>
|
||||
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
|
||||
<Button variant="outline" onclick={fetchReadingList} class="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if readingList}
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">{readingList.name}</CardTitle>
|
||||
{#if readingList.description}
|
||||
<CardDescription class="text-base">{readingList.description}</CardDescription>
|
||||
{/if}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{readingList.itemCount} {readingList.itemCount === 1 ? 'novel' : 'novels'}
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<!-- Operation error -->
|
||||
{#if operationError}
|
||||
<Card class="border-destructive/40 bg-destructive/5">
|
||||
<CardContent class="py-4">
|
||||
<p class="text-destructive text-sm">{operationError}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Novels list -->
|
||||
{#if sortedItems.length === 0}
|
||||
<!-- Empty state -->
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<div class="text-center space-y-4">
|
||||
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">No novels in this list</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Add novels to this reading list from a novel's detail page.
|
||||
</p>
|
||||
</div>
|
||||
<Button href="/novels" variant="outline">
|
||||
Browse Novels
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="py-4">
|
||||
<div class="space-y-2">
|
||||
{#each sortedItems as item, index (item.novelId)}
|
||||
{@const novel = novels.get(item.novelId)}
|
||||
<div
|
||||
class="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 transition-colors {removing === item.novelId || reordering ? 'opacity-50' : ''}"
|
||||
>
|
||||
<!-- Cover image -->
|
||||
<a href={`/novels/${item.novelId}`} class="shrink-0">
|
||||
{#if novel?.coverImage?.newPath}
|
||||
<div class="w-16 h-20 overflow-hidden rounded-md bg-muted/50">
|
||||
<img
|
||||
src={novel.coverImage.newPath}
|
||||
alt={novel?.name ?? 'Novel cover'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-16 h-20 rounded-md bg-muted/50 flex items-center justify-center">
|
||||
<BookOpen class="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<!-- Novel info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<a
|
||||
href={`/novels/${item.novelId}`}
|
||||
class="font-medium hover:text-primary transition-colors line-clamp-1"
|
||||
>
|
||||
{novel?.name ?? `Novel #${item.novelId}`}
|
||||
</a>
|
||||
{#if novel?.description}
|
||||
<p class="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||
{novel.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<!-- Move up -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={index === 0 || reordering || removing !== null}
|
||||
onclick={() => moveItem(item, 'up')}
|
||||
>
|
||||
<ArrowUp class="h-4 w-4" />
|
||||
<span class="sr-only">Move up</span>
|
||||
</Button>
|
||||
|
||||
<!-- Move down -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
disabled={index === sortedItems.length - 1 || reordering || removing !== null}
|
||||
onclick={() => moveItem(item, 'down')}
|
||||
>
|
||||
<ArrowDown class="h-4 w-4" />
|
||||
<span class="sr-only">Move down</span>
|
||||
</Button>
|
||||
|
||||
<!-- Remove -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
disabled={reordering || removing !== null}
|
||||
onclick={() => removeItem(item.novelId)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
<span class="sr-only">Remove from list</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,432 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Dialog as DialogPrimitive } from 'bits-ui';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import {
|
||||
GetReadingListsDocument,
|
||||
CreateReadingListDocument,
|
||||
UpdateReadingListDocument,
|
||||
DeleteReadingListDocument,
|
||||
type GetReadingListsQuery
|
||||
} from '$lib/graphql/__generated__/graphql';
|
||||
import { isAuthenticated, login } from '$lib/auth/authStore';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '$lib/components/ui/card';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Pencil from '@lucide/svelte/icons/pencil';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
|
||||
type ReadingList = GetReadingListsQuery['readingLists'][0];
|
||||
|
||||
// State
|
||||
let readingLists: ReadingList[] = $state([]);
|
||||
let fetching = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Dialog state
|
||||
let dialogOpen = $state(false);
|
||||
let dialogMode: 'create' | 'edit' = $state('create');
|
||||
let editingList: ReadingList | null = $state(null);
|
||||
|
||||
// Form state
|
||||
let formName = $state('');
|
||||
let formDescription = $state('');
|
||||
let formSubmitting = $state(false);
|
||||
let formError: string | null = $state(null);
|
||||
|
||||
// Delete confirmation state
|
||||
let deleteDialogOpen = $state(false);
|
||||
let deletingList: ReadingList | null = $state(null);
|
||||
let deleteSubmitting = $state(false);
|
||||
let deleteError: string | null = $state(null);
|
||||
|
||||
// Reset form when dialog opens/closes
|
||||
$effect(() => {
|
||||
if (dialogOpen) {
|
||||
if (dialogMode === 'edit' && editingList) {
|
||||
formName = editingList.name;
|
||||
formDescription = editingList.description ?? '';
|
||||
} else {
|
||||
formName = '';
|
||||
formDescription = '';
|
||||
}
|
||||
formError = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchReadingLists(skipCache = false) {
|
||||
fetching = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await client.query(GetReadingListsDocument, {}, { requestPolicy: skipCache ? 'network-only' : 'cache-first' }).toPromise();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data) {
|
||||
readingLists = result.data.readingLists;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode = 'create';
|
||||
editingList = null;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openEditDialog(list: ReadingList) {
|
||||
dialogMode = 'edit';
|
||||
editingList = list;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function openDeleteDialog(list: ReadingList) {
|
||||
deletingList = list;
|
||||
deleteError = null;
|
||||
deleteDialogOpen = true;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formName.trim()) {
|
||||
formError = 'Name is required';
|
||||
return;
|
||||
}
|
||||
|
||||
formSubmitting = true;
|
||||
formError = null;
|
||||
|
||||
try {
|
||||
if (dialogMode === 'create') {
|
||||
const result = await client
|
||||
.mutation(CreateReadingListDocument, {
|
||||
input: {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null
|
||||
}
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
formError = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.createReadingList?.errors?.length) {
|
||||
formError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create reading list';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.createReadingList?.readingListPayload?.readingList) {
|
||||
dialogOpen = false;
|
||||
await fetchReadingLists(true);
|
||||
}
|
||||
} else if (dialogMode === 'edit' && editingList) {
|
||||
const result = await client
|
||||
.mutation(UpdateReadingListDocument, {
|
||||
input: {
|
||||
id: editingList.id,
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null
|
||||
}
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
formError = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.updateReadingList?.errors?.length) {
|
||||
formError = result.data.updateReadingList.errors[0]?.message ?? 'Failed to update reading list';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.updateReadingList?.readingListPayload?.readingList) {
|
||||
dialogOpen = false;
|
||||
await fetchReadingLists(true);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
formError = e instanceof Error ? e.message : 'An error occurred';
|
||||
} finally {
|
||||
formSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deletingList) return;
|
||||
|
||||
deleteSubmitting = true;
|
||||
deleteError = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(DeleteReadingListDocument, {
|
||||
input: { id: deletingList.id }
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
deleteError = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.deleteReadingList?.errors?.length) {
|
||||
deleteError = result.data.deleteReadingList.errors[0]?.message ?? 'Failed to delete reading list';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.deleteReadingList?.success) {
|
||||
deleteDialogOpen = false;
|
||||
deletingList = null;
|
||||
await fetchReadingLists(true);
|
||||
}
|
||||
} catch (e) {
|
||||
deleteError = e instanceof Error ? e.message : 'An error occurred';
|
||||
} finally {
|
||||
deleteSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if ($isAuthenticated) {
|
||||
fetchReadingLists();
|
||||
} else {
|
||||
fetching = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-fetch when auth changes
|
||||
$effect(() => {
|
||||
if ($isAuthenticated) {
|
||||
fetchReadingLists();
|
||||
} else {
|
||||
readingLists = [];
|
||||
fetching = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Reading Lists</h1>
|
||||
<p class="text-muted-foreground">Organize your novels into collections</p>
|
||||
</div>
|
||||
{#if $isAuthenticated}
|
||||
<Button onclick={openCreateDialog}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
New List
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !$isAuthenticated}
|
||||
<!-- Auth gate - sign in prompt -->
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<div class="text-center space-y-4">
|
||||
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Sign in to use Reading Lists</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Create and manage your personal reading lists to organize novels.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={login}>
|
||||
<LogIn class="mr-2 h-4 w-4" />
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if fetching}
|
||||
<!-- Loading state -->
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="text-muted-foreground">Loading your reading lists...</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<Card>
|
||||
<CardContent class="py-6">
|
||||
<p class="text-destructive">{error}</p>
|
||||
<Button class="mt-4" onclick={fetchReadingLists}>Try Again</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if readingLists.length === 0}
|
||||
<!-- Empty state -->
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<div class="text-center space-y-4">
|
||||
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">No reading lists yet</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Create your first reading list to start organizing your novels.
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={openCreateDialog}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Create Your First List
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else}
|
||||
<!-- Reading lists grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each readingLists as list (list.id)}
|
||||
<a href={`/reading-lists/${list.id}`} class="block group">
|
||||
<Card class="h-full transition-colors hover:border-primary/50">
|
||||
<CardHeader class="pb-2">
|
||||
<div class="flex items-start justify-between">
|
||||
<CardTitle class="line-clamp-1">{list.name}</CardTitle>
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openEditDialog(list);
|
||||
}}
|
||||
>
|
||||
<Pencil class="h-4 w-4" />
|
||||
<span class="sr-only">Edit</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onclick={(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openDeleteDialog(list);
|
||||
}}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
<span class="sr-only">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{#if list.description}
|
||||
<CardDescription class="line-clamp-2">{list.description}</CardDescription>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardFooter class="pt-2">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
|
||||
</span>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Dialog -->
|
||||
<DialogPrimitive.Root bind:open={dialogOpen}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
|
||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
|
||||
{dialogMode === 'create' ? 'Create Reading List' : 'Edit Reading List'}
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description class="text-sm text-muted-foreground">
|
||||
{dialogMode === 'create' ? 'Create a new reading list to organize your novels.' : 'Update your reading list details.'}
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="list-name" class="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
id="list-name"
|
||||
type="text"
|
||||
placeholder="My Reading List"
|
||||
bind:value={formName}
|
||||
disabled={formSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="list-description" class="text-sm font-medium">Description (optional)</label>
|
||||
<Textarea
|
||||
id="list-description"
|
||||
placeholder="A collection of..."
|
||||
bind:value={formDescription}
|
||||
disabled={formSubmitting}
|
||||
class="min-h-[80px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
{#if formError}
|
||||
<p class="text-sm text-destructive">{formError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" disabled={formSubmitting} onclick={() => dialogOpen = false}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={formSubmitting || !formName.trim()}>
|
||||
{#if formSubmitting}
|
||||
{dialogMode === 'create' ? 'Creating...' : 'Saving...'}
|
||||
{:else}
|
||||
{dialogMode === 'create' ? 'Create' : 'Save'}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<DialogPrimitive.Root bind:open={deleteDialogOpen}>
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
|
||||
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
|
||||
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
|
||||
Delete Reading List
|
||||
</DialogPrimitive.Title>
|
||||
<DialogPrimitive.Description class="text-sm text-muted-foreground">
|
||||
Are you sure you want to delete "{deletingList?.name}"? This action cannot be undone.
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
{#if deleteError}
|
||||
<p class="text-sm text-destructive">{deleteError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" type="button" disabled={deleteSubmitting} onclick={() => deleteDialogOpen = false}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={deleteSubmitting}>
|
||||
{deleteSubmitting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
</DialogPrimitive.Root>
|
||||
Reference in New Issue
Block a user