mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(library): add creator wizard, folder picker, and full CRUD
This commit is contained in:
@@ -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"> · {{ 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>
|
||||
|
||||
Reference in New Issue
Block a user