[FA-27] Bookmark implementation
This commit is contained in:
@@ -1,29 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import { UpsertBookmarkDocument, RemoveBookmarkDocument } from '$lib/graphql/__generated__/graphql';
|
||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import List from '@lucide/svelte/icons/list';
|
||||
import Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
|
||||
|
||||
interface Props {
|
||||
novelId: string;
|
||||
chapterId?: number;
|
||||
prevChapterVolumeOrder: number | null | undefined;
|
||||
prevChapterOrder: number | null | undefined;
|
||||
nextChapterVolumeOrder: number | null | undefined;
|
||||
nextChapterOrder: number | null | undefined;
|
||||
showKeyboardHints?: boolean;
|
||||
isBookmarked?: boolean;
|
||||
bookmarkDescription?: string | null;
|
||||
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
novelId,
|
||||
chapterId,
|
||||
prevChapterVolumeOrder,
|
||||
prevChapterOrder,
|
||||
nextChapterVolumeOrder,
|
||||
nextChapterOrder,
|
||||
showKeyboardHints = true
|
||||
showKeyboardHints = true,
|
||||
isBookmarked = false,
|
||||
bookmarkDescription = null,
|
||||
onBookmarkChange
|
||||
}: Props = $props();
|
||||
|
||||
const hasPrev = $derived(prevChapterOrder != null && prevChapterVolumeOrder != null);
|
||||
const hasNext = $derived(nextChapterOrder != null && nextChapterVolumeOrder != null);
|
||||
|
||||
// Bookmark state
|
||||
let popoverOpen = $state(false);
|
||||
let description = $state(bookmarkDescription ?? '');
|
||||
let saving = $state(false);
|
||||
let removing = $state(false);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Reset description when popover opens
|
||||
$effect(() => {
|
||||
if (popoverOpen) {
|
||||
description = bookmarkDescription ?? '';
|
||||
error = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function saveBookmark() {
|
||||
if (!chapterId) return;
|
||||
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(UpsertBookmarkDocument, {
|
||||
input: {
|
||||
chapterId,
|
||||
novelId: parseInt(novelId, 10),
|
||||
description: description.trim() || null
|
||||
}
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.upsertBookmark?.errors?.length) {
|
||||
error = result.data.upsertBookmark.errors[0]?.message ?? 'Failed to save bookmark';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.upsertBookmark?.bookmarkPayload?.success) {
|
||||
popoverOpen = false;
|
||||
onBookmarkChange?.(true, description.trim() || null);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to save bookmark';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeBookmark() {
|
||||
if (!chapterId) return;
|
||||
|
||||
removing = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(RemoveBookmarkDocument, {
|
||||
input: { chapterId }
|
||||
})
|
||||
.toPromise();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.removeBookmark?.errors?.length) {
|
||||
error = result.data.removeBookmark.errors[0]?.message ?? 'Failed to remove bookmark';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data?.removeBookmark?.bookmarkPayload?.success) {
|
||||
popoverOpen = false;
|
||||
description = '';
|
||||
onBookmarkChange?.(false, null);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to remove bookmark';
|
||||
} finally {
|
||||
removing = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -38,10 +140,72 @@
|
||||
<span class="hidden sm:inline">Previous</span>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" href="/novels/{novelId}" class="gap-2">
|
||||
<List class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Contents</span>
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" href="/novels/{novelId}" class="gap-2">
|
||||
<List class="h-4 w-4" />
|
||||
<span class="hidden sm:inline">Contents</span>
|
||||
</Button>
|
||||
|
||||
{#if chapterId}
|
||||
<Popover bind:open={popoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant={isBookmarked ? 'default' : 'outline'}
|
||||
class="gap-2"
|
||||
{...props}
|
||||
>
|
||||
{#if isBookmarked}
|
||||
<BookmarkCheck class="h-4 w-4" />
|
||||
{:else}
|
||||
<Bookmark class="h-4 w-4" />
|
||||
{/if}
|
||||
<span class="hidden sm:inline">{isBookmarked ? 'Bookmarked' : 'Bookmark'}</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-80">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium leading-none">
|
||||
{isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'}
|
||||
</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'}
|
||||
</p>
|
||||
</div>
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
placeholder="Add a note..."
|
||||
class="min-h-[80px] resize-none"
|
||||
/>
|
||||
{#if error}
|
||||
<p class="text-sm text-destructive">{error}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
{#if isBookmarked}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onclick={removeBookmark}
|
||||
disabled={removing || saving}
|
||||
>
|
||||
{removing ? 'Removing...' : 'Remove'}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={saveBookmark}
|
||||
disabled={saving || removing}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts" module>
|
||||
import type { GetChapterQuery } from '$lib/graphql/__generated__/graphql';
|
||||
import type { GetChapterQuery, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
|
||||
|
||||
export type ChapterData = NonNullable<GetChapterQuery['chapter']>;
|
||||
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import { GetChapterDocument } from '$lib/graphql/__generated__/graphql';
|
||||
import { GetChapterDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
|
||||
import { Card, CardContent } from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import ChapterNavigation from './ChapterNavigation.svelte';
|
||||
@@ -28,6 +29,10 @@
|
||||
let error: string | null = $state(null);
|
||||
let scrollProgress = $state(0);
|
||||
|
||||
// Bookmark state
|
||||
let isBookmarked = $state(false);
|
||||
let bookmarkDescription: string | null = $state(null);
|
||||
|
||||
// Derived values
|
||||
const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : '');
|
||||
|
||||
@@ -78,6 +83,8 @@
|
||||
chapter = result.data.chapter;
|
||||
// Update the page title with chapter info
|
||||
document.title = `${chapter.novelName} - ${chapter.order}`;
|
||||
// Fetch bookmark status
|
||||
await fetchBookmarks();
|
||||
} else {
|
||||
error = 'Chapter not found';
|
||||
}
|
||||
@@ -88,6 +95,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBookmarks() {
|
||||
if (!novelId || !chapter) return;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
|
||||
.toPromise();
|
||||
|
||||
if (result.data?.bookmarks) {
|
||||
const bookmark = result.data.bookmarks.find((b) => b.chapterId === chapter!.id);
|
||||
if (bookmark) {
|
||||
isBookmarked = true;
|
||||
bookmarkDescription = bookmark.description ?? null;
|
||||
} else {
|
||||
isBookmarked = false;
|
||||
bookmarkDescription = null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - bookmark status is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function handleBookmarkChange(newIsBookmarked: boolean, newDescription?: string | null) {
|
||||
isBookmarked = newIsBookmarked;
|
||||
bookmarkDescription = newDescription ?? null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchChapter();
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
@@ -139,10 +174,14 @@
|
||||
<!-- Navigation (top) -->
|
||||
<ChapterNavigation
|
||||
novelId={novelId ?? ''}
|
||||
chapterId={chapter.id}
|
||||
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
|
||||
prevChapterOrder={chapter.prevChapterOrder}
|
||||
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
|
||||
nextChapterOrder={chapter.nextChapterOrder}
|
||||
{isBookmarked}
|
||||
{bookmarkDescription}
|
||||
onBookmarkChange={handleBookmarkChange}
|
||||
/>
|
||||
|
||||
<!-- Chapter Header -->
|
||||
@@ -173,11 +212,15 @@
|
||||
<!-- Navigation (bottom) -->
|
||||
<ChapterNavigation
|
||||
novelId={novelId ?? ''}
|
||||
chapterId={chapter.id}
|
||||
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
|
||||
prevChapterOrder={chapter.prevChapterOrder}
|
||||
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
|
||||
nextChapterOrder={chapter.nextChapterOrder}
|
||||
showKeyboardHints={false}
|
||||
{isBookmarked}
|
||||
{bookmarkDescription}
|
||||
onBookmarkChange={handleBookmarkChange}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts" module>
|
||||
import type { NovelQuery, NovelStatus, Language } from '$lib/graphql/__generated__/graphql';
|
||||
import type { NovelQuery, NovelStatus, Language, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
|
||||
import { TagType } from '$lib/graphql/__generated__/graphql';
|
||||
import { SystemTags } from '$lib/constants/systemTags';
|
||||
|
||||
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
|
||||
|
||||
export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number];
|
||||
|
||||
const statusColors: Record<NovelStatus, string> = {
|
||||
@@ -32,7 +34,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { client } from '$lib/graphql/client';
|
||||
import { NovelDocument, ImportNovelDocument, DeleteNovelDocument } from '$lib/graphql/__generated__/graphql';
|
||||
import { NovelDocument, ImportNovelDocument, DeleteNovelDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
|
||||
import { isAuthenticated } from '$lib/auth/authStore';
|
||||
import { Card, CardContent, CardHeader } from '$lib/components/ui/card';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
@@ -98,6 +100,11 @@
|
||||
let activeTab = $state('chapters');
|
||||
let galleryLoaded = $state(false);
|
||||
|
||||
// Bookmarks state
|
||||
let bookmarks: BookmarkData[] = $state([]);
|
||||
let bookmarksLoaded = $state(false);
|
||||
let bookmarksFetching = $state(false);
|
||||
|
||||
const DESCRIPTION_PREVIEW_LENGTH = 300;
|
||||
|
||||
// Derived values
|
||||
@@ -128,6 +135,15 @@
|
||||
|
||||
const isSingleVolume = $derived(sortedVolumes.length === 1);
|
||||
|
||||
// Chapter lookup for bookmarks (maps chapterId to chapter details)
|
||||
const chapterLookup = $derived(
|
||||
new Map(
|
||||
sortedVolumes.flatMap((v) =>
|
||||
v.chapters.map((c) => [c.id, { ...c, volumeOrder: v.order }])
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const chapterCount = $derived(
|
||||
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
|
||||
);
|
||||
@@ -184,6 +200,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Load bookmarks when tab is first activated
|
||||
$effect(() => {
|
||||
if (activeTab === 'bookmarks' && !bookmarksLoaded && novelId) {
|
||||
fetchBookmarks();
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchBookmarks() {
|
||||
if (!novelId || bookmarksFetching) return;
|
||||
|
||||
bookmarksFetching = true;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
|
||||
.toPromise();
|
||||
|
||||
if (result.data?.bookmarks) {
|
||||
bookmarks = result.data.bookmarks;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - bookmarks are non-critical
|
||||
} finally {
|
||||
bookmarksFetching = false;
|
||||
bookmarksLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Image viewer functions
|
||||
function openImageViewer(index: number) {
|
||||
viewerIndex = index;
|
||||
@@ -528,10 +572,9 @@
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="bookmarks"
|
||||
disabled
|
||||
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
|
||||
>
|
||||
Bookmarks
|
||||
Bookmarks{bookmarksLoaded ? ` (${bookmarks.length})` : ''}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
@@ -646,9 +689,50 @@
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="bookmarks" class="mt-0">
|
||||
<p class="text-muted-foreground text-sm py-8 text-center">
|
||||
Bookmarks coming soon.
|
||||
</p>
|
||||
{#if bookmarksFetching}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div
|
||||
class="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
|
||||
aria-label="Loading bookmarks"
|
||||
></div>
|
||||
</div>
|
||||
{:else if bookmarks.length === 0}
|
||||
<p class="text-muted-foreground text-sm py-8 text-center">
|
||||
No bookmarks yet. Add bookmarks while reading chapters.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="max-h-96 overflow-y-auto -mx-2">
|
||||
{#each bookmarks as bookmark (bookmark.id)}
|
||||
{@const chapter = chapterLookup.get(bookmark.chapterId)}
|
||||
{#if chapter}
|
||||
{@const bookmarkDate = new Date(bookmark.createdTime)}
|
||||
<a
|
||||
href="/novels/{novelId}/volumes/{chapter.volumeOrder}/chapters/{chapter.order}"
|
||||
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
|
||||
Ch. {chapter.order}
|
||||
</span>
|
||||
<span class="text-sm truncate group-hover:text-primary transition-colors">
|
||||
{chapter.name}
|
||||
</span>
|
||||
</div>
|
||||
{#if bookmark.description}
|
||||
<p class="text-xs text-muted-foreground/70 mt-1 ml-[4.25rem] truncate">
|
||||
{bookmark.description}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
|
||||
{formatRelativeTime(bookmarkDate)}
|
||||
</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import Content from './popover-content.svelte';
|
||||
|
||||
const Root = PopoverPrimitive.Root;
|
||||
const Trigger = PopoverPrimitive.Trigger;
|
||||
const Close = PopoverPrimitive.Close;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Trigger,
|
||||
Content,
|
||||
Close,
|
||||
//
|
||||
Root as Popover,
|
||||
Trigger as PopoverTrigger,
|
||||
Content as PopoverContent,
|
||||
Close as PopoverClose
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Popover as PopoverPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
sideOffset = 4,
|
||||
align = 'center',
|
||||
class: className,
|
||||
...restProps
|
||||
}: PopoverPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="popover-content"
|
||||
{sideOffset}
|
||||
{align}
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 overflow-hidden rounded-md border p-4 shadow-md outline-none',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './textarea.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Textarea
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
type Props = WithElementRef<HTMLTextareaAttributes, HTMLTextAreaElement>;
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
'data-slot': dataSlot = 'textarea',
|
||||
...restProps
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex min-h-[80px] w-full min-w-0 rounded-md border px-3 py-2 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
bind:value
|
||||
{...restProps}
|
||||
></textarea>
|
||||
Reference in New Issue
Block a user