chore: remove unused files and keep staged settings updates

This commit is contained in:
neon
2026-03-10 22:44:53 -06:00
parent 909806636c
commit 9d25e219ab
25 changed files with 124 additions and 882 deletions
-32
View File
@@ -1,32 +0,0 @@
@layer components {
/* ── Reader viewer chrome ────────────────────────────────── */
.viewer-btn {
@apply flex items-center justify-center w-8 h-8 rounded text-white/60 hover:text-white hover:bg-white/10 transition-colors shrink-0 disabled:opacity-30 disabled:cursor-not-allowed;
}
.viewer-sep {
@apply w-px h-5 mx-1 bg-white/15 shrink-0;
}
/* ── Settings typography scale ──────────────────────────────
Change these once to update every settings panel at once.
────────────────────────────────────────────────────────────── */
.settings-title {
@apply font-serif font-semibold text-foreground text-2xl tracking-tight;
}
.settings-subtitle {
@apply text-sm text-muted-foreground mt-1;
}
.settings-group-label {
@apply text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3;
}
.settings-label {
@apply text-sm font-medium text-foreground;
}
.settings-hint {
@apply text-xs text-muted-foreground mt-0.5;
}
.settings-value {
@apply text-sm font-medium tabular-nums text-foreground;
}
}
-243
View File
@@ -1,243 +0,0 @@
.pattern-preview {
position: relative;
overflow: hidden;
}
.pattern-preview[class*='pattern-']::before {
position: absolute;
z-index: 1;
}
/* ── Fundamental ───────────────────────────────────────────── */
.pattern-dots {
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 24px 24px;
}
.pattern-cross {
background-image: radial-gradient(circle, var(--dot-color) 1px, transparent 1px), radial-gradient(circle, var(--dot-color) 1px, transparent 1px);
background-size: 24px 24px;
background-position:
0 0,
12px 12px;
}
.pattern-terminal::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
opacity: 0.05;
background-image: repeating-linear-gradient(90deg, var(--primary), var(--primary) 1px, transparent 1px, transparent 40px);
}
.pattern-millimeter::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--primary) 5%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 5%, transparent) 1px, transparent 1px);
background-size: 2px 2px;
}
/* ── Structural ────────────────────────────────────────────── */
.pattern-blueprint::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, color-mix(in oklch, var(--primary) 4%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 4%, transparent) 1px, transparent 1px);
background-size: 4px 4px;
}
.pattern-brushed {
background-image: repeating-linear-gradient(
45deg,
transparent,
transparent 1px,
color-mix(in oklch, var(--primary) 6%, transparent) 1px,
color-mix(in oklch, var(--primary) 6%, transparent) 2px
);
}
.pattern-scanlines::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(to bottom, transparent 50%, color-mix(in oklch, var(--primary) 5%, transparent) 50%);
background-size: 100% 4px;
}
.pattern-carbon::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(45deg, color-mix(in oklch, var(--primary) 5%, transparent) 25%, transparent 25%),
linear-gradient(-45deg, color-mix(in oklch, var(--primary) 5%, transparent) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, color-mix(in oklch, var(--primary) 5%, transparent) 75%),
linear-gradient(-45deg, transparent 75%, color-mix(in oklch, var(--primary) 5%, transparent) 75%);
background-size: 8px 8px;
}
.pattern-vinyl::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: repeating-radial-gradient(circle at 50% -25%, transparent, transparent 3px, color-mix(in oklch, var(--primary) 7%, transparent) 3.75px, transparent 4.5px);
}
.pattern-perforated::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image: radial-gradient(circle at center, color-mix(in oklch, var(--primary) 8%, transparent) 1.5px, transparent 2px);
background-size: 12px 12px;
}
/* ── Ambient ───────────────────────────────────────────────── */
.pattern-aurora {
background-image:
radial-gradient(ellipse 60% 45% at 0% 50%, color-mix(in oklch, var(--primary) 15%, transparent), transparent 100%),
radial-gradient(ellipse 50% 40% at 100% 50%, color-mix(in oklch, var(--primary) 10%, transparent), transparent 100%),
radial-gradient(ellipse 40% 35% at 50% 0%, color-mix(in oklch, var(--primary) 8%, transparent), transparent 100%);
}
.pattern-horizon {
background-image: linear-gradient(180deg, transparent 0%, color-mix(in oklch, var(--primary) 8%, transparent) 50%, transparent 100%);
}
.pattern-glow {
background-image: radial-gradient(ellipse 80% 50% at 50% -10%, color-mix(in oklch, var(--primary) 20%, transparent) 0%, transparent 100%);
}
.pattern-mesh {
background-image:
radial-gradient(at 0% 0%, color-mix(in oklch, var(--primary) 20%, transparent) 0px, transparent 50%),
radial-gradient(at 100% 0%, color-mix(in oklch, var(--primary) 15%, transparent) 0px, transparent 50%),
radial-gradient(at 100% 100%, color-mix(in oklch, var(--primary) 20%, transparent) 0px, transparent 50%),
radial-gradient(at 0% 100%, color-mix(in oklch, var(--primary) 15%, transparent) 0px, transparent 50%),
radial-gradient(at 50% 50%, color-mix(in oklch, var(--primary) 10%, transparent) 0px, transparent 50%);
}
.pattern-elevation::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to bottom, color-mix(in oklch, var(--primary) 5%, transparent), transparent 40%),
linear-gradient(to top, color-mix(in oklch, var(--primary) 3%, transparent), transparent 30%);
}
/* ── Refractive ────────────────────────────────────────────── */
.pattern-prism::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(135deg, rgba(255, 0, 0, 0.03) 0%, transparent 50%), linear-gradient(225deg, rgba(0, 0, 255, 0.03) 0%, transparent 50%),
linear-gradient(45deg, rgba(0, 255, 0, 0.03) 0%, transparent 50%);
filter: contrast(150%) brightness(110%);
}
.pattern-spectrum::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(135deg, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
linear-gradient(225deg, oklch(0.7 0.15 calc(var(--tint-h) + 120) / 0.08) 0%, transparent 50%),
linear-gradient(45deg, oklch(0.7 0.15 calc(var(--tint-h) + 240) / 0.08) 0%, transparent 50%);
filter: contrast(160%) brightness(110%);
}
.pattern-spectrum-x::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(135deg, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
linear-gradient(225deg, oklch(0.7 0.15 calc(var(--tint-h) + 90) / 0.08) 0%, transparent 50%),
linear-gradient(315deg, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.08) 0%, transparent 50%),
linear-gradient(45deg, oklch(0.7 0.15 calc(var(--tint-h) + 270) / 0.08) 0%, transparent 50%);
filter: contrast(160%) brightness(110%);
}
.pattern-spectrum-plus::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to bottom, oklch(0.7 0.15 var(--tint-h) / 0.08) 0%, transparent 50%),
linear-gradient(to left, oklch(0.7 0.15 calc(var(--tint-h) + 90) / 0.08) 0%, transparent 50%),
linear-gradient(to top, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.08) 0%, transparent 50%),
linear-gradient(to right, oklch(0.7 0.15 calc(var(--tint-h) + 270) / 0.08) 0%, transparent 50%);
filter: contrast(160%) brightness(110%);
}
.pattern-eclipse::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(at 0% 0%, oklch(0.7 0.15 var(--tint-h) / 0.12) 0%, transparent 65%),
radial-gradient(at 100% 100%, oklch(0.7 0.15 calc(var(--tint-h) + 180) / 0.12) 0%, transparent 65%);
filter: contrast(150%) brightness(110%);
}
.pattern-nova::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(at 50% 50%, oklch(0.7 0.15 var(--tint-h) / 0.12) 0%, transparent 60%),
linear-gradient(to bottom, oklch(0.7 0.15 calc(var(--tint-h) + 150) / 0.08) 0%, transparent 40%),
linear-gradient(to top, oklch(0.7 0.15 calc(var(--tint-h) + 210) / 0.08) 0%, transparent 40%);
filter: contrast(150%) brightness(110%);
}
.pattern-golden-ratio::before {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(at 38.2% 38.2%, oklch(0.7 0.15 var(--tint-h) / 0.1) 0%, transparent 55%),
radial-gradient(at 61.8% 61.8%, oklch(0.7 0.15 calc(var(--tint-h) + 137.5) / 0.1) 0%, transparent 55%),
radial-gradient(at 80% 20%, oklch(0.7 0.15 calc(var(--tint-h) + 275) / 0.08) 0%, transparent 50%);
filter: contrast(150%) brightness(110%);
}
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
@@ -1,87 +0,0 @@
import { ref, computed, type Ref } from 'vue'
import { api } from '@/lib/api'
import { bookCoverStyle } from '../lib/book-cover'
export { bookCoverStyle }
export interface BookFile {
id: number
format: string | null
role: string
}
export interface Book {
id: number
status: string
title: string | null
authors: string[]
seriesName: string | null
seriesIndex: number | null
files: BookFile[]
}
interface BooksPage {
items: Book[]
total: number
page: number
size: number
}
const PAGE_SIZE = 50
export function useBooks(libraryId: Ref<number | null>) {
const books = ref<Book[]>([])
const total = ref(0)
const page = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const search = ref('')
const hasMore = computed(() => books.value.length < total.value)
async function load(reset = false) {
if (loading.value || libraryId.value === null) return
loading.value = true
error.value = null
try {
const currentPage = reset ? 0 : page.value
const params = new URLSearchParams({
libraryId: String(libraryId.value),
page: String(currentPage),
size: String(PAGE_SIZE),
})
if (search.value.trim()) params.set('search', search.value.trim())
const res = await api(`/api/v1/books?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: BooksPage = await res.json()
if (reset) {
books.value = data.items
page.value = 1
} else {
books.value.push(...data.items)
page.value = currentPage + 1
}
total.value = data.total
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load books'
} finally {
loading.value = false
}
}
function clear() {
books.value = []
total.value = 0
page.value = 0
}
function onSearch() {
load(true)
}
return { books, total, loading, error, search, hasMore, load, onSearch, clear }
}
@@ -1,180 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { LayoutGrid, RotateCw, X, ZoomIn } from 'lucide-vue-next'
import { ZOOM_PRESETS } from '../composables/usePdfZoom'
defineProps<{
zoomMode: string
customScale: number
zoomLabel: string
spread: 'none' | 'odd' | 'even'
scrollMode: 'vertical' | 'page'
rotation: 0 | 90 | 180 | 270
}>()
const emit = defineEmits<{
close: []
applyZoomPreset: [value: string]
'update:spread': [v: 'none' | 'odd' | 'even']
'update:scrollMode': [v: 'vertical' | 'page']
rotate: []
}>()
type Tab = 'zoom' | 'layout' | 'display'
const activeTab = ref<Tab>('zoom')
</script>
<template>
<div class="fixed inset-0 z-50 flex flex-col justify-end" @click.self="emit('close')">
<div
class="bg-card text-card-foreground rounded-t-xl max-h-[85vh] overflow-y-auto shadow-2xl border-t border-border w-full max-w-2xl mx-auto"
@click.stop
>
<div class="sticky top-0 bg-card z-10 flex items-center justify-between px-5 py-4 border-b border-border">
<div class="flex gap-1">
<button
v-for="tab in [
{ id: 'zoom', icon: ZoomIn, label: 'Zoom' },
{ id: 'layout', icon: LayoutGrid, label: 'Layout' },
{ id: 'display', icon: RotateCw, label: 'Display' },
] as const"
:key="tab.id"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors"
:class="activeTab === tab.id ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="activeTab = tab.id"
>
<component :is="tab.icon" :size="14" />
{{ tab.label }}
</button>
</div>
<button
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
<X :size="16" />
</button>
</div>
<div class="px-5 py-5 space-y-6">
<template v-if="activeTab === 'zoom'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Preset</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'fit-width'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', 'fit-width')"
>
Fit Width
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'fit-page'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', 'fit-page')"
>
Fit Page
</button>
<button
v-for="preset in ZOOM_PRESETS"
:key="preset.label"
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
zoomMode === 'custom' && customScale === parseFloat(preset.value)
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('applyZoomPreset', preset.value)"
>
{{ preset.label }}
</button>
</div>
</div>
</template>
<template v-if="activeTab === 'layout'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Scroll Mode</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
scrollMode === 'vertical'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:scrollMode', 'vertical')"
>
Scrolled
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
scrollMode === 'page'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:scrollMode', 'page')"
>
Page by Page
</button>
</div>
</div>
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Page Spread</p>
<div class="grid grid-cols-2 gap-2">
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
spread === 'none'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:spread', 'none')"
>
Single Page
</button>
<button
class="py-2.5 rounded-xl text-sm font-medium border-2 transition-colors"
:class="
spread !== 'none'
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground'
"
@click="emit('update:spread', 'odd')"
>
Two-Page
</button>
</div>
</div>
</template>
<template v-if="activeTab === 'display'">
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Rotation</p>
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Rotate Clockwise</p>
<p class="text-xs text-muted-foreground">Current: {{ rotation }}°</p>
</div>
<button
class="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border-2 border-border hover:border-muted-foreground/40 hover:bg-muted text-foreground transition-colors"
@click="emit('rotate')"
>
<RotateCw :size="16" />
Rotate 90°
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -1,41 +0,0 @@
import { ref } from 'vue'
import type * as pdfjsLib from 'pdfjs-dist'
import type { Ref, ShallowRef } from 'vue'
export function usePdfThumbnails(pdfDoc: ShallowRef<pdfjsLib.PDFDocumentProxy | null>, totalPages: Ref<number>) {
const thumbnails = ref(new Map<number, string>())
const pending = new Set<number>()
async function loadThumbnail(pageNum: number): Promise<void> {
const doc = pdfDoc.value
if (!doc || thumbnails.value.has(pageNum) || pending.has(pageNum)) return
if (pageNum < 1 || pageNum > totalPages.value) return
pending.add(pageNum)
try {
const page = await doc.getPage(pageNum)
const vp = page.getViewport({ scale: 0.25 })
const canvas = document.createElement('canvas')
canvas.width = Math.round(vp.width)
canvas.height = Math.round(vp.height)
const ctx = canvas.getContext('2d')
if (!ctx) return
await page.render({ canvasContext: ctx, viewport: vp }).promise
page.cleanup()
thumbnails.value = new Map(thumbnails.value).set(pageNum, canvas.toDataURL('image/jpeg', 0.7))
} catch {
// silently skip failed thumbnails
} finally {
pending.delete(pageNum)
}
}
function loadRange(start: number, end: number) {
const cap = totalPages.value
for (let i = start; i <= Math.min(end, cap); i++) {
loadThumbnail(i)
}
}
return { thumbnails, loadThumbnail, loadRange }
}
@@ -1,4 +1,5 @@
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import { type ReaderFormatGroup, type ReaderSettings, READER_GROUP_DEFAULTS, getFormatGroup } from '@projectx/types'
@@ -191,6 +192,7 @@ export function useReaderDefaultSettings<T extends ReaderSettings>(format: strin
if (syncEnabled.value) {
api(`/api/v1/reader/defaults/${group}`, { method: 'DELETE' }).catch(() => {})
}
toast.success('Settings reset to defaults')
}
return { effective, load, update, reset }
+18 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Plus, Trash2, Copy, Check, Pencil, X, Tablet } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { useKoboDevices } from '@/features/kobo/composables/useKoboDevices'
@@ -22,7 +23,6 @@ const createError = ref<string | null>(null)
// New device token display
const newDeviceToken = ref<string | null>(null)
const newDeviceSyncUrl = ref<string | null>(null)
const tokenCopied = ref(false)
// Rename
const renamingId = ref<number | null>(null)
@@ -79,8 +79,10 @@ async function submitCreate() {
newDeviceSyncUrl.value = `${window.location.origin}/api/v1/kobo/${device.token}`
showCreateForm.value = false
newDeviceName.value = ''
toast.success(`Device "${device.name}" registered`)
} catch (e) {
createError.value = e instanceof Error ? e.message : 'Failed to create device'
toast.error(createError.value ?? 'Failed to create device')
} finally {
creating.value = false
}
@@ -95,14 +97,12 @@ function cancelCreate() {
function dismissToken() {
newDeviceToken.value = null
newDeviceSyncUrl.value = null
tokenCopied.value = false
}
async function copyToken() {
if (!newDeviceSyncUrl.value) return
await navigator.clipboard.writeText(newDeviceSyncUrl.value)
tokenCopied.value = true
setTimeout(() => (tokenCopied.value = false), 2000)
toast.success('Sync URL copied to clipboard')
}
function startRename(device: KoboDevice) {
@@ -120,7 +120,10 @@ async function submitRename(device: KoboDevice) {
renaming.value = true
try {
await renameDevice(device.id, renameValue.value.trim())
toast.success('Device renamed')
renamingId.value = null
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to rename device')
} finally {
renaming.value = false
}
@@ -128,12 +131,18 @@ async function submitRename(device: KoboDevice) {
async function revoke(device: KoboDevice) {
if (!confirm(`Revoke access for "${device.name}"? The device will not be able to sync until re-paired.`)) return
await revokeDevice(device.id)
try {
await revokeDevice(device.id)
toast.success(`Access revoked for "${device.name}"`)
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to revoke access')
}
}
async function saveSettings() {
if (readingThreshold.value >= finishedThreshold.value) {
settingsError.value = 'Reading threshold must be less than finished threshold'
toast.error(settingsError.value ?? 'Failed to save settings')
return
}
savingSettings.value = true
@@ -147,8 +156,10 @@ async function saveSettings() {
forceEnableHyphenation: forceEnableHyphenation.value,
kepubConversionLimitMb: kepubConversionLimitMb.value,
})
toast.success('Kobo sync settings saved')
} catch (e) {
settingsError.value = e instanceof Error ? e.message : 'Failed to save'
toast.error(settingsError.value ?? 'Failed to save settings')
} finally {
savingSettings.value = false
}
@@ -188,8 +199,8 @@ async function saveSettings() {
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
@click="copyToken()"
>
<component :is="tokenCopied ? Check : Copy" :size="12" />
{{ tokenCopied ? 'Copied' : 'Copy' }}
<Copy :size="12" />
Copy
</button>
</div>
<p class="mt-3 text-xs text-muted-foreground leading-relaxed">
@@ -168,7 +168,12 @@ async function onSaved(library: LibraryType) {
creatorOpen.value = false
editingLibrary.value = null
subscribeLibrary(library.id)
if (isNew) pendingNavigateLibraryId.value = library.id
if (isNew) {
pendingNavigateLibraryId.value = library.id
toast.success(`Library "${library.name}" created`)
} else {
toast.success(`Library "${library.name}" updated`)
}
await refreshLibraries()
loadAllStats()
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, FileEdit } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import type { GlobalFileWriteSettings } from '@projectx/types'
@@ -21,8 +22,10 @@ async function rebuildEmbeddings() {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: { queued: number } = await res.json()
queued.value = data.queued
toast.success(`${data.queued} books queued for embedding`)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed'
toast.error(`Failed to rebuild embeddings: ${error.value ?? 'Unknown error'}`)
} finally {
running.value = false
}
@@ -30,7 +33,6 @@ async function rebuildEmbeddings() {
const writeSettings = ref<GlobalFileWriteSettings>(structuredClone(DEFAULT_FILE_WRITE_SETTINGS))
const writeSaving = ref(false)
const writeSaved = ref(false)
const epubMaxMb = computed({
get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)),
@@ -61,7 +63,6 @@ onMounted(async () => {
async function saveWriteSettings() {
if (writeSaving.value) return
writeSaving.value = true
writeSaved.value = false
try {
const res = await api('/api/v1/app-settings/file-write-settings', {
method: 'PUT',
@@ -70,11 +71,12 @@ async function saveWriteSettings() {
})
if (res.ok) {
writeSettings.value = await res.json()
writeSaved.value = true
setTimeout(() => {
writeSaved.value = false
}, 2000)
toast.success('Metadata sync settings saved')
} else {
toast.error('Failed to save metadata sync settings')
}
} catch {
toast.error('Failed to save metadata sync settings')
} finally {
writeSaving.value = false
}
@@ -294,11 +296,6 @@ function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
</template>
</div>
</template>
<div v-if="writeSaved" class="px-5 py-3 flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<Check :size="12" />
Settings saved
</div>
</div>
</div>
</template>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { api } from '@/lib/api'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -19,7 +20,6 @@ const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const saveError = ref<string | null>(null)
const saveSuccess = ref(false)
const testResult = ref<{ success: boolean; message: string } | null>(null)
const form = reactive<OidcConfig>({
@@ -48,7 +48,6 @@ onMounted(async () => {
async function save() {
saveError.value = null
saveSuccess.value = false
saving.value = true
try {
const body: Partial<OidcConfig> = { ...form }
@@ -63,12 +62,10 @@ async function save() {
const err = await res.json().catch(() => ({}))
throw new Error(((err as Record<string, unknown>).message as string) ?? 'Failed to save')
}
saveSuccess.value = true
setTimeout(() => {
saveSuccess.value = false
}, 3000)
toast.success('OIDC settings saved')
} catch (err) {
saveError.value = err instanceof Error ? err.message : 'Failed to save settings'
toast.error(saveError.value)
} finally {
saving.value = false
}
@@ -222,8 +219,7 @@ async function testConnection() {
<button type="submit" :disabled="saving" class="settings-btn-primary">
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
<p v-if="saveSuccess" class="text-sm text-green-600">Saved.</p>
<p v-if="saveError" class="text-sm text-destructive">{{ saveError }}</p>
</div>
</form>
</template>
</template>
+50 -24
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { Plus, Trash2, Copy, Check, Rss } from 'lucide-vue-next'
import { Plus, Trash2, Copy, Rss } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
@@ -14,7 +15,6 @@ const opdsEnabled = ref(true)
const opdsUsers = ref<OpdsUser[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const copied = ref(false)
const showCreateForm = ref(false)
const createUsername = ref('')
@@ -59,18 +59,26 @@ onMounted(async () => {
async function toggleOpds() {
const newVal = !opdsEnabled.value
const res = await api('/api/v1/app-settings/opds_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) opdsEnabled.value = newVal
try {
const res = await api('/api/v1/app-settings/opds_enabled', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) {
opdsEnabled.value = newVal
toast.success(`OPDS server ${newVal ? 'enabled' : 'disabled'}`)
} else {
toast.error('Failed to update OPDS settings')
}
} catch {
toast.error('Failed to update OPDS settings')
}
}
async function copyUrl() {
await navigator.clipboard.writeText(opdsUrl.value)
copied.value = true
setTimeout(() => (copied.value = false), 2000)
toast.success('OPDS URL copied to clipboard')
}
async function createUser() {
@@ -85,6 +93,7 @@ async function createUser() {
if (!res.ok) {
const data = await res.json().catch(() => ({}))
createError.value = data.message ?? 'Failed to create OPDS user'
toast.error(createError.value ?? 'Failed to create OPDS user')
return
}
const user = await res.json()
@@ -93,21 +102,31 @@ async function createUser() {
createUsername.value = ''
createPassword.value = ''
createSortOrder.value = 'recent'
toast.success(`OPDS user "${user.username}" created`)
} catch {
toast.error('Failed to create OPDS user')
} finally {
creating.value = false
}
}
async function updateSortOrder(user: OpdsUser, sortOrder: OpdsSortOrder) {
const res = await api(`/api/v1/opds-users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sortOrder }),
})
if (res.ok) {
const updated = await res.json()
const idx = opdsUsers.value.findIndex((u) => u.id === user.id)
if (idx >= 0) opdsUsers.value[idx] = updated
try {
const res = await api(`/api/v1/opds-users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sortOrder }),
})
if (res.ok) {
const updated = await res.json()
const idx = opdsUsers.value.findIndex((u) => u.id === user.id)
if (idx >= 0) opdsUsers.value[idx] = updated
toast.success(`Sort order updated for "${user.username}"`)
} else {
toast.error('Failed to update sort order')
}
} catch {
toast.error('Failed to update sort order')
}
}
@@ -118,9 +137,16 @@ function cancelCreate() {
async function deleteUser(user: OpdsUser) {
if (!confirm(`Delete OPDS user "${user.username}"? This cannot be undone.`)) return
const res = await api(`/api/v1/opds-users/${user.id}`, { method: 'DELETE' })
if (res.ok) {
opdsUsers.value = opdsUsers.value.filter((u) => u.id !== user.id)
try {
const res = await api(`/api/v1/opds-users/${user.id}`, { method: 'DELETE' })
if (res.ok) {
opdsUsers.value = opdsUsers.value.filter((u) => u.id !== user.id)
toast.success(`OPDS user "${user.username}" deleted`)
} else {
toast.error('Failed to delete OPDS user')
}
} catch {
toast.error('Failed to delete OPDS user')
}
}
</script>
@@ -156,8 +182,8 @@ async function deleteUser(user: OpdsUser) {
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-background hover:bg-muted transition-colors shrink-0"
@click="copyUrl()"
>
<component :is="copied ? Check : Copy" :size="12" />
{{ copied ? 'Copied' : 'Copy' }}
<Copy :size="12" />
Copy
</button>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Monitor, Cloud } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -11,13 +12,20 @@ const syncEnabled = computed(() => user.value?.settings?.syncReaderPreferences ?
async function setStorageMode(sync: boolean) {
if (!user.value || syncEnabled.value === sync) return
const res = await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { syncReaderPreferences: sync } }),
})
if (res.ok) {
user.value = { ...user.value, settings: { ...user.value.settings, syncReaderPreferences: sync } }
try {
const res = await api('/api/v1/users/me', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { syncReaderPreferences: sync } }),
})
if (res.ok) {
user.value = { ...user.value, settings: { ...user.value.settings, syncReaderPreferences: sync } }
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
} else {
toast.error('Failed to update storage mode')
}
} catch {
toast.error('An error occurred while updating storage mode')
}
}
</script>
@@ -1,87 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.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>
<SettingsPageHeader title="Scanner" subtitle="Configure when and how your libraries are scanned for new content." />
<!-- Schedule -->
<div class="mb-6">
<p class="settings-group-label">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="settings-label">Automatic scanning</p>
<p class="settings-hint">Periodically scan libraries for new or changed files</p>
</div>
<ToggleSwitch v-model="autoScan" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card" :class="!autoScan ? 'opacity-40 pointer-events-none' : ''">
<div>
<p class="settings-label">Scan interval</p>
<p class="settings-hint">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="settings-label">Scan on startup</p>
<p class="settings-hint">Run a scan automatically when the server starts</p>
</div>
<ToggleSwitch v-model="scanOnStartup" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Watch for changes</p>
<p class="settings-hint">Detect new files instantly using filesystem events</p>
</div>
<ToggleSwitch v-model="watchForChanges" />
</div>
</div>
</div>
<!-- File types -->
<div>
<p class="settings-group-label">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>
<ToggleSwitch v-model="type.enabled" />
</div>
</div>
</div>
<p class="mt-6 text-xs text-muted-foreground">Scanner configuration will be persisted in a future update.</p>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Loader2 } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import { api } from '@/lib/api'
@@ -39,11 +40,14 @@ onMounted(async () => {
})
async function saveSetting(key: string, value: string) {
await api(`/api/v1/app-settings/${key}`, {
const res = await api(`/api/v1/app-settings/${key}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
toast.error('Failed to save setting')
}
}
async function toggle() {
@@ -56,7 +60,12 @@ async function toggle() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) autoFetch.value = newVal
if (res.ok) {
autoFetch.value = newVal
toast.success(newVal ? 'Auto-fetch enabled' : 'Auto-fetch disabled')
} else {
toast.error('Failed to update setting')
}
} finally {
saving.value = false
}
@@ -72,7 +81,12 @@ async function toggleAutoFinalize() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: String(newVal) }),
})
if (res.ok) autoFinalizeEnabled.value = newVal
if (res.ok) {
autoFinalizeEnabled.value = newVal
toast.success(newVal ? 'Auto-finalize enabled' : 'Auto-finalize disabled')
} else {
toast.error('Failed to update setting')
}
} finally {
saving.value = false
}
@@ -87,15 +101,18 @@ async function onLibraryChange(event: Event) {
saveSetting('staging_auto_finalize_library_id', String(id)),
saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
])
toast.success('Destination library updated')
}
async function onFolderChange(event: Event) {
autoFinalizeFolderId.value = Number((event.target as HTMLSelectElement).value)
await saveSetting('staging_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
toast.success('Destination folder updated')
}
async function onThresholdChange() {
await saveSetting('staging_auto_finalize_threshold', String(autoFinalizeThreshold.value))
toast.success('Confidence threshold updated')
}
</script>
@@ -1,49 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { PackageOpen, ArrowRight, Clock, CheckCircle, AlertCircle } from 'lucide-vue-next'
import { useStagingSummary } from '../composables/useStagingSummary'
const router = useRouter()
const { summary, fetchSummary } = useStagingSummary()
onMounted(() => {
fetchSummary()
})
</script>
<template>
<div
v-if="summary.total > 0"
class="rounded-xl border border-border bg-card/50 p-4 space-y-3 hover:border-primary/30 transition-colors cursor-pointer"
@click="router.push({ name: 'staging' })"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="flex items-center justify-center size-8 rounded-lg bg-primary/10">
<PackageOpen class="size-4 text-primary" />
</div>
<div>
<p class="text-sm font-semibold text-foreground">Staging</p>
<p class="text-xs text-muted-foreground">{{ summary.total }} file{{ summary.total === 1 ? '' : 's' }} awaiting review</p>
</div>
</div>
<ArrowRight class="size-4 text-muted-foreground" />
</div>
<div class="flex items-center gap-4 text-xs">
<div v-if="summary.pending > 0" class="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<Clock class="size-3" />
{{ summary.pending }} pending
</div>
<div v-if="summary.ready > 0" class="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle class="size-3" />
{{ summary.ready }} ready
</div>
<div v-if="summary.error > 0" class="flex items-center gap-1 text-red-500">
<AlertCircle class="size-3" />
{{ summary.error }} error
</div>
</div>
</div>
</template>