feature/FA-27_Bookmarks #59
@@ -0,0 +1,181 @@
|
||||
<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 Bookmark from '@lucide/svelte/icons/bookmark';
|
||||
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
|
||||
|
||||
interface Props {
|
||||
novelId: number;
|
||||
chapterId: number;
|
||||
isBookmarked?: boolean;
|
||||
bookmarkDescription?: string | null;
|
||||
size?: 'default' | 'sm' | 'icon';
|
||||
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
novelId,
|
||||
chapterId,
|
||||
isBookmarked = false,
|
||||
bookmarkDescription = null,
|
||||
size = 'icon',
|
||||
onBookmarkChange
|
||||
}: Props = $props();
|
||||
|
||||
// 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() {
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const result = await client
|
||||
.mutation(UpsertBookmarkDocument, {
|
||||
input: {
|
||||
chapterId,
|
||||
novelId,
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- 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={isBookmarked ? 'default' : 'ghost'}
|
||||
{size}
|
||||
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
|
||||
{...props}
|
||||
>
|
||||
{#if isBookmarked}
|
||||
<BookmarkCheck class="h-4 w-4" />
|
||||
{:else}
|
||||
<Bookmark class="h-4 w-4" />
|
||||
{/if}
|
||||
{#if size !== 'icon'}
|
||||
<span>{isBookmarked ? 'Bookmarked' : 'Bookmark'}</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">
|
||||
{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>
|
||||
</div>
|
||||
@@ -54,6 +54,7 @@
|
||||
} from '$lib/components/ui/tooltip';
|
||||
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
|
||||
import { sanitizeHtml } from '$lib/utils/sanitize';
|
||||
import ChapterBookmarkButton from './ChapterBookmarkButton.svelte';
|
||||
// Direct imports for faster builds
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||
@@ -144,6 +145,32 @@
|
||||
)
|
||||
);
|
||||
|
||||
// Bookmark lookup by chapterId for quick access in chapter list
|
||||
const bookmarkLookup = $derived(
|
||||
new Map(bookmarks.map((b) => [b.chapterId, b]))
|
||||
);
|
||||
|
||||
function handleChapterBookmarkChange(chapterId: number, isBookmarked: boolean, description?: string | null) {
|
||||
if (isBookmarked) {
|
||||
// Add or update bookmark in local state
|
||||
const existingIndex = bookmarks.findIndex((b) => b.chapterId === chapterId);
|
||||
const newBookmark = {
|
||||
id: existingIndex >= 0 ? bookmarks[existingIndex].id : -1, // temp id
|
||||
chapterId,
|
||||
description: description ?? null,
|
||||
createdTime: new Date().toISOString()
|
||||
};
|
||||
if (existingIndex >= 0) {
|
||||
bookmarks[existingIndex] = newBookmark;
|
||||
} else {
|
||||
bookmarks = [...bookmarks, newBookmark];
|
||||
}
|
||||
} else {
|
||||
// Remove bookmark from local state
|
||||
bookmarks = bookmarks.filter((b) => b.chapterId !== chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
const chapterCount = $derived(
|
||||
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
|
||||
);
|
||||
@@ -200,9 +227,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Load bookmarks when tab is first activated
|
||||
// Load bookmarks when novel is loaded (for count display)
|
||||
$effect(() => {
|
||||
if (activeTab === 'bookmarks' && !bookmarksLoaded && novelId) {
|
||||
if (novel && !bookmarksLoaded && novelId) {
|
||||
fetchBookmarks();
|
||||
}
|
||||
});
|
||||
@@ -591,24 +618,36 @@
|
||||
<div class="max-h-96 overflow-y-auto -mx-2">
|
||||
{#each singleVolumeChapters as chapter (chapter.id)}
|
||||
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
|
||||
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
|
||||
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
|
||||
<a
|
||||
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}"
|
||||
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<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>
|
||||
</a>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||
{#if chapterDate}
|
||||
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
|
||||
<span class="text-xs text-muted-foreground/70">
|
||||
{formatRelativeTime(chapterDate)}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{#if novelId}
|
||||
<ChapterBookmarkButton
|
||||
novelId={parseInt(novelId, 10)}
|
||||
chapterId={chapter.id}
|
||||
isBookmarked={!!chapterBookmark}
|
||||
bookmarkDescription={chapterBookmark?.description}
|
||||
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -630,24 +669,36 @@
|
||||
<div class="space-y-0.5">
|
||||
{#each volumeChapters as chapter (chapter.id)}
|
||||
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
|
||||
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
|
||||
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
|
||||
<a
|
||||
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
|
||||
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<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>
|
||||
</a>
|
||||
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||
{#if chapterDate}
|
||||
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
|
||||
<span class="text-xs text-muted-foreground/70">
|
||||
{formatRelativeTime(chapterDate)}
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{#if novelId}
|
||||
<ChapterBookmarkButton
|
||||
novelId={parseInt(novelId, 10)}
|
||||
chapterId={chapter.id}
|
||||
isBookmarked={!!chapterBookmark}
|
||||
bookmarkDescription={chapterBookmark?.description}
|
||||
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
|
||||
Reference in New Issue
Block a user