feat(library): add creator wizard, folder picker, and full CRUD

This commit is contained in:
neon
2026-01-14 13:13:07 -06:00
parent 8dea90d966
commit 760cc6476f
28 changed files with 2346 additions and 53 deletions
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Folder, FolderOpen, ChevronRight, ChevronUp, Search, X, Check, Loader2, HardDrive } from 'lucide-vue-next'
import { api } from '@/lib/api'
interface DirEntry {
name: string
path: string
}
const emit = defineEmits<{
select: [path: string]
close: []
}>()
const currentPath = ref('/')
const entries = ref<DirEntry[]>([])
const loading = ref(false)
const search = ref('')
const error = ref<string | null>(null)
const filteredEntries = computed(() => {
const q = search.value.trim().toLowerCase()
return q ? entries.value.filter((e) => e.name.toLowerCase().includes(q)) : entries.value
})
const breadcrumbs = computed(() => {
if (currentPath.value === '/') return [{ label: '/', path: '/' }]
const parts = currentPath.value.split('/').filter(Boolean)
const crumbs = [{ label: '/', path: '/' }]
let built = ''
for (const part of parts) {
built += '/' + part
crumbs.push({ label: part, path: built })
}
return crumbs
})
const canGoUp = computed(() => currentPath.value !== '/')
async function navigate(path: string) {
search.value = ''
currentPath.value = path
}
async function goUp() {
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
navigate(parts.length === 0 ? '/' : '/' + parts.join('/'))
}
async function loadEntries(path: string) {
loading.value = true
error.value = null
try {
const res = await api(`/api/path?path=${encodeURIComponent(path)}`)
if (res.ok) {
entries.value = await res.json()
} else {
error.value = 'Could not read directory'
entries.value = []
}
} catch {
error.value = 'Could not connect'
entries.value = []
} finally {
loading.value = false
}
}
watch(currentPath, (p) => loadEntries(p), { immediate: true })
function selectCurrent() {
emit('select', currentPath.value)
}
function selectEntry(entry: DirEntry) {
emit('select', entry.path)
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[80] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="emit('close')" />
<div class="relative flex flex-col w-full max-w-lg bg-background rounded-xl shadow-2xl border border-border overflow-hidden" style="height: min(80vh, 560px)">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<div class="flex items-center gap-2">
<HardDrive :size="15" class="text-primary" />
<span class="text-sm font-semibold text-foreground">Browse folders</span>
</div>
<button class="flex items-center justify-center w-7 h-7 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" @click="emit('close')">
<X :size="14" />
</button>
</div>
<!-- Breadcrumb -->
<div class="flex items-center gap-1 px-4 py-2 border-b border-border bg-muted/30 shrink-0 overflow-x-auto">
<button v-if="canGoUp" class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0 mr-1" :title="'Go up'" @click="goUp">
<ChevronUp :size="13" />
</button>
<template v-for="(crumb, i) in breadcrumbs" :key="crumb.path">
<ChevronRight v-if="i > 0" :size="12" class="text-muted-foreground/50 shrink-0" />
<button
class="text-xs px-1 py-0.5 rounded transition-colors shrink-0 whitespace-nowrap"
:class="i === breadcrumbs.length - 1 ? 'text-foreground font-medium' : 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="navigate(crumb.path)"
>
{{ crumb.label }}
</button>
</template>
</div>
<!-- Search -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<Search :size="13" class="text-muted-foreground shrink-0" />
<input v-model="search" type="text" placeholder="Filter folders…" class="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none" />
<button v-if="search" class="text-muted-foreground hover:text-foreground" @click="search = ''">
<X :size="12" />
</button>
</div>
<!-- Directory list -->
<div class="flex-1 overflow-y-auto">
<div v-if="loading" class="flex items-center justify-center h-full">
<Loader2 :size="20" class="animate-spin text-muted-foreground" />
</div>
<div v-else-if="error" class="flex items-center justify-center h-full">
<p class="text-sm text-muted-foreground">{{ error }}</p>
</div>
<div v-else-if="filteredEntries.length === 0" class="flex items-center justify-center h-full">
<p class="text-sm text-muted-foreground">No folders found</p>
</div>
<div v-else class="py-1">
<button
v-for="entry in filteredEntries"
:key="entry.path"
class="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-muted/60 transition-colors group"
@click="navigate(entry.path)"
>
<Folder :size="15" class="text-primary/70 shrink-0 group-hover:hidden" />
<FolderOpen :size="15" class="text-primary shrink-0 hidden group-hover:block" />
<span class="flex-1 text-sm text-foreground text-left truncate">{{ entry.name }}</span>
<ChevronRight :size="13" class="text-muted-foreground/40 shrink-0" />
</button>
</div>
</div>
<!-- Footer -->
<div class="shrink-0 border-t border-border px-4 py-3 flex items-center justify-between gap-3 bg-muted/20">
<p class="text-xs text-muted-foreground font-mono truncate">{{ currentPath }}</p>
<div class="flex items-center gap-2 shrink-0">
<button class="px-3 py-1.5 rounded-md border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" @click="emit('close')">
Cancel
</button>
<button class="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 transition-opacity" @click="selectCurrent">
<Check :size="12" />
Select this folder
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { UserPlus, Trash2 } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { LibraryAccessEntry } from '@projectx/types'
const props = defineProps<{
libraryId: number | null
}>()
interface UserOption {
id: number
username: string
name: string
}
const accessList = ref<LibraryAccessEntry[]>([])
const availableUsers = ref<UserOption[]>([])
const grantUserId = ref<number | null>(null)
const grantLevel = ref<'viewer' | 'editor' | 'owner'>('viewer')
const loading = ref(false)
async function loadAccess() {
if (!props.libraryId) return
loading.value = true
try {
const [accessRes, usersRes] = await Promise.all([api(`/api/libraries/${props.libraryId}/access`), api('/api/users')])
if (accessRes.ok) accessList.value = await accessRes.json()
if (usersRes.ok) {
const all: UserOption[] = await usersRes.json()
const grantedIds = new Set(accessList.value.map((a) => a.userId))
availableUsers.value = all.filter((u) => !grantedIds.has(u.id))
}
} finally {
loading.value = false
}
}
async function grant() {
if (!props.libraryId || !grantUserId.value) return
await api(`/api/libraries/${props.libraryId}/access`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: grantUserId.value, accessLevel: grantLevel.value }),
})
grantUserId.value = null
await loadAccess()
}
async function changeLevel(userId: number, accessLevel: 'viewer' | 'editor' | 'owner') {
if (!props.libraryId) return
await api(`/api/libraries/${props.libraryId}/access/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessLevel }),
})
await loadAccess()
}
async function revoke(userId: number) {
if (!props.libraryId) return
await api(`/api/libraries/${props.libraryId}/access/${userId}`, { method: 'DELETE' })
await loadAccess()
}
const ACCESS_LEVEL_LABELS = { viewer: 'Viewer', editor: 'Editor', owner: 'Owner' }
onMounted(loadAccess)
</script>
<template>
<div class="px-6 py-6 space-y-5">
<div v-if="!libraryId" class="rounded-lg border border-dashed border-border px-5 py-8 text-center">
<UserPlus :size="22" class="text-muted-foreground/30 mx-auto mb-2" />
<p class="text-sm text-muted-foreground">Access can be configured after the library is created.</p>
</div>
<template v-else>
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Access control</h3>
<p class="text-xs text-muted-foreground mb-4">Superusers always have access. Non-superusers must be granted access explicitly.</p>
<!-- Grant access row -->
<div class="flex gap-2 mb-4">
<select
v-model="grantUserId"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option :value="null" disabled>Select a user…</option>
<option v-for="u in availableUsers" :key="u.id" :value="u.id">{{ u.name }} ({{ u.username }})</option>
</select>
<select
v-model="grantLevel"
class="rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="owner">Owner</option>
</select>
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50 shrink-0"
:disabled="!grantUserId"
@click="grant"
>
<UserPlus :size="14" />
Grant
</button>
</div>
<!-- Access list -->
<div class="rounded-lg border border-border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-muted/50">
<tr>
<th class="px-4 py-3 text-left font-medium text-muted-foreground text-xs">User</th>
<th class="px-4 py-3 text-left font-medium text-muted-foreground text-xs">Access level</th>
<th class="px-4 py-3 w-10" />
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-for="entry in accessList" :key="entry.userId" class="hover:bg-muted/20 transition-colors">
<td class="px-4 py-3">
<p class="font-medium text-foreground">{{ entry.name }}</p>
<p class="text-xs text-muted-foreground">{{ entry.username }}</p>
</td>
<td class="px-4 py-3">
<select
:value="entry.accessLevel"
class="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
@change="changeLevel(entry.userId, ($event.target as HTMLSelectElement).value as 'viewer' | 'editor' | 'owner')"
>
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="owner">Owner</option>
</select>
</td>
<td class="px-4 py-3">
<button
class="p-1.5 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
@click="revoke(entry.userId)"
>
<Trash2 :size="13" />
</button>
</td>
</tr>
<tr v-if="accessList.length === 0">
<td colspan="3" class="px-4 py-6 text-center text-xs text-muted-foreground">No users have been granted access yet.</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import * as LucideIcons from 'lucide-vue-next'
import { Library, Search, X } from 'lucide-vue-next'
import { RecycleScroller } from 'vue-virtual-scroller'
const props = defineProps<{
name: string
icon: string | null
}>()
const emit = defineEmits<{
'update:name': [value: string]
'update:icon': [value: string | null]
}>()
// ── Icon grid ──────────────────────────────────────────────────────────────
const ALL_ICONS: string[] = Object.keys(LucideIcons)
.filter((k) => /^[A-Z]/.test(k) && !k.endsWith('Icon') && !k.startsWith('Lucide'))
.sort()
function getIconComponent(name: string) {
return (LucideIcons as Record<string, unknown>)[name]
}
const COLS = 10
const ROW_HEIGHT = 44
const search = ref('')
const filteredIcons = computed(() => {
const q = search.value.trim().toLowerCase()
return q ? ALL_ICONS.filter((n) => n.toLowerCase().includes(q)) : ALL_ICONS
})
interface IconRow {
id: number
icons: string[]
}
const rows = computed<IconRow[]>(() => {
const result: IconRow[] = []
const list = filteredIcons.value
for (let i = 0; i < list.length; i += COLS) {
result.push({ id: i, icons: list.slice(i, i + COLS) })
}
return result
})
const selectedIconComponent = computed(() => (props.icon ? (LucideIcons as Record<string, unknown>)[props.icon] : null) ?? Library)
</script>
<template>
<div class="px-6 py-6 flex flex-col gap-4 h-full min-h-0">
<!-- Name row with inline icon preview -->
<div>
<label class="block text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Library name</label>
<div class="flex items-center gap-3">
<!-- Icon preview -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<component :is="selectedIconComponent" :size="18" class="text-primary" />
</div>
<!-- Name input -->
<input
type="text"
:value="name"
placeholder="My Library"
maxlength="255"
class="flex-1 rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:ring-2 focus:ring-ring"
@input="emit('update:name', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<!-- Icon picker (fills remaining height) -->
<div class="flex-1 flex flex-col min-h-0">
<div class="flex items-center justify-between mb-2">
<label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Icon</label>
<div class="flex items-center gap-3">
<span v-if="icon" class="text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ icon }}</span>
<button class="ml-1.5 text-muted-foreground hover:text-foreground underline" @click="emit('update:icon', null)">clear</button>
</span>
<span class="text-xs text-muted-foreground">{{ filteredIcons.length.toLocaleString() }} icons</span>
</div>
</div>
<div class="flex flex-col rounded-lg border border-border bg-card overflow-hidden flex-1 min-h-0">
<!-- Search -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
<Search :size="13" class="text-muted-foreground shrink-0" />
<input
v-model="search"
type="text"
placeholder="Search icons…"
class="flex-1 bg-transparent text-xs text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
/>
<button v-if="search" class="text-muted-foreground hover:text-foreground" @click="search = ''">
<X :size="12" />
</button>
</div>
<!-- Grid -->
<RecycleScroller class="flex-1 overflow-y-auto px-2 py-1" :items="rows" :item-size="ROW_HEIGHT" key-field="id">
<template #default="{ item }">
<div class="flex gap-0.5">
<button
v-for="iconName in item.icons"
:key="iconName"
class="flex items-center justify-center rounded-md transition-colors"
style="width: calc(100% / 10); aspect-ratio: 1"
:class="icon === iconName ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground'"
:title="iconName"
@click="emit('update:icon', icon === iconName ? null : iconName)"
>
<component :is="getIconComponent(iconName)" :size="16" />
</button>
</div>
</template>
</RecycleScroller>
</div>
</div>
</div>
</template>
<style>
@import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Plus, Trash2, RefreshCw, FolderOpen, AlertTriangle, CheckCircle2, XCircle, Loader2 } from 'lucide-vue-next'
import type { PrescanResult } from '@projectx/types'
import { api } from '@/lib/api'
import FolderPickerModal from './FolderPickerModal.vue'
const props = defineProps<{
folders: string[]
prescanResult: PrescanResult | null
prescanLoading: boolean
}>()
const emit = defineEmits<{
'update:folders': [value: string[]]
prescan: []
}>()
const pickerOpen = ref(false)
const manualPath = ref('')
const testLoading = ref(false)
const testResult = ref<'ok' | 'error' | null>(null)
function onFolderSelected(path: string) {
pickerOpen.value = false
if (!path || props.folders.includes(path)) return
emit('update:folders', [...props.folders, path])
}
function removeFolder(index: number) {
const updated = [...props.folders]
updated.splice(index, 1)
emit('update:folders', updated)
}
function prescanStatusFor(path: string) {
return props.prescanResult?.paths.find((p) => p.path === path) ?? null
}
function onManualInput() {
testResult.value = null
}
async function testPath() {
const trimmed = manualPath.value.trim()
if (!trimmed) return
testLoading.value = true
testResult.value = null
try {
const res = await api('/api/libraries/prescan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: [trimmed] }),
})
if (res.ok) {
const data: PrescanResult = await res.json()
testResult.value = data.paths[0]?.accessible ? 'ok' : 'error'
} else {
testResult.value = 'error'
}
} catch {
testResult.value = 'error'
} finally {
testLoading.value = false
}
}
function addManual() {
const trimmed = manualPath.value.trim()
if (!trimmed || props.folders.includes(trimmed)) return
emit('update:folders', [...props.folders, trimmed])
manualPath.value = ''
testResult.value = null
}
function onManualKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { e.preventDefault(); addManual() }
}
</script>
<template>
<div class="px-6 py-6 space-y-5">
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Folders</h3>
<p class="text-xs text-muted-foreground mb-4">Add one or more directories to scan for books.</p>
<!-- Browse button -->
<button
class="flex items-center gap-2 w-full rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground hover:text-foreground hover:border-primary/50 hover:bg-muted/40 transition-colors"
@click="pickerOpen = true"
>
<Plus :size="15" class="shrink-0" />
Browse and add a folder
</button>
<!-- Manual entry -->
<div class="mt-3">
<p class="text-xs text-muted-foreground mb-2">Or enter a path manually:</p>
<div class="flex gap-2">
<div class="relative flex-1">
<input
v-model="manualPath"
type="text"
placeholder="/path/to/books"
class="w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground font-mono placeholder:text-muted-foreground/40 placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-ring transition-colors"
:class="testResult === 'ok' ? 'border-emerald-500' : testResult === 'error' ? 'border-destructive' : 'border-border'"
@input="onManualInput"
@keydown="onManualKeydown"
/>
<span v-if="testResult === 'ok'" class="absolute right-2.5 top-1/2 -translate-y-1/2">
<CheckCircle2 :size="14" class="text-emerald-500" />
</span>
<span v-else-if="testResult === 'error'" class="absolute right-2.5 top-1/2 -translate-y-1/2">
<XCircle :size="14" class="text-destructive" />
</span>
</div>
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-50"
:disabled="!manualPath.trim() || testLoading"
@click="testPath"
>
<Loader2 v-if="testLoading" :size="12" class="animate-spin" />
Test
</button>
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-md bg-primary text-primary-foreground text-xs font-medium hover:opacity-90 transition-opacity shrink-0 disabled:opacity-50"
:disabled="!manualPath.trim()"
@click="addManual"
>
<Plus :size="12" />
Add
</button>
</div>
<p v-if="testResult === 'error'" class="mt-1.5 text-xs text-destructive">Path is not accessible on the server.</p>
<p v-else-if="testResult === 'ok'" class="mt-1.5 text-xs text-emerald-600 dark:text-emerald-400">Path is accessible.</p>
</div>
<!-- Folder list -->
<div class="mt-4 space-y-2">
<div v-for="(folder, index) in folders" :key="folder" class="rounded-lg border border-border bg-card px-4 py-3">
<div class="flex items-start gap-3">
<FolderOpen :size="15" class="text-muted-foreground shrink-0 mt-0.5" />
<span class="flex-1 text-sm text-foreground font-mono break-all">{{ folder }}</span>
<button
class="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors shrink-0"
@click="removeFolder(index)"
>
<Trash2 :size="13" />
</button>
</div>
<!-- Prescan result for this path -->
<div v-if="prescanStatusFor(folder)" class="mt-2 ml-6">
<div v-if="!prescanStatusFor(folder)!.accessible" class="flex items-center gap-1.5 text-xs text-destructive">
<XCircle :size="12" />
Path is not accessible
</div>
<div v-else class="flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<CheckCircle2 :size="12" />
{{ prescanStatusFor(folder)!.fileCount }} book file{{ prescanStatusFor(folder)!.fileCount === 1 ? '' : 's' }} found
</span>
<span v-if="prescanStatusFor(folder)!.overlapLibrary" class="flex items-center gap-1 text-amber-600 dark:text-amber-400">
<AlertTriangle :size="12" />
Overlaps with "{{ prescanStatusFor(folder)!.overlapLibrary }}"
</span>
</div>
</div>
</div>
<div v-if="folders.length === 0" class="rounded-lg border border-dashed border-border px-4 py-6 text-center">
<FolderOpen :size="22" class="text-muted-foreground/30 mx-auto mb-2" />
<p class="text-xs text-muted-foreground">No folders added yet</p>
</div>
</div>
<!-- Prescan button -->
<div class="flex items-center justify-between mt-4 pt-4 border-t border-border">
<div v-if="prescanResult" class="text-xs text-muted-foreground">
{{ prescanResult.totalFiles }} total book file{{ prescanResult.totalFiles === 1 ? '' : 's' }} found across all folders
</div>
<div v-else class="text-xs text-muted-foreground">Run a prescan to validate paths before saving.</div>
<button
class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="folders.length === 0 || prescanLoading"
@click="emit('prescan')"
>
<RefreshCw :size="12" :class="prescanLoading ? 'animate-spin' : ''" />
{{ prescanLoading ? 'Scanning…' : 'Prescan' }}
</button>
</div>
</div>
</div>
<FolderPickerModal v-if="pickerOpen" @select="onFolderSelected" @close="pickerOpen = false" />
</template>
@@ -0,0 +1,432 @@
<script setup lang="ts">
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 { useLibraryCreator } from '../composables/useLibraryCreator'
import LibraryCreatorDetails from './LibraryCreatorDetails.vue'
import LibraryCreatorFolders from './LibraryCreatorFolders.vue'
import LibraryCreatorScanner from './LibraryCreatorScanner.vue'
import LibraryCreatorSchedule from './LibraryCreatorSchedule.vue'
import LibraryCreatorAccess from './LibraryCreatorAccess.vue'
const props = defineProps<{
library?: Library | null
}>()
const emit = defineEmits<{
close: []
saved: [library: Library]
}>()
const creator = useLibraryCreator()
const { form, mode, editingLibraryId, loading, prescanLoading, prescanResult, error } = creator
type SectionId = 'details' | 'folders' | 'scanner' | 'schedule' | 'access'
const ALL_SECTIONS: { id: SectionId; label: string; icon: unknown; component: unknown }[] = [
{ id: 'details', label: 'Details', icon: Info, component: LibraryCreatorDetails },
{ id: 'folders', label: 'Folders', icon: FolderOpen, component: LibraryCreatorFolders },
{ id: 'scanner', label: 'Scanner', icon: ScanLine, component: LibraryCreatorScanner },
{ id: 'schedule', label: 'Schedule', icon: Clock, component: LibraryCreatorSchedule },
{ id: 'access', label: 'Access', icon: Users, component: LibraryCreatorAccess },
]
// Access is only meaningful after a library exists
const sections = computed(() => (mode.value === 'create' ? ALL_SECTIONS.slice(0, 4) : ALL_SECTIONS))
// ── Stepper state ──────────────────────────────────────────────────────────
const stepIndex = ref(0)
const visitedUpTo = ref(0) // highest step reached in create mode
const activeSection = computed(() => sections.value[stepIndex.value])
const ActiveComponent = computed(() => activeSection.value?.component)
const activeId = computed(() => activeSection.value?.id ?? 'details')
const isFirstStep = computed(() => stepIndex.value === 0)
const isLastStep = computed(() => stepIndex.value === sections.value.length - 1)
function goTo(index: number) {
if (index < 0 || index >= sections.value.length) return
// In create mode, can only jump to visited steps (not ahead)
if (mode.value === 'create' && index > visitedUpTo.value) return
stepIndex.value = index
mobileView.value = 'content'
}
function next() {
if (isLastStep.value) return
stepIndex.value++
if (stepIndex.value > visitedUpTo.value) visitedUpTo.value = stepIndex.value
mobileView.value = 'content'
}
function back() {
if (isFirstStep.value) {
emit('close')
return
}
stepIndex.value--
mobileView.value = 'content'
}
// ── Mobile ─────────────────────────────────────────────────────────────────
const mobileView = ref<'nav' | 'content'>('nav')
// ── Init ───────────────────────────────────────────────────────────────────
onMounted(() => {
if (props.library) {
creator.initEdit(props.library)
visitedUpTo.value = ALL_SECTIONS.length - 1 // all unlocked in edit
} else {
creator.initCreate()
visitedUpTo.value = 0
}
stepIndex.value = 0
mobileView.value = mode.value === 'create' ? 'content' : 'nav'
})
// ── Save ───────────────────────────────────────────────────────────────────
async function handleSave() {
const saved = await creator.save()
if (saved) emit('saved', saved)
}
// ── Keyboard ───────────────────────────────────────────────────────────────
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
// ── Section props / events ─────────────────────────────────────────────────
const title = computed(() => (props.library ? `Edit: ${props.library.name}` : 'Create Library'))
const sectionProps = computed(() => ({
details: { name: form.name, icon: form.icon },
folders: { folders: form.folders, prescanResult: prescanResult.value, prescanLoading: prescanLoading.value },
scanner: {
metadataPrecedence: form.metadataPrecedence,
formatPriority: form.formatPriority,
allowedFormats: form.allowedFormats,
excludePatterns: form.excludePatterns,
},
schedule: { watch: form.watch, autoScanCronExpression: form.autoScanCronExpression },
access: { libraryId: editingLibraryId.value },
}))
function getSectionProps(id: SectionId) {
return sectionProps.value[id]
}
function onSectionEvent(id: SectionId, event: string, value: unknown) {
if (event === 'update:name') form.name = value as string
else if (event === 'update:icon') form.icon = value as string | null
else if (event === 'update:folders') form.folders = value as string[]
else if (event === 'prescan') creator.runPrescan()
else if (event === 'update:metadataPrecedence') form.metadataPrecedence = value as string[]
else if (event === 'update:formatPriority') form.formatPriority = value as string[]
else if (event === 'update:allowedFormats') form.allowedFormats = value as string[]
else if (event === 'update:excludePatterns') form.excludePatterns = value as string[]
else if (event === 'update:watch') form.watch = value as boolean
else if (event === 'update:autoScanCronExpression') form.autoScanCronExpression = value as string | null
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="emit('close')" />
<div
class="relative flex flex-col w-full max-w-3xl bg-background rounded-xl shadow-2xl overflow-hidden border border-border"
style="height: min(90vh, 680px)"
>
<!-- ── MOBILE: Nav list (edit mode only — create goes straight to content) ── -->
<Transition name="mobile-nav">
<div v-if="mobileView === 'nav'" class="md:hidden absolute inset-0 flex flex-col bg-background z-10">
<div class="flex items-center justify-between px-4 h-14 border-b border-border shrink-0">
<span class="text-base font-semibold text-foreground font-serif">{{ title }}</span>
<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="flex-1 overflow-y-auto">
<button
v-for="(section, i) in sections"
:key="section.id"
class="w-full flex items-center gap-3 px-4 py-4 border-b border-border/60 hover:bg-muted/50 transition-colors"
@click="goTo(i)"
>
<div class="flex items-center justify-center w-8 h-8 rounded-lg bg-primary/10 shrink-0">
<component :is="section.icon" :size="15" class="text-primary" />
</div>
<span class="flex-1 text-sm font-medium text-foreground text-left">{{ section.label }}</span>
</button>
</div>
<div class="px-4 py-4 border-t border-border flex gap-3 justify-end">
<button
class="px-4 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
Cancel
</button>
<button
class="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="loading"
@click="handleSave"
>
{{ loading ? 'Saving…' : 'Save changes' }}
</button>
</div>
</div>
</Transition>
<!-- ── MOBILE: Content ───────────────────────── -->
<Transition name="mobile-content">
<div v-if="mobileView === 'content'" class="md:hidden absolute inset-0 flex flex-col bg-background z-10">
<div class="flex items-center gap-1 px-2 h-14 border-b border-border shrink-0">
<button
v-if="mode === 'edit'"
class="flex items-center gap-1 px-2 py-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="mobileView = 'nav'"
>
<ChevronLeft :size="18" />
<span class="text-sm">Back</span>
</button>
<span class="flex-1 text-sm font-medium text-foreground text-center" :class="mode === 'edit' ? 'pr-16' : ''">
{{ activeSection?.label }}
</span>
<button
v-if="mode === 'create'"
class="flex items-center justify-center w-8 h-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors ml-auto"
@click="emit('close')"
>
<X :size="16" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<component
:is="ActiveComponent"
v-bind="getSectionProps(activeId)"
@update:name="onSectionEvent(activeId, 'update:name', $event)"
@update:icon="onSectionEvent(activeId, 'update:icon', $event)"
@update:folders="onSectionEvent(activeId, 'update:folders', $event)"
@prescan="onSectionEvent(activeId, 'prescan', null)"
@update:metadataPrecedence="onSectionEvent(activeId, 'update:metadataPrecedence', $event)"
@update:formatPriority="onSectionEvent(activeId, 'update:formatPriority', $event)"
@update:allowedFormats="onSectionEvent(activeId, 'update:allowedFormats', $event)"
@update:excludePatterns="onSectionEvent(activeId, 'update:excludePatterns', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
/>
</div>
<div class="shrink-0 px-4 py-4 border-t border-border">
<p v-if="error" class="text-xs text-destructive mb-3">{{ error }}</p>
<div class="flex gap-3" :class="mode === 'create' ? 'justify-between' : 'justify-end'">
<!-- Create mode: Back / Next or Create -->
<template v-if="mode === 'create'">
<button
class="flex items-center gap-1.5 px-4 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="back"
>
<ChevronLeft v-if="!isFirstStep" :size="14" />
{{ isFirstStep ? 'Cancel' : 'Back' }}
</button>
<button
v-if="!isLastStep"
class="flex items-center gap-1.5 px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
@click="next"
>
Next
<ChevronRight :size="14" />
</button>
<button
v-else
class="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="loading"
@click="handleSave"
>
{{ loading ? 'Creating…' : 'Create library' }}
</button>
</template>
<!-- Edit mode: Cancel / Save -->
<template v-else>
<button
class="px-4 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
Cancel
</button>
<button
class="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="loading"
@click="handleSave"
>
{{ loading ? 'Saving…' : 'Save changes' }}
</button>
</template>
</div>
</div>
</div>
</Transition>
<!-- ── DESKTOP: Two-column ───────────────────── -->
<div class="hidden md:flex flex-1 min-h-0">
<!-- Left sidebar -->
<nav class="flex flex-col w-48 shrink-0 bg-muted/40 border-r border-border">
<div class="px-4 pt-5 pb-4 border-b border-border flex items-center justify-between shrink-0">
<span class="text-sm font-semibold text-foreground font-serif truncate">{{ title }}</span>
<button
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0 ml-1"
@click="emit('close')"
>
<X :size="14" />
</button>
</div>
<div class="flex-1 overflow-y-auto py-3 px-2">
<button
v-for="(section, i) in sections"
:key="section.id"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm transition-colors relative"
:class="[
stepIndex === i
? 'bg-background text-foreground font-medium shadow-sm'
: mode === 'create' && i > visitedUpTo
? 'text-muted-foreground/40 cursor-not-allowed'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60 cursor-pointer',
]"
:disabled="mode === 'create' && i > visitedUpTo"
@click="goTo(i)"
>
<!-- Step indicator: number / check / icon -->
<span
v-if="mode === 'create'"
class="flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold shrink-0 transition-colors"
:class="
i < stepIndex
? 'bg-primary text-primary-foreground'
: stepIndex === i
? 'bg-primary/15 text-primary ring-1 ring-primary'
: 'bg-muted-foreground/15 text-muted-foreground/40'
"
>
<Check v-if="i < stepIndex" :size="10" />
<template v-else>{{ i + 1 }}</template>
</span>
<component v-else :is="section.icon" :size="14" :class="stepIndex === i ? 'text-primary' : 'text-muted-foreground/70'" />
{{ section.label }}
<div v-if="stepIndex === i" class="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 rounded-r bg-primary" />
</button>
</div>
</nav>
<!-- Right content -->
<div class="flex flex-col flex-1 min-w-0">
<div class="flex-1 overflow-y-auto">
<component
:is="ActiveComponent"
v-bind="getSectionProps(activeId)"
@update:name="onSectionEvent(activeId, 'update:name', $event)"
@update:icon="onSectionEvent(activeId, 'update:icon', $event)"
@update:folders="onSectionEvent(activeId, 'update:folders', $event)"
@prescan="onSectionEvent(activeId, 'prescan', null)"
@update:metadataPrecedence="onSectionEvent(activeId, 'update:metadataPrecedence', $event)"
@update:formatPriority="onSectionEvent(activeId, 'update:formatPriority', $event)"
@update:allowedFormats="onSectionEvent(activeId, 'update:allowedFormats', $event)"
@update:excludePatterns="onSectionEvent(activeId, 'update:excludePatterns', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
/>
</div>
<!-- Footer -->
<div class="shrink-0 border-t border-border px-6 py-4">
<p v-if="error" class="text-xs text-destructive mb-3">{{ error }}</p>
<!-- Create mode: stepper nav -->
<div v-if="mode === 'create'" class="flex items-center justify-between">
<button
class="flex items-center gap-1.5 px-4 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="back"
>
<ChevronLeft :size="14" />
{{ isFirstStep ? 'Cancel' : 'Back' }}
</button>
<span class="text-xs text-muted-foreground"> Step {{ stepIndex + 1 }} of {{ sections.length }} </span>
<button
v-if="!isLastStep"
class="flex items-center gap-1.5 px-5 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity"
@click="next"
>
Next
<ChevronRight :size="14" />
</button>
<button
v-else
class="px-5 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="loading"
@click="handleSave"
>
{{ loading ? 'Creating…' : 'Create library' }}
</button>
</div>
<!-- Edit mode: cancel + save -->
<div v-else class="flex items-center justify-end gap-3">
<button
class="px-4 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="emit('close')"
>
Cancel
</button>
<button
class="px-5 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="loading"
@click="handleSave"
>
{{ loading ? 'Saving…' : 'Save changes' }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.mobile-nav-enter-active,
.mobile-nav-leave-active,
.mobile-content-enter-active,
.mobile-content-leave-active {
transition:
transform 0.2s ease,
opacity 0.15s ease;
}
.mobile-nav-enter-from {
transform: translateX(-100%);
}
.mobile-nav-leave-to {
transform: translateX(-100%);
}
.mobile-content-enter-from {
transform: translateX(100%);
}
.mobile-content-leave-to {
transform: translateX(100%);
}
</style>
@@ -0,0 +1,212 @@
<script setup lang="ts">
import { ref } from 'vue'
import { GripVertical, Plus, X } from 'lucide-vue-next'
import { METADATA_LABELS, FORMAT_LABELS } from '../composables/useLibraryCreator'
const props = defineProps<{
metadataPrecedence: string[]
formatPriority: string[]
allowedFormats: string[]
excludePatterns: string[]
}>()
const emit = defineEmits<{
'update:metadataPrecedence': [value: string[]]
'update:formatPriority': [value: string[]]
'update:allowedFormats': [value: string[]]
'update:excludePatterns': [value: string[]]
}>()
// ── Drag-and-drop for reorderable lists ──────────────────────────────────────
function useDragList(getList: () => string[], onUpdate: (v: string[]) => void) {
const dragFrom = ref<number | null>(null)
const dragOver = ref<number | null>(null)
function onDragStart(i: number) {
dragFrom.value = i
}
function onDragEnter(i: number) {
dragOver.value = i
}
function onDrop() {
if (dragFrom.value === null || dragOver.value === null || dragFrom.value === dragOver.value) return
const list = [...getList()]
const [item] = list.splice(dragFrom.value, 1)
list.splice(dragOver.value, 0, item)
onUpdate(list)
dragFrom.value = null
dragOver.value = null
}
function onDragEnd() {
dragFrom.value = null
dragOver.value = null
}
return { dragFrom, dragOver, onDragStart, onDragEnter, onDrop, onDragEnd }
}
const metaDrag = useDragList(
() => props.metadataPrecedence,
(v) => emit('update:metadataPrecedence', v),
)
const fmtDrag = useDragList(
() => props.formatPriority,
(v) => emit('update:formatPriority', v),
)
// ── Allowed formats ──────────────────────────────────────────────────────────
const ALL_FORMATS = Object.keys(FORMAT_LABELS)
function toggleAllowedFormat(fmt: string) {
const current = [...props.allowedFormats]
const idx = current.indexOf(fmt)
if (idx === -1) {
current.push(fmt)
} else {
current.splice(idx, 1)
}
emit('update:allowedFormats', current)
}
// ── Exclude patterns ─────────────────────────────────────────────────────────
const newPattern = ref('')
function addPattern() {
const trimmed = newPattern.value.trim()
if (!trimmed || props.excludePatterns.includes(trimmed)) return
emit('update:excludePatterns', [...props.excludePatterns, trimmed])
newPattern.value = ''
}
function removePattern(i: number) {
const updated = [...props.excludePatterns]
updated.splice(i, 1)
emit('update:excludePatterns', updated)
}
function onPatternKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
addPattern()
}
}
</script>
<template>
<div class="px-6 py-6 space-y-8">
<!-- Metadata precedence -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Metadata precedence</h3>
<p class="text-xs text-muted-foreground mb-3">Drag to reorder. The source higher in the list is preferred when both are present.</p>
<div class="rounded-lg border border-border overflow-hidden divide-y divide-border">
<div
v-for="(key, index) in metadataPrecedence"
:key="key"
draggable="true"
class="flex items-center gap-3 px-4 py-3 bg-card cursor-grab active:cursor-grabbing select-none transition-colors"
:class="metaDrag.dragOver.value === index ? 'bg-primary/5' : ''"
@dragstart="metaDrag.onDragStart(index)"
@dragenter.prevent="metaDrag.onDragEnter(index)"
@dragover.prevent
@drop="metaDrag.onDrop()"
@dragend="metaDrag.onDragEnd()"
>
<GripVertical :size="14" class="text-muted-foreground/40 shrink-0" />
<span class="flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-[10px] font-bold shrink-0">
{{ index + 1 }}
</span>
<span class="flex-1 text-sm text-foreground">{{ METADATA_LABELS[key] ?? key }}</span>
</div>
</div>
</div>
<!-- Format priority -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Format priority</h3>
<p class="text-xs text-muted-foreground mb-3">When a book has multiple formats, prefer the format highest in the list.</p>
<div class="rounded-lg border border-border overflow-hidden divide-y divide-border">
<div
v-for="(fmt, index) in formatPriority"
:key="fmt"
draggable="true"
class="flex items-center gap-3 px-4 py-2.5 bg-card cursor-grab active:cursor-grabbing select-none transition-colors"
:class="fmtDrag.dragOver.value === index ? 'bg-primary/5' : ''"
@dragstart="fmtDrag.onDragStart(index)"
@dragenter.prevent="fmtDrag.onDragEnter(index)"
@dragover.prevent
@drop="fmtDrag.onDrop()"
@dragend="fmtDrag.onDragEnd()"
>
<GripVertical :size="14" class="text-muted-foreground/40 shrink-0" />
<span class="flex-1 text-sm font-mono text-foreground uppercase">{{ fmt }}</span>
<span class="text-xs text-muted-foreground">{{ FORMAT_LABELS[fmt] ?? fmt }}</span>
</div>
</div>
</div>
<!-- Allowed formats -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Allowed formats</h3>
<p class="text-xs text-muted-foreground mb-3">
{{ allowedFormats.length === 0 ? 'All formats are allowed.' : 'Only the selected formats will be imported.' }}
</p>
<div class="flex flex-wrap gap-2">
<button
v-for="fmt in ALL_FORMATS"
:key="fmt"
class="px-3 py-1 rounded-full text-xs font-medium border transition-colors"
:class="
allowedFormats.length === 0 || allowedFormats.includes(fmt)
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/50 hover:text-foreground'
"
@click="toggleAllowedFormat(fmt)"
>
{{ fmt.toUpperCase() }}
</button>
</div>
<p v-if="allowedFormats.length > 0" class="mt-2 text-xs text-amber-600 dark:text-amber-400">
Books in other formats already in the library will be marked as missing on the next scan.
</p>
</div>
<!-- Exclude patterns -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-1">Exclude patterns</h3>
<p class="text-xs text-muted-foreground mb-3">
Glob patterns to skip during scanning (e.g. <code class="font-mono bg-muted px-1 rounded">**/samples/**</code>).
</p>
<div class="flex gap-2 mb-3">
<input
v-model="newPattern"
type="text"
placeholder="**/node_modules/**"
class="flex-1 rounded-md border border-border bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-ring"
@keydown="onPatternKeydown"
/>
<button
class="flex items-center gap-1.5 px-3 py-2 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
@click="addPattern"
>
<Plus :size="14" />
Add
</button>
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="(pattern, i) in excludePatterns"
:key="pattern"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-muted border border-border text-xs font-mono text-foreground"
>
{{ pattern }}
<button class="text-muted-foreground hover:text-destructive transition-colors" @click="removePattern(i)">
<X :size="11" />
</button>
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Eye } from 'lucide-vue-next'
const props = defineProps<{
watch: boolean
autoScanCronExpression: string | null
}>()
const emit = defineEmits<{
'update:watch': [value: boolean]
'update:autoScanCronExpression': [value: string | null]
}>()
const PRESETS = [
{ label: 'Never', value: null },
{ label: 'Every hour', value: '0 * * * *' },
{ label: 'Every 6 hours', value: '0 */6 * * *' },
{ label: 'Every 12 hours', value: '0 */12 * * *' },
{ label: 'Daily', value: '0 0 * * *' },
{ label: 'Weekly', value: '0 0 * * 1' },
{ label: 'Custom', value: '__custom__' },
]
const isCustom = computed(() => {
if (props.autoScanCronExpression === null) return false
return !PRESETS.some((p) => p.value === props.autoScanCronExpression)
})
const selectedPreset = computed(() => {
if (props.autoScanCronExpression === null) return null
if (isCustom.value) return '__custom__'
return props.autoScanCronExpression
})
function selectPreset(value: string | null) {
if (value === '__custom__') {
emit('update:autoScanCronExpression', '0 0 * * *')
} else {
emit('update:autoScanCronExpression', value)
}
}
function humanReadableCron(cron: string | null): string {
if (!cron) return 'Auto-scan disabled'
const map: Record<string, string> = {
'0 * * * *': 'Every hour',
'0 */6 * * *': 'Every 6 hours',
'0 */12 * * *': 'Every 12 hours',
'0 0 * * *': 'Daily at midnight',
'0 0 * * 1': 'Weekly on Monday at midnight',
}
return map[cron] ?? `Cron: ${cron}`
}
</script>
<template>
<div class="px-6 py-6 space-y-6">
<!-- Watch folders -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-3">File watching</h3>
<div class="rounded-lg border border-border overflow-hidden divide-y divide-border">
<label class="flex items-center justify-between px-5 py-4 bg-card cursor-pointer">
<div>
<p class="text-sm font-medium text-foreground">Watch folders</p>
<p class="text-xs text-muted-foreground mt-0.5">Automatically detect new files added to library folders.</p>
</div>
<button
role="switch"
:aria-checked="watch"
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
:class="watch ? 'bg-primary' : 'bg-muted-foreground/30'"
@click="emit('update:watch', !watch)"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
:class="watch ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</label>
</div>
</div>
<!-- Auto-scan schedule -->
<div>
<h3 class="text-sm font-semibold text-foreground mb-3">Auto-scan schedule</h3>
<div class="grid grid-cols-3 gap-2 mb-4">
<button
v-for="preset in PRESETS"
:key="String(preset.value)"
class="px-3 py-2 rounded-lg border text-xs font-medium transition-colors"
:class="
selectedPreset === preset.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:border-primary/40 hover:text-foreground'
"
@click="selectPreset(preset.value)"
>
{{ preset.label }}
</button>
</div>
<!-- Custom cron input -->
<div v-if="isCustom || selectedPreset === '__custom__'" class="mt-2">
<label class="block text-xs font-medium text-muted-foreground mb-1.5">Cron expression</label>
<input
type="text"
:value="autoScanCronExpression ?? ''"
placeholder="0 0 * * *"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-ring"
@input="emit('update:autoScanCronExpression', ($event.target as HTMLInputElement).value || null)"
/>
<p class="mt-1 text-xs text-muted-foreground">Format: minute hour day month weekday</p>
</div>
<!-- Human readable preview -->
<div class="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
<Eye :size="12" />
{{ humanReadableCron(autoScanCronExpression) }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,147 @@
import { reactive, ref } from 'vue'
import { api } from '@/lib/api'
import type { Library, OrganizationMode, PrescanResult } from '@projectx/types'
export const DEFAULT_METADATA_PRECEDENCE = ['embedded', 'opfFile']
export const DEFAULT_FORMAT_PRIORITY = ['epub', 'pdf', 'cbz', 'cbr', 'mobi', 'azw3', 'fb2']
export const METADATA_LABELS: Record<string, string> = {
embedded: 'Embedded metadata',
opfFile: 'OPF files',
}
export const FORMAT_LABELS: Record<string, string> = {
epub: 'EPUB',
pdf: 'PDF',
cbz: 'CBZ',
cbr: 'CBR',
mobi: 'MOBI',
azw3: 'AZW3',
fb2: 'FB2',
}
function blankForm() {
return {
name: '',
icon: null as string | null,
displayOrder: 0,
folders: [] as string[],
watch: false,
autoScanCronExpression: null as string | null,
metadataPrecedence: [...DEFAULT_METADATA_PRECEDENCE],
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
allowedFormats: [] as string[],
organizationMode: 'auto' as OrganizationMode,
excludePatterns: [] as string[],
markAsFinishedSecondsRemaining: null as number | null,
markAsFinishedPercentComplete: null as number | null,
}
}
export function useLibraryCreator() {
const form = reactive(blankForm())
const mode = ref<'create' | 'edit'>('create')
const editingLibraryId = ref<number | null>(null)
const loading = ref(false)
const prescanLoading = ref(false)
const prescanResult = ref<PrescanResult | null>(null)
const error = ref<string | null>(null)
function initCreate() {
Object.assign(form, blankForm())
mode.value = 'create'
editingLibraryId.value = null
prescanResult.value = null
error.value = null
}
function initEdit(library: Library) {
form.name = library.name
form.icon = library.icon ?? null
form.displayOrder = library.displayOrder
form.folders = library.folders.map((f) => f.path)
form.watch = library.watch
form.autoScanCronExpression = library.autoScanCronExpression ?? null
form.metadataPrecedence = [...library.metadataPrecedence]
form.formatPriority = [...library.formatPriority]
form.allowedFormats = [...library.allowedFormats]
form.organizationMode = library.organizationMode
form.excludePatterns = [...library.excludePatterns]
form.markAsFinishedSecondsRemaining = library.markAsFinishedSecondsRemaining ?? null
form.markAsFinishedPercentComplete = library.markAsFinishedPercentComplete ?? null
mode.value = 'edit'
editingLibraryId.value = library.id
prescanResult.value = null
error.value = null
}
async function runPrescan() {
if (form.folders.length === 0) return
prescanLoading.value = true
prescanResult.value = null
try {
const res = await api('/api/libraries/prescan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: form.folders }),
})
if (res.ok) {
prescanResult.value = await res.json()
}
} finally {
prescanLoading.value = false
}
}
async function save(): Promise<Library | null> {
if (!form.name.trim()) {
error.value = 'Library name is required'
return null
}
if (form.folders.length === 0) {
error.value = 'At least one folder is required'
return null
}
error.value = null
loading.value = true
try {
const payload = { ...form }
let res: Response
if (mode.value === 'create') {
res = await api('/api/libraries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} else {
res = await api(`/api/libraries/${editingLibraryId.value}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
}
if (!res.ok) {
const body = await res.json().catch(() => ({}))
error.value = body?.message ?? 'Failed to save library'
return null
}
return await res.json()
} finally {
loading.value = false
}
}
return {
form,
mode,
editingLibraryId,
loading,
prescanLoading,
prescanResult,
error,
initCreate,
initEdit,
runPrescan,
save,
}
}
@@ -1,24 +1,37 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { FolderOpen, Plus, RefreshCw } from 'lucide-vue-next'
import { FolderOpen, Plus, RefreshCw, Pencil, Library } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { Library as LibraryType, LibraryStats } from '@projectx/types'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
interface Library {
id: number
name: string
createdAt: string
}
const libraries = ref<Library[]>([])
const libraries = ref<LibraryType[]>([])
const stats = ref<Record<number, LibraryStats>>({})
const scanning = ref<Record<number, boolean>>({})
const scanningAll = ref(false)
const creatorOpen = ref(false)
const editingLibrary = ref<LibraryType | null>(null)
onMounted(async () => {
async function loadLibraries() {
const res = await api('/api/libraries')
if (res.ok) libraries.value = await res.json()
})
if (res.ok) {
libraries.value = await res.json()
loadAllStats()
}
}
async function scan(lib: Library) {
async function loadAllStats() {
await Promise.all(
libraries.value.map(async (lib) => {
const res = await api(`/api/libraries/${lib.id}/stats`)
if (res.ok) stats.value[lib.id] = await res.json()
}),
)
}
onMounted(loadLibraries)
async function scan(lib: LibraryType) {
scanning.value[lib.id] = true
try {
await api(`/api/scanner/libraries/${lib.id}/scan`, { method: 'POST' })
@@ -35,6 +48,35 @@ async function scanAll() {
scanningAll.value = false
}
}
function openCreate() {
editingLibrary.value = null
creatorOpen.value = true
}
function openEdit(lib: LibraryType) {
editingLibrary.value = lib
creatorOpen.value = true
}
async function onSaved(library: LibraryType) {
creatorOpen.value = false
editingLibrary.value = null
await loadLibraries()
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function iconComponent(iconName: string | null | undefined) {
// Dynamic Lucide icon lookup via pre-imported default
return iconName ? null : Library
}
</script>
<template>
@@ -55,9 +97,8 @@ async function scanAll() {
{{ 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"
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"
@click="openCreate"
>
<Plus :size="12" />
Add Library
@@ -73,37 +114,51 @@ async function scanAll() {
<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>
<p class="text-sm font-medium text-foreground truncate">{{ lib.name }}</p>
<div class="flex items-center gap-3 mt-0.5">
<span v-if="stats[lib.id]" class="text-xs text-muted-foreground">
{{ stats[lib.id].totalBooks }} book{{ stats[lib.id].totalBooks === 1 ? '' : 's' }}
<span v-if="stats[lib.id].totalSizeBytes > 0"> &middot; {{ formatBytes(stats[lib.id].totalSizeBytes) }}</span>
</span>
<span v-else class="text-xs text-muted-foreground">
Added {{ new Date(lib.createdAt).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) }}
</span>
<span v-if="lib.watch" class="text-xs text-primary/70">Watching</span>
</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 class="flex items-center gap-1.5 shrink-0">
<button
class="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="Edit library"
@click="openEdit(lib)"
>
<Pencil :size="13" />
</button>
<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="scanning[lib.id]"
@click="scan(lib)"
>
<RefreshCw :size="12" :class="scanning[lib.id] ? 'animate-spin' : ''" />
{{ scanning[lib.id] ? 'Scanning…' : 'Scan' }}
</button>
</div>
</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>
<p class="text-sm text-muted-foreground">No libraries yet.</p>
<button class="mt-3 text-xs text-primary hover:underline" @click="openCreate">Create your first library</button>
</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>
<!-- Library creator/editor modal -->
<LibraryCreatorModal
v-if="creatorOpen"
:library="editingLibrary"
@close="creatorOpen = false; editingLibrary = null"
@saved="onSaved"
/>
</div>
</template>