[FA-27] Still need to test events
This commit is contained in:
@@ -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';
|
} 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}
|
||||||
|
{@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
|
<a
|
||||||
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}"
|
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">
|
<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>
|
||||||
</div>
|
</a>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||||
{#if chapterDate}
|
{#if chapterDate}
|
||||||
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
|
<span class="text-xs text-muted-foreground/70">
|
||||||
{formatRelativeTime(chapterDate)}
|
{formatRelativeTime(chapterDate)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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}
|
{/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}
|
||||||
|
{@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
|
<a
|
||||||
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
|
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">
|
<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>
|
||||||
</div>
|
</a>
|
||||||
|
<div class="flex items-center gap-2 shrink-0 ml-2">
|
||||||
{#if chapterDate}
|
{#if chapterDate}
|
||||||
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
|
<span class="text-xs text-muted-foreground/70">
|
||||||
{formatRelativeTime(chapterDate)}
|
{formatRelativeTime(chapterDate)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user