[FA-27] Still need to test events

This commit is contained in:
gamer147
2026-01-19 15:40:21 -05:00
parent 1ecfd9cc99
commit c97654631b
2 changed files with 256 additions and 24 deletions

View File

@@ -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>

View File

@@ -54,6 +54,7 @@
} from '$lib/components/ui/tooltip'; } from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time'; import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize'; import { sanitizeHtml } from '$lib/utils/sanitize';
import ChapterBookmarkButton from './ChapterBookmarkButton.svelte';
// Direct imports for faster builds // Direct imports for faster builds
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ExternalLink from '@lucide/svelte/icons/external-link'; 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( const chapterCount = $derived(
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0) 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(() => { $effect(() => {
if (activeTab === 'bookmarks' && !bookmarksLoaded && novelId) { if (novel && !bookmarksLoaded && novelId) {
fetchBookmarks(); fetchBookmarks();
} }
}); });
@@ -591,24 +618,36 @@
<div class="max-h-96 overflow-y-auto -mx-2"> <div class="max-h-96 overflow-y-auto -mx-2">
{#each singleVolumeChapters as chapter (chapter.id)} {#each singleVolumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null} {@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a {@const chapterBookmark = bookmarkLookup.get(chapter.id)}
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}" <div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
class="flex items-center justify-between 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}"
<div class="flex items-center gap-3 min-w-0"> class="flex items-center gap-3 min-w-0 flex-1"
>
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14"> <span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order} Ch. {chapter.order}
</span> </span>
<span class="text-sm truncate group-hover:text-primary transition-colors"> <span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name} {chapter.name}
</span> </span>
</a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate}
<span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)}
</span>
{/if}
{#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>
{#if chapterDate} </div>
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
{/each} {/each}
</div> </div>
{:else} {:else}
@@ -630,24 +669,36 @@
<div class="space-y-0.5"> <div class="space-y-0.5">
{#each volumeChapters as chapter (chapter.id)} {#each volumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null} {@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a {@const chapterBookmark = bookmarkLookup.get(chapter.id)}
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}" <div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group" <a
> href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
<div class="flex items-center gap-3 min-w-0"> class="flex items-center gap-3 min-w-0 flex-1"
>
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14"> <span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order} Ch. {chapter.order}
</span> </span>
<span class="text-sm truncate group-hover:text-primary transition-colors"> <span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name} {chapter.name}
</span> </span>
</a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate}
<span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)}
</span>
{/if}
{#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>
{#if chapterDate} </div>
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
{/each} {/each}
</div> </div>
</AccordionContent> </AccordionContent>