feat: settings page — slide-over drawer (option B)

This commit is contained in:
neon
2026-01-06 06:13:05 -06:00
parent e9300e7d85
commit 917e961732
13 changed files with 1196 additions and 5 deletions
+16 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { Library, BookMarked, Clock, BookOpen } from 'lucide-vue-next'
import { Library, BookMarked, Clock, BookOpen, Settings } from 'lucide-vue-next'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
@@ -12,6 +13,9 @@ import {
SidebarMenuItem,
SidebarRail,
} from '@/components/ui/sidebar'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
const { open: openSettings } = useSettingsDrawer()
const navItems = [
{ id: 'library', label: 'All Books', icon: Library, active: true },
@@ -76,6 +80,17 @@ const navItems = [
</SidebarGroup>
</SidebarContent>
<SidebarFooter class="border-t border-sidebar-border">
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton tooltip="Settings" class="gap-2.5" @click="openSettings">
<Settings :size="15" class="text-sidebar-foreground/50" />
<span class="text-sidebar-foreground/70">Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
</template>
@@ -0,0 +1,11 @@
import { ref } from 'vue'
const isOpen = ref(false)
export function useSettingsDrawer() {
return {
isOpen,
open: () => (isOpen.value = true),
close: () => (isOpen.value = false),
}
}
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { BookOpen, Database, Github, Server } from 'lucide-vue-next'
const stack = [
{ label: 'Frontend', value: 'Vue 3 + TypeScript + Tailwind CSS v4' },
{ label: 'Backend', value: 'NestJS 11 + Fastify' },
{ label: 'Database', value: 'PostgreSQL + Drizzle ORM' },
{ label: 'EPUB Engine', value: 'Foliate (epub.js)' },
{ label: 'PDF Engine', value: 'PDF.js' },
]
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">About</h2>
<p class="mt-1 text-sm text-muted-foreground">Information about this application.</p>
</div>
<!-- App identity -->
<div class="mb-6">
<div class="border border-border rounded-lg bg-card px-5 py-5 flex items-start gap-4">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<BookOpen :size="22" class="text-primary" />
</div>
<div>
<p class="text-base font-semibold text-foreground font-serif">projectx</p>
<p class="text-xs text-muted-foreground mt-0.5">Version 0.1.0 — early development</p>
<p class="text-xs text-muted-foreground mt-2 leading-relaxed max-w-lg">
A self-hosted book and library management application with support for EPUB, PDF, and comic formats.
</p>
</div>
</div>
</div>
<!-- Tech stack -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Tech Stack</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="item in stack" :key="item.label" class="flex items-center justify-between px-5 py-3.5 bg-card">
<p class="text-sm text-muted-foreground">{{ item.label }}</p>
<p class="text-sm font-medium text-foreground">{{ item.value }}</p>
</div>
</div>
</div>
<!-- Server info -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Server</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<Server :size="15" class="text-muted-foreground shrink-0" />
<div>
<p class="text-sm font-medium text-foreground">API</p>
<p class="text-xs text-muted-foreground font-mono mt-0.5">/api</p>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<Database :size="15" class="text-muted-foreground shrink-0" />
<div>
<p class="text-sm font-medium text-foreground">Database</p>
<p class="text-xs text-muted-foreground mt-0.5">PostgreSQL</p>
</div>
</div>
</div>
</div>
<!-- Links -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Links</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<a
href="https://github.com/neonsolstice/projectx"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 px-5 py-4 bg-card hover:bg-muted/50 transition-colors group"
>
<Github :size="15" class="text-muted-foreground shrink-0" />
<div class="flex-1">
<p class="text-sm font-medium text-foreground group-hover:text-primary transition-colors">GitHub</p>
<p class="text-xs text-muted-foreground mt-0.5">Source code and issue tracker</p>
</div>
</a>
</div>
</div>
</div>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { ACCENT_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
import { Moon, Sun } from 'lucide-vue-next'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const themeStore = useThemeStore()
const { coverSize, gridGap } = useDisplaySettings()
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Appearance</h2>
<p class="mt-1 text-sm text-muted-foreground">Customize how the app looks and feels.</p>
</div>
<!-- Theme & colors -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Theme</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<!-- Light / dark -->
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Color scheme</p>
<p class="text-xs text-muted-foreground mt-0.5">Light or dark interface</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'light' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="themeStore.theme === 'dark' && themeStore.toggleTheme()"
>
<Sun :size="12" /> Light
</button>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="themeStore.theme === 'dark' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="themeStore.theme === 'light' && themeStore.toggleTheme()"
>
<Moon :size="12" /> Dark
</button>
</div>
</div>
<!-- Accent color -->
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Accent color</p>
<p class="text-xs text-muted-foreground mt-0.5">Controls highlights and interactive elements</p>
</div>
<div class="flex items-center gap-2">
<button
v-for="opt in ACCENT_OPTIONS"
:key="opt.id"
:title="opt.label"
class="w-6 h-6 rounded-full transition-all hover:scale-110 focus:outline-none"
:style="{
backgroundColor: opt.color,
outline: themeStore.accent === opt.id ? `2px solid ${opt.color}` : 'none',
outlineOffset: '2px',
transform: themeStore.accent === opt.id ? 'scale(1.25)' : '',
}"
@click="themeStore.setAccent(opt.id)"
/>
</div>
</div>
<!-- Corner radius -->
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Corner radius</p>
<p class="text-xs text-muted-foreground mt-0.5">Roundness of cards and UI elements</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-for="opt in RADIUS_OPTIONS"
:key="opt.id"
:title="opt.label"
class="h-7 px-3 text-xs border-2 transition-colors font-medium"
:style="{ borderRadius: opt.id === 'sharp' ? '2px' : opt.id === 'default' ? '6px' : '14px' }"
:class="
themeStore.radius === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="themeStore.setRadius(opt.id)"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Library view -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Library View</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<!-- Cover size -->
<div class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-medium text-foreground">Cover size</p>
<p class="text-xs text-muted-foreground mt-0.5">Width of book covers in the grid</p>
</div>
<span class="text-sm font-medium tabular-nums text-foreground">{{ coverSize }}px</span>
</div>
<input
:value="coverSize"
@input="coverSize = Number(($event.target as HTMLInputElement).value)"
type="range"
min="80"
max="280"
step="10"
class="w-full accent-primary cursor-pointer"
/>
</div>
<!-- Grid gap -->
<div class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-medium text-foreground">Grid spacing</p>
<p class="text-xs text-muted-foreground mt-0.5">Gap between covers in the grid</p>
</div>
<span class="text-sm font-medium tabular-nums text-foreground">{{ gridGap }}px</span>
</div>
<input
:value="gridGap"
@input="gridGap = Number(($event.target as HTMLInputElement).value)"
type="range"
min="4"
max="40"
step="4"
class="w-full accent-primary cursor-pointer"
/>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { ref } from 'vue'
const scrollMode = ref<'webtoon' | 'paged'>('paged')
const readingDirection = ref<'ltr' | 'rtl'>('ltr')
const background = ref<'black' | 'white' | 'system'>('black')
const fitMode = ref<'width' | 'height' | 'original'>('width')
const rememberPosition = ref(true)
const preloadPages = ref(3)
const backgrounds = [
{ id: 'black', label: 'Black' },
{ id: 'white', label: 'White' },
{ id: 'system', label: 'System' },
]
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Comics Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening CBZ, CBR, and CB7 files.</p>
</div>
<!-- View -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">View</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Reading mode</p>
<p class="text-xs text-muted-foreground mt-0.5">Paged flips one page at a time; Webtoon scrolls vertically</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="scrollMode === 'paged' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="scrollMode = 'paged'"
>
Paged
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="scrollMode === 'webtoon' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="scrollMode = 'webtoon'"
>
Webtoon
</button>
</div>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Reading direction</p>
<p class="text-xs text-muted-foreground mt-0.5">Left-to-right for western; right-to-left for manga</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="readingDirection === 'ltr' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="readingDirection = 'ltr'"
>
L → R
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="readingDirection === 'rtl' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="readingDirection = 'rtl'"
>
R → L
</button>
</div>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Fit mode</p>
<p class="text-xs text-muted-foreground mt-0.5">How pages are scaled to fit the screen</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-for="opt in [
{ id: 'width', label: 'Width' },
{ id: 'height', label: 'Height' },
{ id: 'original', label: 'Original' },
]"
:key="opt.id"
class="h-7 px-3 text-xs border-2 transition-colors font-medium rounded-md"
:class="
fitMode === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="fitMode = opt.id as 'width' | 'height' | 'original'"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Display -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Display</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Background color</p>
<p class="text-xs text-muted-foreground mt-0.5">Canvas color behind pages</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-for="bg in backgrounds"
:key="bg.id"
class="h-7 px-3 text-xs border-2 transition-colors font-medium rounded-md"
:class="
background === bg.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="background = bg.id as 'black' | 'white' | 'system'"
>
{{ bg.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Performance -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Performance</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-medium text-foreground">Preload pages</p>
<p class="text-xs text-muted-foreground mt-0.5">Number of pages to load ahead for faster navigation</p>
</div>
<span class="text-sm font-medium tabular-nums text-foreground">{{ preloadPages }}</span>
</div>
<input v-model.number="preloadPages" type="range" min="1" max="10" step="1" class="w-full accent-primary cursor-pointer" />
</div>
</div>
</div>
<!-- Behavior -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Behavior</p>
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Remember position</p>
<p class="text-xs text-muted-foreground mt-0.5">Resume from the last page you were on</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="rememberPosition ? 'bg-primary' : 'bg-muted'"
@click="rememberPosition = !rememberPosition"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="rememberPosition ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<p class="mt-6 text-xs text-muted-foreground">These defaults will be persisted and applied to new comics in a future update.</p>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { ref } from 'vue'
const defaultFlow = ref<'paginated' | 'scrolled'>('paginated')
const defaultFontSize = ref(18)
const justify = ref(true)
const hyphenate = ref(false)
const rememberPosition = ref(true)
const defaultTheme = ref('default')
const themes = [
{ id: 'default', label: 'Default' },
{ id: 'sepia', label: 'Sepia' },
{ id: 'dark', label: 'Dark' },
{ id: 'night', label: 'Night' },
]
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">eBook Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening EPUB and MOBI files.</p>
</div>
<!-- Layout -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Layout</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Reading flow</p>
<p class="text-xs text-muted-foreground mt-0.5">Paginated flips pages; scrolled flows continuously</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultFlow === 'paginated' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultFlow = 'paginated'"
>
Paginated
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultFlow === 'scrolled' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultFlow = 'scrolled'"
>
Scrolled
</button>
</div>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Default theme</p>
<p class="text-xs text-muted-foreground mt-0.5">Starting color scheme for new books</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-for="t in themes"
:key="t.id"
class="h-7 px-3 text-xs border-2 transition-colors font-medium rounded-md"
:class="
defaultTheme === t.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="defaultTheme = t.id"
>
{{ t.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Typography -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Typography</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<div>
<p class="text-sm font-medium text-foreground">Default font size</p>
<p class="text-xs text-muted-foreground mt-0.5">Base size before any reader adjustment</p>
</div>
<span class="text-sm font-medium tabular-nums text-foreground">{{ defaultFontSize }}px</span>
</div>
<input v-model.number="defaultFontSize" type="range" min="10" max="32" step="1" class="w-full accent-primary cursor-pointer" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Justify text</p>
<p class="text-xs text-muted-foreground mt-0.5">Align text to both margins</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="justify ? 'bg-primary' : 'bg-muted'"
@click="justify = !justify"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="justify ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Hyphenation</p>
<p class="text-xs text-muted-foreground mt-0.5">Auto word-break with hyphens</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="hyphenate ? 'bg-primary' : 'bg-muted'"
@click="hyphenate = !hyphenate"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="hyphenate ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<!-- Behavior -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Behavior</p>
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Remember position</p>
<p class="text-xs text-muted-foreground mt-0.5">Resume from where you left off</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="rememberPosition ? 'bg-primary' : 'bg-muted'"
@click="rememberPosition = !rememberPosition"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="rememberPosition ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<p class="mt-6 text-xs text-muted-foreground">These defaults will be persisted and applied to new books in a future update.</p>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { FolderOpen, Plus, RefreshCw } from 'lucide-vue-next'
interface Library {
id: number
name: string
createdAt: string
}
const libraries = ref<Library[]>([])
const scanning = ref<Record<number, boolean>>({})
const scanningAll = ref(false)
onMounted(async () => {
const res = await fetch('/api/libraries')
if (res.ok) libraries.value = await res.json()
})
async function scan(lib: Library) {
scanning.value[lib.id] = true
try {
await fetch(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
} finally {
scanning.value[lib.id] = false
}
}
async function scanAll() {
scanningAll.value = true
try {
await Promise.all(libraries.value.map((lib) => fetch(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })))
} finally {
scanningAll.value = false
}
}
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<!-- Header -->
<div class="flex items-start justify-between mb-8">
<div>
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Libraries</h2>
<p class="mt-1 text-sm text-muted-foreground">Manage your media libraries and trigger content scans.</p>
</div>
<div class="flex items-center gap-2 shrink-0 ml-4">
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50"
:disabled="scanningAll || libraries.length === 0"
@click="scanAll"
>
<RefreshCw :size="12" :class="scanningAll ? 'animate-spin' : ''" />
{{ scanningAll ? 'Scanning…' : 'Scan All' }}
</button>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-opacity opacity-50 cursor-not-allowed"
disabled
title="Coming soon"
>
<Plus :size="12" />
Add Library
</button>
</div>
</div>
<!-- Library list -->
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="lib in libraries" :key="lib.id" class="bg-card px-5 py-4">
<div class="flex items-center gap-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FolderOpen :size="17" class="text-primary" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-foreground truncate">{{ lib.name }}</p>
</div>
<p class="text-xs text-muted-foreground mt-0.5">
Added {{ new Date(lib.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }) }}
</p>
</div>
<button
class="flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors text-muted-foreground hover:text-foreground disabled:opacity-50 shrink-0"
:disabled="scanning[lib.id]"
@click="scan(lib)"
>
<RefreshCw :size="12" :class="scanning[lib.id] ? 'animate-spin' : ''" />
{{ scanning[lib.id] ? 'Scanning…' : 'Scan' }}
</button>
</div>
</div>
<div v-if="libraries.length === 0" class="bg-card px-5 py-12 text-center">
<FolderOpen :size="28" class="text-muted-foreground/30 mx-auto mb-3" />
<p class="text-sm text-muted-foreground">No libraries configured yet.</p>
</div>
</div>
<!-- Info note -->
<div class="mt-6 rounded-lg border border-border bg-muted/40 px-5 py-4">
<p class="text-xs font-medium text-foreground mb-1">Library folders</p>
<p class="text-xs text-muted-foreground leading-relaxed">
Libraries are configured via the server environment and database. Folder path management and adding new libraries from the UI will be
available in a future update.
</p>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref } from 'vue'
const defaultZoom = ref('fit-width')
const defaultScroll = ref<'continuous' | 'page'>('continuous')
const defaultSpread = ref<'none' | 'odd'>('none')
const invertColors = ref(false)
const rememberPosition = ref(true)
const zoomOptions = [
{ id: 'fit-width', label: 'Fit Width' },
{ id: 'fit-page', label: 'Fit Page' },
{ id: '0.75', label: '75%' },
{ id: '1', label: '100%' },
{ id: '1.25', label: '125%' },
{ id: '1.5', label: '150%' },
]
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">PDF Reader</h2>
<p class="mt-1 text-sm text-muted-foreground">Default settings applied when opening PDF files.</p>
</div>
<!-- Zoom -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Zoom</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="px-5 py-4 bg-card">
<div class="mb-3">
<p class="text-sm font-medium text-foreground">Default zoom level</p>
<p class="text-xs text-muted-foreground mt-0.5">Starting zoom when a PDF is opened</p>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in zoomOptions"
:key="opt.id"
class="h-8 px-3 text-xs border-2 transition-colors font-medium rounded-md"
:class="
defaultZoom === opt.id
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="defaultZoom = opt.id"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
</div>
<!-- Layout -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Layout</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Scroll mode</p>
<p class="text-xs text-muted-foreground mt-0.5">Continuous scrolls through pages; Page by Page flips one at a time</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultScroll === 'continuous' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultScroll = 'continuous'"
>
Continuous
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultScroll === 'page' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultScroll = 'page'"
>
Page by Page
</button>
</div>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Page spread</p>
<p class="text-xs text-muted-foreground mt-0.5">Show one or two pages side by side</p>
</div>
<div class="flex items-center gap-1 p-1 rounded-lg border border-border bg-muted/50">
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultSpread === 'none' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultSpread = 'none'"
>
Single
</button>
<button
class="px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
:class="defaultSpread === 'odd' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground'"
@click="defaultSpread = 'odd'"
>
Two-Page
</button>
</div>
</div>
</div>
</div>
<!-- Display -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Display</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Invert colors</p>
<p class="text-xs text-muted-foreground mt-0.5">Invert PDF colors for easier reading in dark mode</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="invertColors ? 'bg-primary' : 'bg-muted'"
@click="invertColors = !invertColors"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="invertColors ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<!-- Behavior -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Behavior</p>
<div class="border border-border rounded-lg overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Remember position</p>
<p class="text-xs text-muted-foreground mt-0.5">Resume from the last page you were on</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="rememberPosition ? 'bg-primary' : 'bg-muted'"
@click="rememberPosition = !rememberPosition"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="rememberPosition ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<p class="mt-6 text-xs text-muted-foreground">These defaults will be persisted and applied to new documents in a future update.</p>
</div>
</template>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { ref } from 'vue'
const defaultFlow = ref('paginated')
const rememberPosition = ref(true)
const preloadPages = ref(true)
</script>
<template>
<div class="max-w-2xl mx-auto px-10 py-10">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Reading</h2>
<p class="mt-1 text-sm text-muted-foreground">Default behavior and preferences for the reader.</p>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<!-- Remember position -->
<div class="flex items-center justify-between px-5 py-4 bg-card border-b border-border">
<div>
<p class="text-sm font-medium text-foreground">Remember position</p>
<p class="text-xs text-muted-foreground mt-0.5">Resume from where you left off</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="rememberPosition ? 'bg-primary' : 'bg-muted'"
@click="rememberPosition = !rememberPosition"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="rememberPosition ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<!-- Preload pages -->
<div class="flex items-center justify-between px-5 py-4 bg-card border-b border-border">
<div>
<p class="text-sm font-medium text-foreground">Preload pages</p>
<p class="text-xs text-muted-foreground mt-0.5">Load upcoming pages in the background (comic reader)</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="preloadPages ? 'bg-primary' : 'bg-muted'"
@click="preloadPages = !preloadPages"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="preloadPages ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<!-- Default comic flow -->
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Default comic layout</p>
<p class="text-xs text-muted-foreground mt-0.5">Starting scroll mode for CBZ / CBR / CB7</p>
</div>
<div class="flex items-center gap-1.5">
<button
v-for="opt in [
{ value: 'paginated', label: 'Paginated' },
{ value: 'infinite', label: 'Infinite' },
{ value: 'long-strip', label: 'Strip' },
]"
:key="opt.value"
class="h-7 px-3 text-xs rounded-md border-2 transition-colors"
:class="
defaultFlow === opt.value
? 'border-primary text-primary bg-primary/8'
: 'border-border text-muted-foreground hover:border-muted-foreground/40 hover:text-foreground'
"
@click="defaultFlow = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
</div>
<p class="mt-4 text-xs text-muted-foreground">Reader preferences will be persisted in a future update.</p>
</div>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref } from 'vue'
const autoScan = ref(true)
const scanOnStartup = ref(true)
const watchForChanges = ref(false)
const scanInterval = ref('hourly')
const fileTypes = ref([
{ ext: 'epub', label: 'EPUB', enabled: true },
{ ext: 'pdf', label: 'PDF', enabled: true },
{ ext: 'cbz', label: 'CBZ', enabled: true },
{ ext: 'cbr', label: 'CBR', enabled: true },
{ ext: 'cb7', label: 'CB7', enabled: true },
{ ext: 'mobi', label: 'MOBI', enabled: true },
{ ext: 'azw3', label: 'AZW3', enabled: true },
])
</script>
<template>
<div class="px-10 py-8 max-w-3xl">
<div class="mb-8">
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">Scanner</h2>
<p class="mt-1 text-sm text-muted-foreground">Configure when and how your libraries are scanned for new content.</p>
</div>
<!-- Schedule -->
<div class="mb-6">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Schedule</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Automatic scanning</p>
<p class="text-xs text-muted-foreground mt-0.5">Periodically scan libraries for new or changed files</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="autoScan ? 'bg-primary' : 'bg-muted'"
@click="autoScan = !autoScan"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="autoScan ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card" :class="!autoScan ? 'opacity-40 pointer-events-none' : ''">
<div>
<p class="text-sm font-medium text-foreground">Scan interval</p>
<p class="text-xs text-muted-foreground mt-0.5">How often to check all library folders</p>
</div>
<select
v-model="scanInterval"
class="text-sm border border-border rounded-md px-3 py-1.5 bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="15min">Every 15 minutes</option>
<option value="hourly">Every hour</option>
<option value="6h">Every 6 hours</option>
<option value="daily">Once a day</option>
<option value="weekly">Once a week</option>
</select>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Scan on startup</p>
<p class="text-xs text-muted-foreground mt-0.5">Run a scan automatically when the server starts</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="scanOnStartup ? 'bg-primary' : 'bg-muted'"
@click="scanOnStartup = !scanOnStartup"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="scanOnStartup ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="text-sm font-medium text-foreground">Watch for changes</p>
<p class="text-xs text-muted-foreground mt-0.5">Detect new files instantly using filesystem events</p>
</div>
<button
class="w-11 h-6 rounded-full transition-colors relative shrink-0"
:class="watchForChanges ? 'bg-primary' : 'bg-muted'"
@click="watchForChanges = !watchForChanges"
>
<div
class="absolute top-1 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="watchForChanges ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
</div>
</div>
<!-- File types -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Supported file types</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="type in fileTypes" :key="type.ext" class="flex items-center justify-between px-5 py-3.5 bg-card">
<div class="flex items-center gap-3">
<span class="text-xs font-mono font-medium text-muted-foreground bg-muted px-1.5 py-0.5 rounded uppercase"> .{{ type.ext }} </span>
<p class="text-sm text-foreground">{{ type.label }}</p>
</div>
<button
class="w-9 h-5 rounded-full transition-colors relative shrink-0"
:class="type.enabled ? 'bg-primary' : 'bg-muted'"
@click="type.enabled = !type.enabled"
>
<div
class="absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform shadow-sm"
:class="type.enabled ? 'translate-x-4' : 'translate-x-0.5'"
/>
</button>
</div>
</div>
</div>
<p class="mt-6 text-xs text-muted-foreground">Scanner configuration will be persisted in a future update.</p>
</div>
</template>
@@ -0,0 +1,137 @@
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import { X, Library, ScanLine, Paintbrush, BookOpen, FileText, BookImage, Info, ChevronLeft } from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import LibrariesSettings from './LibrariesSettings.vue'
import ScannerSettings from './ScannerSettings.vue'
import AppearanceSettings from './AppearanceSettings.vue'
import EbookSettings from './EbookSettings.vue'
import PdfSettings from './PdfSettings.vue'
import ComicsSettings from './ComicsSettings.vue'
import AboutSettings from './AboutSettings.vue'
const { isOpen, close } = useSettingsDrawer()
type SectionId = 'libraries' | 'scanner' | 'appearance' | 'ebook' | 'pdf' | 'comics' | 'about'
const navGroups = [
{
label: 'General',
items: [
{ id: 'libraries' as SectionId, label: 'Libraries', icon: Library, component: LibrariesSettings },
{ id: 'scanner' as SectionId, label: 'Scanner', icon: ScanLine, component: ScannerSettings },
{ id: 'appearance' as SectionId, label: 'Appearance', icon: Paintbrush, component: AppearanceSettings },
],
},
{
label: 'Reader',
items: [
{ id: 'ebook' as SectionId, label: 'eBook', icon: BookOpen, component: EbookSettings },
{ id: 'pdf' as SectionId, label: 'PDF', icon: FileText, component: PdfSettings },
{ id: 'comics' as SectionId, label: 'Comics', icon: BookImage, component: ComicsSettings },
],
},
{
label: 'System',
items: [{ id: 'about' as SectionId, label: 'About', icon: Info, component: AboutSettings }],
},
]
const allItems = navGroups.flatMap((g) => g.items)
const activeId = ref<SectionId>('libraries')
const ActiveComponent = shallowRef(LibrariesSettings)
function navigate(id: SectionId) {
activeId.value = id
ActiveComponent.value = allItems.find((i) => i.id === id)!.component
}
watch(isOpen, (v) => {
if (v) navigate('libraries')
})
</script>
<template>
<Teleport to="body">
<Transition name="drawer-fade">
<div v-if="isOpen" class="fixed inset-0 z-50 flex justify-end" @click.self="close">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/40 backdrop-blur-[2px]" @click="close" />
<!-- Panel -->
<Transition name="drawer-slide">
<div
v-if="isOpen"
class="relative flex h-full w-full max-w-[900px] shadow-2xl overflow-hidden"
style="border-left: 1px solid var(--border)"
>
<!-- Left nav -->
<nav class="flex flex-col w-52 shrink-0 bg-muted/40 border-r border-border h-full">
<div class="px-4 pt-5 pb-4 border-b border-border flex items-center justify-between">
<span class="text-sm font-semibold text-foreground font-serif">Settings</span>
<button
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="close"
>
<X :size="14" />
</button>
</div>
<div class="flex-1 overflow-y-auto py-3 px-2">
<div v-for="group in navGroups" :key="group.label" class="mb-4">
<p class="px-2 mb-1 text-[10px] font-semibold text-muted-foreground/60 uppercase tracking-widest">
{{ group.label }}
</p>
<button
v-for="item in group.items"
:key="item.id"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors relative"
:class="
activeId === item.id
? 'bg-background text-foreground font-medium shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
"
@click="navigate(item.id)"
>
<component :is="item.icon" :size="14" :class="activeId === item.id ? 'text-primary' : 'text-muted-foreground/70'" />
{{ item.label }}
<div v-if="activeId === item.id" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r bg-primary" />
</button>
</div>
</div>
<div class="px-4 py-3 border-t border-border">
<p class="text-[10px] text-muted-foreground/40">projectx · v0.1.0</p>
</div>
</nav>
<!-- Content -->
<main class="flex-1 overflow-y-auto bg-background">
<component :is="ActiveComponent" />
</main>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
transition: opacity 0.2s ease;
}
.drawer-fade-enter-from,
.drawer-fade-leave-to {
opacity: 0;
}
.drawer-slide-enter-active,
.drawer-slide-leave-active {
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.drawer-slide-enter-from,
.drawer-slide-leave-to {
transform: translateX(100%);
}
</style>
+2
View File
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import BookCoverCard from '@/features/book/components/BookCoverCard.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
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'
@@ -44,6 +45,7 @@ watch(search, () => {
</script>
<template>
<SettingsDrawer />
<SidebarProvider>
<AppSidebar />
+4 -4
View File
@@ -5,28 +5,28 @@
{
"idx": 0,
"version": "7",
"when": 1767510287823,
"when": 1767748212922,
"tag": "0000_init_schema",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1767510287823,
"when": 1767748212922,
"tag": "0001_lethal_pixie",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767510287823,
"when": 1767748212922,
"tag": "0002_powerful_thundra",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767510287823,
"when": 1767748212922,
"tag": "0003_reading_progress_by_file",
"breakpoints": true
}