feat(client): add native image loading via cookie and sonner toasts

This commit is contained in:
neon
2026-01-15 00:10:29 -06:00
parent 760cc6476f
commit 588c1b8972
18 changed files with 225 additions and 81 deletions
+4 -3
View File
@@ -16,9 +16,9 @@
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@projectx/types": "workspace:*",
"@fontsource-variable/fraunces": "^5.2.9",
"@fontsource-variable/inter": "^5.2.8",
"@projectx/types": "workspace:*",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.1.0",
"class-variance-authority": "^0.7.1",
@@ -31,12 +31,13 @@
"tailwindcss": "^4.1.18",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.3",
"@types/jsdom": "^27.0.0",
"@types/node": "^22.19.5",
"@types/node": "^22.19.6",
"@vitejs/plugin-vue": "^6.0.3",
"@vitest/eslint-plugin": "^1.6.6",
"@vue/eslint-config-typescript": "^14.6.0",
@@ -50,7 +51,7 @@
"jsdom": "^27.4.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.39.0",
"prettier": "3.7.4",
"prettier": "3.8.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
+2
View File
@@ -2,6 +2,7 @@
import { useThemeStore } from '@/stores/theme'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import { Toaster } from '@/components/ui/sonner'
useThemeStore()
const { isOpen } = useChangePasswordDialog()
@@ -10,4 +11,5 @@ const { isOpen } = useChangePasswordDialog()
<template>
<RouterView />
<ChangePasswordDialog v-if="isOpen" />
<Toaster rich-colors />
</template>
+65 -14
View File
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { Library, BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import * as Icons from 'lucide-vue-next'
import { BookMarked, Clock, BookOpen, Settings, LogOut, KeyRound } from 'lucide-vue-next'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Sidebar,
SidebarContent,
@@ -18,18 +20,26 @@ import {
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useLibraries } from '@/features/library/composables/useLibraries'
const { open: openSettings } = useSettingsDrawer()
const { open: openChangePassword } = useChangePasswordDialog()
const { user, logout } = useAuth()
const router = useRouter()
const route = useRoute()
const { libraries, fetchLibraries } = useLibraries()
const navItems = [
{ id: 'library', label: 'All Books', icon: Library, active: true },
{ id: 'reading', label: 'Reading', icon: BookOpen, active: false },
{ id: 'bookmarks', label: 'Bookmarks', icon: BookMarked, active: false },
{ id: 'recent', label: 'Recently Added', icon: Clock, active: false },
]
const activeLibraryId = computed(() => {
const id = route.params.id
return id ? Number(id) : null
})
function getLibraryIcon(name: string | null | undefined) {
if (name && name in Icons) return (Icons as Record<string, unknown>)[name]
return Icons.BookCopy
}
onMounted(fetchLibraries)
</script>
<template>
@@ -68,23 +78,64 @@ const navItems = [
</SidebarHeader>
<SidebarContent>
<!-- Libraries -->
<SidebarGroup class="pt-2">
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
Library
Libraries
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in navItems" :key="item.id">
<SidebarMenuButton :is-active="item.active" :tooltip="item.label" class="gap-2.5">
<component :is="item.icon" :size="15" :class="item.active ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'" />
<span :class="item.active ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
{{ item.label }}
<SidebarMenuItem v-for="lib in libraries" :key="lib.id">
<SidebarMenuButton
:is-active="activeLibraryId === lib.id"
:tooltip="lib.name"
class="gap-2.5"
@click="router.push({ name: 'library', params: { id: lib.id } })"
>
<component
:is="getLibraryIcon(lib.icon)"
:size="15"
:class="activeLibraryId === lib.id ? 'text-sidebar-primary' : 'text-sidebar-foreground/50'"
/>
<span :class="activeLibraryId === lib.id ? 'font-medium text-sidebar-foreground' : 'text-sidebar-foreground/70'">
{{ lib.name }}
</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator class="group-data-[collapsible=icon]:hidden" />
<!-- Browse -->
<SidebarGroup>
<SidebarGroupLabel class="text-[10px] uppercase tracking-widest text-sidebar-foreground/35 font-medium group-data-[collapsible=icon]:hidden">
Browse
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Reading" class="gap-2.5 opacity-40 cursor-not-allowed">
<BookOpen :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Reading</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Bookmarks" class="gap-2.5 opacity-40 cursor-not-allowed">
<BookMarked :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Bookmarks</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Recently Added" class="gap-2.5 opacity-40 cursor-not-allowed">
<Clock :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Recently Added</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter class="border-t border-sidebar-border">
@@ -0,0 +1,19 @@
<script setup lang="ts">
import 'vue-sonner/style.css'
import { Toaster as Sonner } from 'vue-sonner'
</script>
<template>
<Sonner
class="toaster group"
v-bind="$attrs"
:toast-options="{
classes: {
toast: 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}"
/>
</template>
+1
View File
@@ -0,0 +1 @@
export { default as Toaster } from './Sonner.vue'
@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { Book, BookFile } from '../composables/useBooks'
import { bookCoverStyle } from '../composables/useBooks'
import { useBookCover } from '../composables/useBookCover'
import { computed, onMounted } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
@@ -21,10 +20,8 @@ const primaryFile = computed(() => props.book.files.find((f) => f.role === 'prim
// Only show format chips when there are multiple files
const extraFiles = computed(() => (props.book.files.length > 1 ? props.book.files : []))
const { src: coverUrl, failed: coverFailed, load: loadCover } = useBookCover(props.book.id, 'thumbnail')
const coverLoaded = computed(() => coverUrl.value !== null)
onMounted(loadCover)
const coverLoaded = ref(false)
const coverFailed = ref(false)
function openFile(file: BookFile) {
router.push({
@@ -47,7 +44,16 @@ function openFile(file: BookFile) {
style="aspect-ratio: 2/3"
:style="coverLoaded ? {} : coverStyle"
>
<img v-if="coverUrl && !coverFailed" :src="coverUrl" class="absolute inset-0 w-full h-full object-cover" :alt="book.title ?? ''" />
<img
v-if="!coverFailed"
:src="`/api/books/${book.id}/thumbnail`"
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-200"
:class="coverLoaded ? 'opacity-100' : 'opacity-0'"
loading="lazy"
:alt="book.title ?? ''"
@load="coverLoaded = true"
@error="coverFailed = true"
/>
<!-- Series badge -->
<div v-if="seriesLine" class="absolute top-1.5 left-1.5 right-1.5">
@@ -1,20 +1,12 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useBookCover } from '../composables/useBookCover'
const props = defineProps<{
bookId: number
type?: 'thumbnail' | 'cover'
class?: string
alt?: string
}>()
const { src, failed, load } = useBookCover(props.bookId, props.type ?? 'thumbnail')
onMounted(load)
</script>
<template>
<img v-if="src && !failed" :src="src" :class="props.class" :alt="props.alt ?? ''" />
<div v-else :class="props.class" class="bg-muted" />
<img :src="`/api/books/${props.bookId}/${props.type ?? 'thumbnail'}`" :class="props.class" :alt="props.alt ?? ''" loading="lazy" />
</template>
@@ -1,29 +0,0 @@
import { ref, onUnmounted } from 'vue'
import { api } from '@/lib/api'
export function useBookCover(bookId: number, type: 'thumbnail' | 'cover' = 'thumbnail') {
const src = ref<string | null>(null)
const failed = ref(false)
let objectUrl: string | null = null
async function load() {
try {
const res = await api(`/api/books/${bookId}/${type}`)
if (!res.ok) {
failed.value = true
return
}
const blob = await res.blob()
objectUrl = URL.createObjectURL(blob)
src.value = objectUrl
} catch {
failed.value = true
}
}
onUnmounted(() => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
})
return { src, failed, load }
}
@@ -2,6 +2,7 @@
import { ref, shallowRef, onMounted, onUnmounted, computed } from 'vue'
import { X, ChevronLeft, ChevronRight, Check, Info, FolderOpen, ScanLine, Clock, Users } from 'lucide-vue-next'
import type { Library } from '@projectx/types'
import { api } from '@/lib/api'
import { useLibraryCreator } from '../composables/useLibraryCreator'
import LibraryCreatorDetails from './LibraryCreatorDetails.vue'
import LibraryCreatorFolders from './LibraryCreatorFolders.vue'
@@ -76,9 +77,11 @@ const mobileView = ref<'nav' | 'content'>('nav')
// ── Init ───────────────────────────────────────────────────────────────────
onMounted(() => {
onMounted(async () => {
if (props.library) {
creator.initEdit(props.library)
const res = await api(`/api/libraries/${props.library.id}`)
const full: Library = res.ok ? await res.json() : props.library
creator.initEdit(full)
visitedUpTo.value = ALL_SECTIONS.length - 1 // all unlocked in edit
} else {
creator.initCreate()
@@ -0,0 +1,15 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
const libraries = ref<Library[]>([])
export function useLibraries() {
async function fetchLibraries() {
const res = await api('/api/libraries')
if (!res.ok) return
libraries.value = await res.json()
}
return { libraries, fetchLibraries }
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { FolderOpen, Plus, RefreshCw, Pencil, Library } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
@@ -34,7 +35,14 @@ onMounted(loadLibraries)
async function scan(lib: LibraryType) {
scanning.value[lib.id] = true
try {
await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
const res = await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
if (res.ok) {
toast.success(`Scan started for "${lib.name}"`)
} else {
toast.error(`Failed to start scan for "${lib.name}"`)
}
} catch {
toast.error(`Failed to start scan for "${lib.name}"`)
} finally {
scanning.value[lib.id] = false
}
@@ -43,7 +51,17 @@ async function scan(lib: LibraryType) {
async function scanAll() {
scanningAll.value = true
try {
await Promise.all(libraries.value.map((lib) => api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })))
const results = await Promise.all(
libraries.value.map((lib) => api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })),
)
const failed = results.filter((r) => !r.ok).length
if (failed === 0) {
toast.success('Scan started for all libraries')
} else {
toast.error(`${failed} librar${failed === 1 ? 'y' : 'ies'} failed to start`)
}
} catch {
toast.error('Failed to start scans')
} finally {
scanningAll.value = false
}
+5
View File
@@ -10,6 +10,11 @@ const router = createRouter({
name: 'home',
component: HomeView,
},
{
path: '/library/:id',
name: 'library',
component: HomeView,
},
{
path: '/read/:bookId/:fileId',
name: 'reader',
+40 -12
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { api } from '@/lib/api'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import AppHeader from '@/components/AppHeader.vue'
@@ -9,28 +9,45 @@ import SettingsDrawer from '@/features/settings/SettingsDrawer.vue'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import { useBooks } from '@/features/book/composables/useBooks'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { BACKGROUND_OPTIONS, useThemeStore } from '@/stores/theme'
const route = useRoute()
const router = useRouter()
const themeStore = useThemeStore()
const backgroundClass = computed(() => BACKGROUND_OPTIONS.find((b) => b.id === themeStore.background)?.cssClass ?? '')
const { coverSize, gridGap, viewMode } = useDisplaySettings()
const libraryId = ref<number | null>(null)
const { libraries, fetchLibraries } = useLibraries()
async function loadLibrary() {
const res = await api('/api/libraries')
if (!res.ok) return
const libs: { id: number }[] = await res.json()
if (libs.length > 0) libraryId.value = libs[0]!.id
}
const libraryId = computed<number | null>(() => {
const id = route.params.id
return id ? Number(id) : null
})
const title = computed(() => libraries.value.find((l) => l.id === libraryId.value)?.name ?? 'Library')
const { books, total, loading, error, search, hasMore, load, onSearch } = useBooks(libraryId)
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
function loadIfSentinelVisible() {
if (!hasMore.value || loading.value) return
const el = sentinel.value
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.top < window.innerHeight + 300) load()
}
onMounted(async () => {
await loadLibrary()
load()
await fetchLibraries()
if (!libraryId.value && libraries.value.length > 0) {
router.replace({ name: 'library', params: { id: libraries.value[0]!.id } })
} else {
load()
}
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) load()
@@ -42,6 +59,17 @@ onMounted(async () => {
onUnmounted(() => observer?.disconnect())
watch(libraryId, (newId, oldId) => {
if (newId !== null && newId !== oldId) {
search.value = ''
load(true)
}
})
watch(loading, (isLoading) => {
if (!isLoading) loadIfSentinelVisible()
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(search, () => {
if (searchTimer) clearTimeout(searchTimer)
@@ -56,7 +84,7 @@ watch(search, () => {
<SidebarInset class="flex flex-col min-h-screen glow-wrapper">
<AppHeader
title="Library"
:title="title"
:total="total"
:loaded="books.length"
v-model:search="search"
+1 -1
View File
@@ -5,7 +5,7 @@
{
"idx": 0,
"version": "7",
"when": 1768464558856,
"when": 1768503997010,
"tag": "0000_baseline",
"breakpoints": true
}
+2 -1
View File
@@ -28,10 +28,11 @@ async function bootstrap() {
await app.register(require('@fastify/cookie'));
// Global IP-based rate limit; tighten per-route as needed
// Global IP-based rate limit; image/cover endpoints are exempt (high volume, cheap reads)
await app.register(require('@fastify/rate-limit'), {
max: 100,
timeWindow: '1 minute',
allowList: (req: { url: string }) => /^\/api\/books\/\d+\/(thumbnail|cover)/.test(req.url),
});
if (process.env.NODE_ENV !== 'production') {
+26
View File
@@ -107,6 +107,7 @@ export class AuthService {
const fullUser = await this.userService.findByIdWithRolesAndPermissions(user.id);
const { accessToken, rawRefreshToken } = await this.issueTokenPair(user.id, user.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken, user: this.buildUserResponse(fullUser!) };
}
@@ -141,6 +142,7 @@ export class AuthService {
if (!user || !user.active) throw new UnauthorizedException();
const { accessToken, rawRefreshToken } = await this.issueTokenPair(userId, user.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken, user: this.buildUserResponse(user) };
}
@@ -175,6 +177,7 @@ export class AuthService {
const { accessToken, rawRefreshToken } = await this.issueTokenPair(row.userId, userForToken.tokenVersion);
this.setRefreshCookie(reply, rawRefreshToken);
this.setAccessCookie(reply, accessToken);
return { accessToken };
}
@@ -196,6 +199,7 @@ export class AuthService {
}
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
if (!userId) return {};
@@ -314,6 +318,7 @@ export class AuthService {
});
this.clearRefreshCookie(reply);
this.clearAccessCookie(reply);
}
private async issueTokenPair(userId: number, tokenVersion: number) {
@@ -350,4 +355,25 @@ export class AuthService {
secure: this.config.get('app.nodeEnv') === 'production',
});
}
private setAccessCookie(reply: FastifyReply, accessToken: string) {
const ttlSeconds = parseDurationMs(this.config.get<string>('auth.jwtExpiresIn') ?? '15m') / 1000;
reply.setCookie('access_token', accessToken, {
httpOnly: true,
sameSite: 'lax',
path: '/api',
maxAge: ttlSeconds,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
private clearAccessCookie(reply: FastifyReply) {
reply.setCookie('access_token', '', {
httpOnly: true,
sameSite: 'lax',
path: '/api',
maxAge: 0,
secure: this.config.get('app.nodeEnv') === 'production',
});
}
}
+4 -1
View File
@@ -12,7 +12,10 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly authService: AuthService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
ExtractJwt.fromAuthHeaderAsBearerToken(),
(req: any) => req?.cookies?.access_token ?? null,
]),
secretOrKey: config.get<string>('auth.jwtSecret')!,
});
}
@@ -24,6 +24,7 @@ export class BookController {
const ext = coverPath.split('.').pop()?.toLowerCase();
const contentType = ext === 'png' ? 'image/png' : 'image/jpeg';
reply.header('Cache-Control', 'private, max-age=86400');
reply.type(contentType);
reply.send(createReadStream(coverPath));
}
@@ -33,6 +34,7 @@ export class BookController {
const thumbnailPath = await this.bookService.getThumbnailPath(id, user);
if (!thumbnailPath) throw new NotFoundException(`No thumbnail for book ${id}`);
reply.header('Cache-Control', 'private, max-age=86400');
reply.type('image/jpeg');
reply.send(createReadStream(thumbnailPath));
}