feat: enhance metadata fetch and author enrichment config

This commit is contained in:
neon
2026-03-15 21:11:58 -06:00
parent aa2456fbf8
commit 3340ee7388
72 changed files with 4312 additions and 8529 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ export default defineConfigWithVueTs(
},
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
+2
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import AppSidebar from '@/components/AppSidebar.vue'
import AppHeader from '@/components/AppHeader.vue'
import BookMetadataFetchWidget from '@/features/book-metadata-fetch/components/BookMetadataFetchWidget.vue'
import { useThemeStore, BACKGROUND_OPTIONS } from '@/stores/theme'
const route = useRoute()
@@ -34,6 +35,7 @@ const viewKey = computed(() => {
<div class="h-6 w-full" />
</div>
</SidebarInset>
<BookMetadataFetchWidget />
</SidebarProvider>
</template>
@@ -3,6 +3,8 @@ import type { AuthUser, AuthResponse } from '@projectx/types'
import { api, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
import { useSetupStatus } from './useSetupStatus'
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
const user = ref<AuthUser | null>(null)
const isLoading = ref(false)
@@ -10,6 +12,8 @@ const isLoading = ref(false)
function clearAuth() {
user.value = null
setAccessToken(null)
disconnectAuthorEnrichmentSocket()
disconnectBookMetadataFetchSocket()
}
setOnAuthFailure(() => {
@@ -102,7 +102,7 @@ function handleDelete() {
>
<div class="flex items-end justify-between gap-2">
<div class="min-w-0">
<h3 class="line-clamp-2 text-sm font-semibold leading-tight" :style="hasImage ? undefined : { color: fallbackStyle.color }">
<h3 class="line-clamp-2 text-xs leading-tight" :style="hasImage ? undefined : { color: fallbackStyle.color }">
{{ author.name }}
</h3>
</div>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { ref } from 'vue'
import { X, Play, Square, AlertTriangle } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { AuthorEnrichmentStatusEvent } from '@projectx/types'
import { useAuthorEnrichmentActions } from '@/features/settings/composables/useAuthorEnrichmentActions'
const props = defineProps<{
status: AuthorEnrichmentStatusEvent
}>()
const emit = defineEmits<{
close: []
openReport: []
}>()
const { pause, resume, cancelPending } = useAuthorEnrichmentActions()
const acting = ref(false)
const confirmingCancel = ref(false)
async function handlePause() {
if (acting.value) return
acting.value = true
try {
await pause()
} catch {
toast.error('Failed to pause')
} finally {
acting.value = false
}
}
async function handleResume() {
if (acting.value) return
acting.value = true
try {
await resume()
} catch {
toast.error('Failed to resume')
} finally {
acting.value = false
}
}
async function handleCancelConfirm() {
if (acting.value) return
acting.value = true
confirmingCancel.value = false
try {
await cancelPending()
} catch {
toast.error('Failed to cancel')
} finally {
acting.value = false
}
}
</script>
<template>
<div class="flex flex-col gap-3 p-3 min-w-[220px]">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-emerald-500">Author Enrichment</span>
<button class="text-muted-foreground hover:text-foreground" @click="emit('close')">
<X :size="14" />
</button>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<span class="text-muted-foreground">Queued</span>
<span class="text-right font-medium">{{ props.status.queued }}</span>
<span class="text-muted-foreground">Processing</span>
<span class="text-right font-medium">{{ props.status.processing }}</span>
<template v-if="props.status.rateLimited > 0">
<span class="text-muted-foreground">Rate limited</span>
<span class="text-right font-medium text-amber-500">{{ props.status.rateLimited }}</span>
</template>
<span class="text-muted-foreground">Failed</span>
<span class="text-right font-medium" :class="props.status.failed > 0 ? 'text-destructive' : ''">{{ props.status.failed }}</span>
</div>
<div v-if="!confirmingCancel" class="flex gap-2">
<button
v-if="!props.status.paused && (props.status.queued > 0 || props.status.processing > 0 || props.status.rateLimited > 0)"
:disabled="acting"
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
@click="handlePause"
>
<Square :size="12" />
Pause
</button>
<button
v-if="props.status.paused && (props.status.queued > 0 || props.status.processing > 0 || props.status.rateLimited > 0)"
:disabled="acting"
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
@click="handleResume"
>
<Play :size="12" />
Resume
</button>
<button
v-if="props.status.queued > 0 || props.status.rateLimited > 0"
:disabled="acting"
class="px-2 py-1 text-xs rounded border border-border hover:bg-muted text-destructive disabled:opacity-50"
@click="confirmingCancel = true"
>
Cancel queued
</button>
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<AlertTriangle :size="11" class="text-amber-500 shrink-0" />
Currently processing items will finish.
</div>
<div class="flex gap-1.5">
<button
:disabled="acting"
class="px-2 py-1 text-xs rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
@click="handleCancelConfirm"
>
Confirm cancel
</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-muted" @click="confirmingCancel = false">Keep running</button>
</div>
</div>
<button v-if="props.status.failed > 0" class="text-xs text-emerald-500 hover:underline text-left" @click="emit('openReport')">
View {{ props.status.failed }} failed
</button>
</div>
</template>
@@ -0,0 +1,110 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { X, RefreshCw } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { AuthorEnrichmentFailedPage } from '@projectx/types'
import { api } from '@/lib/api'
import { useAuthorEnrichmentActions } from '@/features/settings/composables/useAuthorEnrichmentActions'
const emit = defineEmits<{ close: [] }>()
const { retryFailed } = useAuthorEnrichmentActions()
const page = ref(1)
const limit = 50
const data = ref<AuthorEnrichmentFailedPage | null>(null)
const loading = ref(false)
const retrying = ref(false)
async function loadPage(p: number) {
loading.value = true
try {
const res = await api(`/api/v1/authors/enrichment/failed?page=${p}&limit=${limit}`)
if (res.ok) {
data.value = await res.json()
page.value = p
}
} finally {
loading.value = false
}
}
async function handleRetryAll() {
retrying.value = true
try {
await retryFailed()
toast.success('Failed items requeued')
emit('close')
} catch {
toast.error('Failed to retry')
} finally {
retrying.value = false
}
}
onMounted(() => loadPage(1))
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="emit('close')">
<div class="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<h2 class="text-sm font-semibold">Failed Author Enrichments</h2>
<button class="text-muted-foreground hover:text-foreground" @click="emit('close')">
<X :size="16" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<div v-if="loading" class="p-6 text-center text-sm text-muted-foreground">Loading...</div>
<div v-else-if="!data || data.items.length === 0" class="p-6 text-center text-sm text-muted-foreground">No failed items.</div>
<table v-else class="w-full text-xs">
<thead class="sticky top-0 bg-background border-b border-border">
<tr>
<th class="text-left px-4 py-2 text-muted-foreground font-medium">Author</th>
<th class="text-left px-4 py-2 text-muted-foreground font-medium">Error</th>
<th class="text-right px-4 py-2 text-muted-foreground font-medium">HTTP</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data.items" :key="item.authorId" class="border-b border-border/50 hover:bg-muted/40">
<td class="px-4 py-2 max-w-[200px] truncate">{{ item.name ?? `Author #${item.authorId}` }}</td>
<td class="px-4 py-2 text-muted-foreground max-w-[320px] truncate" :title="item.error ?? ''">{{ item.error ?? '-' }}</td>
<td class="px-4 py-2 text-right text-muted-foreground">{{ item.httpStatus ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="data && data.total > 0" class="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
<div class="flex items-center gap-2">
<button
:disabled="page <= 1"
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
@click="loadPage(page - 1)"
>
Prev
</button>
<span class="text-xs text-muted-foreground">{{ page }} / {{ Math.ceil(data.total / limit) }}</span>
<button
:disabled="page >= Math.ceil(data.total / limit)"
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
@click="loadPage(page + 1)"
>
Next
</button>
</div>
<button
:disabled="retrying"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
@click="handleRetryAll"
>
<RefreshCw :size="12" :class="retrying ? 'animate-spin' : ''" />
Retry all failed
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref } from 'vue'
import { X, Play, Square, AlertTriangle } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { BookMetadataFetchStatusEvent } from '@projectx/types'
import { useBookMetadataFetchActions } from '../composables/useBookMetadataFetchActions'
const props = defineProps<{
status: BookMetadataFetchStatusEvent
}>()
const emit = defineEmits<{
close: []
openReport: []
}>()
const { pause, resume, cancelPending } = useBookMetadataFetchActions()
const acting = ref(false)
const confirmingCancel = ref(false)
async function handlePause() {
if (acting.value) return
acting.value = true
try {
await pause()
} catch {
toast.error('Failed to pause')
} finally {
acting.value = false
}
}
async function handleResume() {
if (acting.value) return
acting.value = true
try {
await resume()
} catch {
toast.error('Failed to resume')
} finally {
acting.value = false
}
}
async function handleCancelConfirm() {
if (acting.value) return
acting.value = true
confirmingCancel.value = false
try {
await cancelPending()
} catch {
toast.error('Failed to cancel')
} finally {
acting.value = false
}
}
</script>
<template>
<div class="flex flex-col gap-3 p-3 min-w-[220px]">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-violet-500">Metadata Fetch</span>
<button class="text-muted-foreground hover:text-foreground" @click="emit('close')">
<X :size="14" />
</button>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<span class="text-muted-foreground">Queued</span>
<span class="text-right font-medium">{{ props.status.queued }}</span>
<span class="text-muted-foreground">Processing</span>
<span class="text-right font-medium">{{ props.status.processing }}</span>
<span class="text-muted-foreground">Failed</span>
<span class="text-right font-medium" :class="props.status.failed > 0 ? 'text-destructive' : ''">{{ props.status.failed }}</span>
</div>
<div v-if="!confirmingCancel" class="flex gap-2">
<button
v-if="!props.status.paused && (props.status.queued > 0 || props.status.processing > 0)"
:disabled="acting"
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
@click="handlePause"
>
<Square :size="12" />
Pause
</button>
<button
v-if="props.status.paused && (props.status.queued > 0 || props.status.processing > 0)"
:disabled="acting"
class="flex items-center gap-1 px-2 py-1 text-xs rounded border border-border hover:bg-muted disabled:opacity-50"
@click="handleResume"
>
<Play :size="12" />
Resume
</button>
<button
v-if="props.status.queued > 0"
:disabled="acting"
class="px-2 py-1 text-xs rounded border border-border hover:bg-muted text-destructive disabled:opacity-50"
@click="confirmingCancel = true"
>
Cancel queued
</button>
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<AlertTriangle :size="11" class="text-amber-500 shrink-0" />
Currently processing items will finish.
</div>
<div class="flex gap-1.5">
<button
:disabled="acting"
class="px-2 py-1 text-xs rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
@click="handleCancelConfirm"
>
Confirm cancel
</button>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-muted" @click="confirmingCancel = false">Keep running</button>
</div>
</div>
<button v-if="props.status.failed > 0" class="text-xs text-violet-500 hover:underline text-left" @click="emit('openReport')">
View {{ props.status.failed }} failed
</button>
</div>
</template>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { X, RefreshCw } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { BookMetadataFetchFailedPage } from '@projectx/types'
import { api } from '@/lib/api'
import { useBookMetadataFetchActions } from '../composables/useBookMetadataFetchActions'
const emit = defineEmits<{ close: [] }>()
const { retryFailed } = useBookMetadataFetchActions()
const page = ref(1)
const limit = 50
const data = ref<BookMetadataFetchFailedPage | null>(null)
const loading = ref(false)
const retrying = ref(false)
async function loadPage(p: number) {
loading.value = true
try {
const res = await api(`/api/v1/book-metadata-fetch/failed?page=${p}&limit=${limit}`)
if (res.ok) {
data.value = await res.json()
page.value = p
}
} finally {
loading.value = false
}
}
async function handleRetryAll() {
retrying.value = true
try {
await retryFailed()
toast.success('Failed items requeued')
emit('close')
} catch {
toast.error('Failed to retry')
} finally {
retrying.value = false
}
}
onMounted(() => loadPage(1))
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="emit('close')">
<div class="bg-background border border-border rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
<h2 class="text-sm font-semibold">Failed Metadata Fetches</h2>
<button @click="emit('close')" class="text-muted-foreground hover:text-foreground">
<X :size="16" />
</button>
</div>
<div class="flex-1 overflow-y-auto">
<div v-if="loading" class="p-6 text-center text-sm text-muted-foreground">Loading...</div>
<div v-else-if="!data || data.items.length === 0" class="p-6 text-center text-sm text-muted-foreground">No failed items.</div>
<table v-else class="w-full text-xs">
<thead class="sticky top-0 bg-background border-b border-border">
<tr>
<th class="text-left px-4 py-2 text-muted-foreground font-medium">Title</th>
<th class="text-left px-4 py-2 text-muted-foreground font-medium">Library</th>
<th class="text-left px-4 py-2 text-muted-foreground font-medium">Error</th>
<th class="text-right px-4 py-2 text-muted-foreground font-medium">HTTP</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data.items" :key="item.bookId" class="border-b border-border/50 hover:bg-muted/40">
<td class="px-4 py-2 max-w-[200px] truncate">{{ item.title ?? `Book #${item.bookId}` }}</td>
<td class="px-4 py-2 text-muted-foreground max-w-[120px] truncate">{{ item.libraryName ?? '-' }}</td>
<td class="px-4 py-2 text-muted-foreground max-w-[240px] truncate" :title="item.error ?? ''">{{ item.error ?? '-' }}</td>
<td class="px-4 py-2 text-right text-muted-foreground">{{ item.httpStatus ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="data && data.total > 0" class="flex items-center justify-between px-4 py-3 border-t border-border shrink-0">
<div class="flex items-center gap-2">
<button
:disabled="page <= 1"
@click="loadPage(page - 1)"
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
>
Prev
</button>
<span class="text-xs text-muted-foreground">{{ page }} / {{ Math.ceil(data.total / limit) }}</span>
<button
:disabled="page >= Math.ceil(data.total / limit)"
@click="loadPage(page + 1)"
class="px-2 py-1 text-xs border border-border rounded hover:bg-muted disabled:opacity-40"
>
Next
</button>
</div>
<button
@click="handleRetryAll"
:disabled="retrying"
class="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-primary text-primary-foreground rounded hover:bg-primary/90 disabled:opacity-50"
>
<RefreshCw :size="12" :class="retrying ? 'animate-spin' : ''" />
Retry all failed
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Sparkles, Users } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import { useBookMetadataFetchStatus } from '../composables/useBookMetadataFetchStatus'
import { useAuthorEnrichmentStatus } from '@/features/settings/composables/useAuthorEnrichmentStatus'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookMetadataFetchExpanded from './BookMetadataFetchExpanded.vue'
import BookMetadataFetchReportModal from './BookMetadataFetchReportModal.vue'
import AuthorEnrichmentExpanded from './AuthorEnrichmentExpanded.vue'
import AuthorEnrichmentReportModal from './AuthorEnrichmentReportModal.vue'
const { status, subscribe } = useBookMetadataFetchStatus()
const { status: authorStatus, subscribe: subscribeAuthors } = useAuthorEnrichmentStatus()
const { hasPermission, isSuperuser } = usePermissions()
const canView = computed(() => isSuperuser.value || hasPermission('manage_metadata_config'))
// Sticky last-known name: keep showing the previous name during the gap between items.
// Reset only when the session fully ends (sessionTotal goes back to 0).
const lastBookName = ref<string | null>(null)
const lastAuthorName = ref<string | null>(null)
watch(
() => status.value.currentItemName,
(name) => {
if (name) lastBookName.value = name
},
)
watch(
() => status.value.sessionTotal,
(total) => {
if (total === 0) lastBookName.value = null
},
)
watch(
() => authorStatus.value.currentItemName,
(name) => {
if (name) lastAuthorName.value = name.trim()
},
)
const isBookFetchActive = computed(() => status.value.queued + status.value.processing + status.value.failed > 0)
const isAuthorActive = computed(
() => authorStatus.value.queued + authorStatus.value.processing + authorStatus.value.rateLimited + authorStatus.value.failed > 0,
)
watch(isAuthorActive, (active) => {
if (!active) lastAuthorName.value = null
})
const isAnyActive = computed(() => isBookFetchActive.value || isAuthorActive.value)
const bookProgressPercent = computed(() => {
if (status.value.sessionTotal === 0) return 0
return Math.round((status.value.sessionDone / status.value.sessionTotal) * 100)
})
const authorProgressPercent = computed(() => {
if (authorStatus.value.sessionTotal === 0) return 0
return Math.round((authorStatus.value.sessionDone / authorStatus.value.sessionTotal) * 100)
})
const bookRemainingLabel = computed(() => {
if (status.value.paused) return 'Paused'
const { queued, processing, failed } = status.value
if (queued > 0) return `${queued + processing} remaining`
if (processing > 0) return `${processing} processing`
if (failed > 0) return `${failed} failed`
return ''
})
const authorRemainingLabel = computed(() => {
if (authorStatus.value.paused) return 'Paused'
const { queued, processing, rateLimited, failed } = authorStatus.value
if (rateLimited > 0) return `${rateLimited} rate limited`
if (queued > 0) return `${queued + processing} remaining`
if (processing > 0) return `${processing} processing`
if (failed > 0) return `${failed} failed`
return ''
})
const bookIconWrapperClass = computed(() => {
if (status.value.failed > 0 && status.value.processing === 0 && status.value.queued === 0) return 'bg-amber-500/15'
return 'bg-violet-500/10'
})
const bookIconOpacity = computed(() => (status.value.paused ? 'opacity-40' : ''))
const showBookDot = computed(() => !status.value.paused && (status.value.processing > 0 || status.value.failed > 0))
const authorIconWrapperClass = computed(() => {
if ((authorStatus.value.rateLimited > 0 || authorStatus.value.failed > 0) && authorStatus.value.processing === 0 && authorStatus.value.queued === 0)
return 'bg-amber-500/15'
return 'bg-emerald-500/10'
})
const authorIconOpacity = computed(() => (authorStatus.value.paused ? 'opacity-40' : ''))
const showAuthorDot = computed(
() => !authorStatus.value.paused && (authorStatus.value.processing > 0 || authorStatus.value.rateLimited > 0 || authorStatus.value.failed > 0),
)
// completion toast - fires only when transitioning from active to done, not on the initial snapshot
let bookSessionToasted = false
watch(status, (newVal, oldVal) => {
const wasActive = oldVal.queued > 0 || oldVal.processing > 0
const isDone = newVal.sessionTotal > 0 && newVal.sessionDone >= newVal.sessionTotal && newVal.queued === 0 && newVal.processing === 0
if (!bookSessionToasted && wasActive && isDone) {
bookSessionToasted = true
const msg =
newVal.failed > 0
? `Metadata fetch done - ${newVal.sessionDone} processed, ${newVal.failed} failed`
: `Metadata fetch complete - ${newVal.sessionDone} books updated`
toast.success(msg)
}
if (newVal.sessionTotal === 0) bookSessionToasted = false
})
let authorSessionToasted = false
watch(authorStatus, (newVal, oldVal) => {
const wasActive = oldVal.queued > 0 || oldVal.processing > 0 || oldVal.rateLimited > 0
const isDone =
newVal.sessionTotal > 0 && newVal.sessionDone >= newVal.sessionTotal && newVal.queued === 0 && newVal.processing === 0 && newVal.rateLimited === 0
if (!authorSessionToasted && wasActive && isDone) {
authorSessionToasted = true
const msg =
newVal.failed > 0
? `Author enrichment done - ${newVal.sessionDone} processed, ${newVal.failed} failed`
: `Author enrichment complete - ${newVal.sessionDone} authors updated`
toast.success(msg)
}
if (newVal.sessionTotal === 0) authorSessionToasted = false
})
const expanded = ref(false)
const showReport = ref(false)
const authorExpanded = ref(false)
const showAuthorReport = ref(false)
function toggleExpanded() {
expanded.value = !expanded.value
if (expanded.value) authorExpanded.value = false
}
function handleClose() {
expanded.value = false
}
function handleOpenReport() {
expanded.value = false
showReport.value = true
}
function toggleAuthorExpanded() {
authorExpanded.value = !authorExpanded.value
if (authorExpanded.value) expanded.value = false
}
function handleAuthorClose() {
authorExpanded.value = false
}
function handleOpenAuthorReport() {
authorExpanded.value = false
showAuthorReport.value = true
}
onMounted(() => {
if (canView.value) {
subscribe()
subscribeAuthors()
}
})
</script>
<template>
<div v-if="canView && isAnyActive" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<!-- Book metadata expanded panel -->
<div v-if="expanded" class="rounded-lg border border-border bg-popover shadow-xl">
<BookMetadataFetchExpanded :status="status" @close="handleClose" @open-report="handleOpenReport" />
</div>
<!-- Author enrichment expanded panel -->
<div v-if="authorExpanded" class="rounded-lg border border-border bg-popover shadow-xl">
<AuthorEnrichmentExpanded :status="authorStatus" @close="handleAuthorClose" @open-report="handleOpenAuthorReport" />
</div>
<!-- Book metadata fetch card -->
<button
v-if="isBookFetchActive"
class="relative overflow-hidden flex items-center gap-3 px-4 py-3 rounded-xl bg-card border border-border shadow-xl text-sm transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] w-60"
:class="status.paused ? 'opacity-70' : ''"
@click="toggleExpanded"
>
<div class="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors duration-300" :class="bookIconWrapperClass">
<Sparkles :size="20" class="text-violet-500" :class="[bookIconOpacity, showBookDot ? 'animate-pulse' : '']" />
</div>
<div class="flex flex-col items-start gap-0.5 min-w-0">
<span class="text-xs font-semibold text-foreground leading-none">Fetching metadata</span>
<span class="text-xs text-muted-foreground leading-none">{{ bookRemainingLabel }}</span>
<span class="text-xs text-muted-foreground/60 leading-none truncate mt-0.5" :class="lastBookName ? '' : 'invisible'">{{
lastBookName ?? '\u00a0'
}}</span>
</div>
<!-- bottom progress bar -->
<div v-if="status.sessionTotal > 0" class="absolute bottom-0 left-0 right-0 h-[3px] bg-muted">
<div class="h-full bg-violet-500 transition-all duration-500" :style="{ width: `${bookProgressPercent}%` }" />
</div>
</button>
<!-- Author enrichment card -->
<button
v-if="isAuthorActive"
class="relative overflow-hidden flex items-center gap-3 px-4 py-3 rounded-xl bg-card border border-border shadow-xl text-sm transition-all duration-300 hover:shadow-2xl hover:scale-[1.02] w-60"
:class="authorStatus.paused ? 'opacity-70' : ''"
@click="toggleAuthorExpanded"
>
<div
class="relative flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors duration-300"
:class="authorIconWrapperClass"
>
<Users :size="20" class="text-emerald-500" :class="[authorIconOpacity, showAuthorDot ? 'animate-pulse' : '']" />
</div>
<div class="flex flex-col items-start gap-0.5 min-w-0">
<span class="text-xs font-semibold text-foreground leading-none">Enriching authors</span>
<span class="text-xs text-muted-foreground leading-none">{{ authorRemainingLabel }}</span>
<span class="text-xs text-muted-foreground/60 leading-none truncate mt-0.5" :class="lastAuthorName ? '' : 'invisible'">{{
lastAuthorName ?? '\u00a0'
}}</span>
</div>
<!-- bottom progress bar -->
<div v-if="authorStatus.sessionTotal > 0" class="absolute bottom-0 left-0 right-0 h-[3px] bg-muted">
<div class="h-full bg-emerald-500 transition-all duration-500" :style="{ width: `${authorProgressPercent}%` }" />
</div>
</button>
<BookMetadataFetchReportModal v-if="showReport" @close="showReport = false" />
<AuthorEnrichmentReportModal v-if="showAuthorReport" @close="showAuthorReport = false" />
</div>
</template>
@@ -0,0 +1,38 @@
import { api } from '@/lib/api'
export function useBookMetadataFetchActions() {
async function triggerGlobal(): Promise<{ queued: number }> {
const res = await api('/api/v1/book-metadata-fetch/run', { method: 'POST' })
if (!res.ok) throw new Error('Failed to trigger run')
return res.json()
}
async function triggerForLibrary(libraryId: number): Promise<{ queued: number }> {
const res = await api(`/api/v1/book-metadata-fetch/run/${libraryId}`, { method: 'POST' })
if (!res.ok) throw new Error('Failed to trigger run')
return res.json()
}
async function pause(): Promise<void> {
const res = await api('/api/v1/book-metadata-fetch/pause', { method: 'POST' })
if (!res.ok) throw new Error('Failed to pause')
}
async function resume(): Promise<void> {
const res = await api('/api/v1/book-metadata-fetch/resume', { method: 'POST' })
if (!res.ok) throw new Error('Failed to resume')
}
async function cancelPending(): Promise<void> {
const res = await api('/api/v1/book-metadata-fetch/cancel', { method: 'POST' })
if (!res.ok) throw new Error('Failed to cancel')
}
async function retryFailed(): Promise<{ requeued: number }> {
const res = await api('/api/v1/book-metadata-fetch/retry-failed', { method: 'POST' })
if (!res.ok) throw new Error('Failed to retry')
return res.json()
}
return { triggerGlobal, triggerForLibrary, pause, resume, cancelPending, retryFailed }
}
@@ -0,0 +1,51 @@
import { ref } from 'vue'
import type { BookMetadataFetchConfig, BookMetadataFetchConfigOverride, BookMetadataFetchLibraryConfig } from '@projectx/types'
import { api } from '@/lib/api'
const globalConfig = ref<BookMetadataFetchConfig | null>(null)
export function useBookMetadataFetchConfig() {
async function fetchGlobalConfig(): Promise<BookMetadataFetchConfig> {
const res = await api('/api/v1/book-metadata-fetch/config')
if (!res.ok) throw new Error('Failed to load config')
const data: BookMetadataFetchConfig = await res.json()
globalConfig.value = data
return data
}
async function saveGlobalConfig(config: BookMetadataFetchConfig): Promise<BookMetadataFetchConfig> {
const res = await api('/api/v1/book-metadata-fetch/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!res.ok) throw new Error('Failed to save config')
const data: BookMetadataFetchConfig = await res.json()
globalConfig.value = data
return data
}
async function fetchLibraryConfig(libraryId: number): Promise<BookMetadataFetchLibraryConfig> {
const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`)
if (!res.ok) throw new Error('Failed to load library config')
return res.json()
}
async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise<BookMetadataFetchConfig> {
const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(override ?? {}),
})
if (!res.ok) throw new Error('Failed to save library config')
return res.json()
}
return {
globalConfig,
fetchGlobalConfig,
saveGlobalConfig,
fetchLibraryConfig,
saveLibraryConfig,
}
}
@@ -0,0 +1,58 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import type { BookMetadataFetchStatusEvent } from '@projectx/types'
import { getAccessToken } from '@/lib/api'
const status = ref<BookMetadataFetchStatusEvent>({
queued: 0,
processing: 0,
failed: 0,
paused: false,
sessionTotal: 0,
sessionDone: 0,
currentItemName: null,
})
const socketConnected = ref(true)
let socket: Socket | null = null
function getSocket(): Socket {
if (!socket) {
socket = io('/book-metadata-fetch', {
auth: (cb: (data: object) => void) => cb({ token: getAccessToken() }),
transports: ['websocket'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
})
socket.on('book-metadata-fetch:status', (data: BookMetadataFetchStatusEvent) => {
status.value = data
})
socket.on('connect', () => {
socketConnected.value = true
})
socket.on('disconnect', () => {
socketConnected.value = false
})
}
return socket
}
export function useBookMetadataFetchStatus() {
function subscribe() {
getSocket()
}
return { status, socketConnected, subscribe }
}
export function disconnectBookMetadataFetchSocket() {
if (socket) {
socket.disconnect()
socket = null
}
status.value = { queued: 0, processing: 0, failed: 0, paused: false, sessionTotal: 0, sessionDone: 0, currentItemName: null }
socketConnected.value = true
}
@@ -0,0 +1,44 @@
import { ref, watch, type Ref } from 'vue'
import type { BookMetadataFetchConditions } from '@projectx/types'
import { api } from '@/lib/api'
export function useEligibleCountPreview(conditions: Ref<BookMetadataFetchConditions | null>, libraryId?: number) {
const count = ref<number | null>(null)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchCount() {
const current = conditions.value
if (!current) {
count.value = null
return
}
loading.value = true
try {
const body: { conditions: BookMetadataFetchConditions; libraryId?: number } = { conditions: current }
if (libraryId !== undefined) body.libraryId = libraryId
const res = await api('/api/v1/book-metadata-fetch/preview-count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const data: { count: number } = await res.json()
count.value = data.count
}
} finally {
loading.value = false
}
}
watch(
conditions,
() => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(fetchCount, 400)
},
{ deep: true, immediate: true },
)
return { count, loading }
}
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ProvidersTab from './ProvidersTab.vue'
import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import RecipientsTab from './RecipientsTab.vue'
@@ -19,7 +20,40 @@ const { fetchGroups } = useEmailGroups()
type Tab = 'providers' | 'recipients' | 'groups' | 'templates' | 'preferences' | 'history'
const activeTab = ref<Tab>('providers')
const route = useRoute()
const router = useRouter()
function normalizeTab(value: unknown): Tab {
if (
value === 'providers' ||
value === 'recipients' ||
value === 'groups' ||
value === 'templates' ||
value === 'preferences' ||
value === 'history'
)
return value
return 'providers'
}
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
if (!route.query.tab) {
router.replace({ name: 'settings-email', query: { ...route.query, tab: activeTab.value } })
}
watch(
() => route.query.tab,
(value) => {
activeTab.value = normalizeTab(value)
},
)
function selectTab(tab: Tab) {
activeTab.value = tab
router.replace({ name: 'settings-email', query: { ...route.query, tab } })
}
const loading = ref(true)
const error = ref<string | null>(null)
@@ -60,7 +94,7 @@ onMounted(async () => {
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
"
@click="activeTab = tab.id"
@click="selectTab(tab.id)"
>
{{ tab.label }}
</button>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RefreshCw, Save } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import type { AuthorAutoEnrichmentConfig } from '@projectx/types'
import { api } from '@/lib/api'
import { useAuthorEligibleCountPreview } from './composables/useAuthorEligibleCountPreview'
const DEFAULT_CONFIG: AuthorAutoEnrichmentConfig = {
enabled: false,
triggerOnImport: true,
writeMode: 'missing_only',
conditions: { neverEnriched: true, missingBio: false, missingPhoto: false },
}
const config = ref<AuthorAutoEnrichmentConfig>({ ...DEFAULT_CONFIG, conditions: { ...DEFAULT_CONFIG.conditions } })
const saving = ref(false)
const authorBackfillRunning = ref(false)
const authorBackfillAllRunning = ref(false)
const eligibleConditions = computed(() => config.value.conditions)
const { count: eligibleCount, loading: countLoading } = useAuthorEligibleCountPreview(eligibleConditions)
onMounted(async () => {
const res = await api('/api/v1/authors/enrichment/config')
if (res.ok) {
config.value = await res.json()
}
})
async function saveConfig() {
if (saving.value) return
saving.value = true
try {
const res = await api('/api/v1/authors/enrichment/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config.value),
})
if (res.ok) {
config.value = await res.json()
toast.success('Author enrichment settings saved')
} else {
toast.error('Failed to save author enrichment settings')
}
} catch {
toast.error('Failed to save author enrichment settings')
} finally {
saving.value = false
}
}
function toggleEnabled() {
config.value = { ...config.value, enabled: !config.value.enabled }
}
function toggleTriggerOnImport() {
config.value = { ...config.value, triggerOnImport: !config.value.triggerOnImport }
}
function toggleCondition(key: keyof AuthorAutoEnrichmentConfig['conditions']) {
config.value = { ...config.value, conditions: { ...config.value.conditions, [key]: !config.value.conditions[key] } }
}
function onWriteModeChange(event: Event) {
const mode = (event.target as HTMLSelectElement).value as AuthorAutoEnrichmentConfig['writeMode']
config.value = { ...config.value, writeMode: mode }
}
async function runAuthorBackfill() {
if (authorBackfillRunning.value) return
authorBackfillRunning.value = true
try {
const res = await api('/api/v1/authors/enrichment/backfill', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { queued } = await res.json()
if (queued === 0) toast.info('No eligible authors found')
} catch {
toast.error('Failed to queue author backfill')
} finally {
authorBackfillRunning.value = false
}
}
async function runAuthorBackfillAll() {
if (authorBackfillAllRunning.value) return
authorBackfillAllRunning.value = true
try {
const res = await api('/api/v1/authors/enrichment/backfill-all', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const { queued } = await res.json()
if (queued === 0) toast.info('No authors to enrich')
} catch {
toast.error('Failed to queue author backfill')
} finally {
authorBackfillAllRunning.value = false
}
}
</script>
<template>
<p class="settings-group-label">Configuration</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="px-5 py-4 flex items-center justify-between gap-6 bg-card">
<div>
<p class="settings-label">Enable auto author enrichment</p>
<p class="settings-hint">Automatically fetch author biographies and profile photos when books are added or updated.</p>
</div>
<ToggleSwitch :model-value="config.enabled" :disabled="saving" @update:model-value="toggleEnabled" />
</div>
<div class="px-5 py-4 flex items-center justify-between gap-6 bg-card">
<div>
<p class="settings-label">Update strategy</p>
<p class="settings-hint">What to do when an author already has a biography or photo.</p>
</div>
<select class="select-field w-64" :value="config.writeMode" :disabled="saving" @change="onWriteModeChange">
<option value="missing_only">Only fill missing data (recommended)</option>
<option value="always_refetch">Always refresh existing data</option>
</select>
</div>
<template v-if="config.enabled">
<div class="px-5 py-4 flex items-center justify-between gap-6 bg-card">
<div>
<p class="settings-label">Trigger on import</p>
<p class="settings-hint">Queue authors for enrichment when books are first added to a library.</p>
</div>
<ToggleSwitch :model-value="config.triggerOnImport" :disabled="saving" @update:model-value="toggleTriggerOnImport" />
</div>
</template>
<div class="px-5 py-4 bg-card">
<p class="settings-label mb-1">Eligibility conditions</p>
<p class="settings-hint mb-3">An author is eligible if it matches any enabled condition.</p>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
class="w-4 h-4 rounded border-border accent-primary"
:checked="config.conditions.neverEnriched"
:disabled="saving"
@change="toggleCondition('neverEnriched')"
/>
<div>
<span class="text-sm font-medium">Never enriched</span>
<p class="text-xs text-muted-foreground">Enrich authors that have never had a successful enrichment.</p>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
class="w-4 h-4 rounded border-border accent-primary"
:checked="config.conditions.missingBio"
:disabled="saving"
@change="toggleCondition('missingBio')"
/>
<div>
<span class="text-sm font-medium">Missing bio</span>
<p class="text-xs text-muted-foreground">Enrich if the author has no biography.</p>
</div>
</label>
<label class="flex items-center gap-3 cursor-pointer select-none">
<input
type="checkbox"
class="w-4 h-4 rounded border-border accent-primary"
:checked="config.conditions.missingPhoto"
:disabled="saving"
@change="toggleCondition('missingPhoto')"
/>
<div>
<span class="text-sm font-medium">Missing photo</span>
<p class="text-xs text-muted-foreground">Enrich if the author has no profile photo.</p>
</div>
</label>
</div>
</div>
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<button class="settings-btn-primary" :disabled="saving" @click="saveConfig">
<Save class="size-3.5" />
{{ saving ? 'Saving...' : 'Save' }}
</button>
<div class="w-px h-4 bg-border shrink-0" />
<button class="settings-btn-outline" :disabled="authorBackfillRunning" @click="runAuthorBackfill">
<RefreshCw :size="13" :class="authorBackfillRunning ? 'animate-spin' : ''" />
{{ authorBackfillRunning ? 'Running...' : 'Run for eligible authors' }}
</button>
<span v-if="eligibleCount !== null" class="text-xs text-muted-foreground">
{{ countLoading ? '...' : `~${eligibleCount} eligible` }}
</span>
<div class="w-px h-4 bg-border shrink-0" />
<button class="settings-btn-outline" :disabled="authorBackfillAllRunning" @click="runAuthorBackfillAll">
<RefreshCw :size="13" :class="authorBackfillAllRunning ? 'animate-spin' : ''" />
{{ authorBackfillAllRunning ? 'Running...' : 'Run for all authors' }}
</button>
</div>
</div>
</template>
@@ -1,14 +1,9 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles, FileEdit } from 'lucide-vue-next'
import { ref, onMounted } from 'vue'
import { Check, RefreshCw, Sparkles } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
import type { AuthorAutoEnrichmentWriteMode, GlobalFileWriteSettings } from '@projectx/types'
import { DEFAULT_FILE_WRITE_SETTINGS } from '@projectx/types'
import { api } from '@/lib/api'
import { useAuthorEnrichmentStatus } from './composables/useAuthorEnrichmentStatus'
const running = ref(false)
const queued = ref<number | null>(null)
@@ -32,159 +27,7 @@ async function rebuildEmbeddings() {
}
}
const writeSettings = ref<GlobalFileWriteSettings>(structuredClone(DEFAULT_FILE_WRITE_SETTINGS))
const writeSaving = ref(false)
const authorAutoEnabled = ref(true)
const authorWriteMode = ref<AuthorAutoEnrichmentWriteMode>('missing_only')
const authorProviderEnabled = ref(true)
const authorSaving = ref(false)
const authorBackfillRunning = ref(false)
const { status: authorEnrichmentStatus, subscribe: subscribeAuthorEnrichmentStatus } = useAuthorEnrichmentStatus()
const isAuthorEnrichmentTaskRunning = computed(
() => authorEnrichmentStatus.value.processing > 0 || authorEnrichmentStatus.value.queued > 0 || authorEnrichmentStatus.value.rateLimited > 0,
)
const epubMaxMb = computed({
get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.epub.maxFileSizeBytes = mb * 1024 * 1024
},
})
const pdfMaxMb = computed({
get: () => Math.round(writeSettings.value.pdf.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.pdf.maxFileSizeBytes = mb * 1024 * 1024
},
})
const cbxMaxMb = computed({
get: () => Math.round(writeSettings.value.cbx.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.cbx.maxFileSizeBytes = mb * 1024 * 1024
},
})
onMounted(async () => {
subscribeAuthorEnrichmentStatus()
const [writeRes, settingsRes] = await Promise.all([api('/api/v1/app-settings/file-write-settings'), api('/api/v1/app-settings')])
if (writeRes.ok) {
writeSettings.value = await writeRes.json()
}
if (settingsRes.ok) {
const settings: { key: string; value: string }[] = await settingsRes.json()
const get = (key: string) => settings.find((s) => s.key === key)?.value
authorAutoEnabled.value = get('authors_auto_enrichment_enabled') !== 'false'
authorProviderEnabled.value = get('authors_provider_audnexus_enabled') !== 'false'
authorWriteMode.value = get('authors_auto_enrichment_write_mode') === 'always_refetch' ? 'always_refetch' : 'missing_only'
}
})
async function saveWriteSettings() {
if (writeSaving.value) return
writeSaving.value = true
try {
const res = await api('/api/v1/app-settings/file-write-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(writeSettings.value),
})
if (res.ok) {
writeSettings.value = await res.json()
toast.success('Metadata sync settings saved')
} else {
toast.error('Failed to save metadata sync settings')
}
} catch {
toast.error('Failed to save metadata sync settings')
} finally {
writeSaving.value = false
}
}
function toggle(path: () => boolean, set: (v: boolean) => void) {
set(!path())
void saveWriteSettings()
}
function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
const formats = writeSettings.value.cbx.formats
const idx = formats.indexOf(fmt)
if (idx === -1) {
writeSettings.value.cbx.formats = [...formats, fmt]
} else {
writeSettings.value.cbx.formats = formats.filter((f) => f !== fmt)
}
void saveWriteSettings()
}
async function saveAuthorSetting(key: string, value: string, successMessage: string): Promise<boolean> {
if (authorSaving.value) return false
authorSaving.value = true
try {
const res = await api(`/api/v1/app-settings/${key}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (res.ok) {
toast.success(successMessage)
return true
} else {
toast.error('Failed to save author enrichment setting')
}
} catch {
toast.error('Failed to save author enrichment setting')
} finally {
authorSaving.value = false
}
return false
}
async function toggleAuthorAutoEnabled() {
const next = !authorAutoEnabled.value
const saved = await saveAuthorSetting(
'authors_auto_enrichment_enabled',
String(next),
next ? 'Auto author enrichment enabled' : 'Auto author enrichment disabled',
)
if (saved) {
authorAutoEnabled.value = next
}
}
async function toggleAuthorProvider() {
const next = !authorProviderEnabled.value
const saved = await saveAuthorSetting(
'authors_provider_audnexus_enabled',
String(next),
next ? 'Audnexus provider enabled' : 'Audnexus provider disabled',
)
if (saved) {
authorProviderEnabled.value = next
}
}
async function onAuthorWriteModeChange(event: Event) {
const mode = (event.target as HTMLSelectElement).value as AuthorAutoEnrichmentWriteMode
const saved = await saveAuthorSetting('authors_auto_enrichment_write_mode', mode, 'Author write mode updated')
if (saved) {
authorWriteMode.value = mode
}
}
async function runAuthorBackfill() {
if (authorBackfillRunning.value) return
authorBackfillRunning.value = true
try {
const res = await api('/api/v1/authors/enrichment/backfill', { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: { queued: number } = await res.json()
toast.success(`${data.queued} authors queued for enrichment`)
} catch {
toast.error('Failed to queue author backfill')
} finally {
authorBackfillRunning.value = false
}
}
onMounted(() => {})
</script>
<template>
@@ -217,223 +60,4 @@ async function runAuthorBackfill() {
</div>
</div>
</div>
<div class="mt-8">
<p class="settings-group-label">Author Enrichment</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border bg-card">
<div class="px-5 py-4 flex items-center justify-between gap-6">
<div>
<p class="settings-label">Enable auto author enrichment</p>
<p class="settings-hint">Automatically queue and enrich authors when books are scanned, uploaded, or metadata is edited.</p>
</div>
<ToggleSwitch :model-value="authorAutoEnabled" :disabled="authorSaving" @update:model-value="() => toggleAuthorAutoEnabled()" />
</div>
<div class="px-5 py-4 flex items-center justify-between gap-6">
<div>
<p class="settings-label">Audnexus provider</p>
<p class="settings-hint">Use Audnexus as the source for author descriptions and images.</p>
</div>
<ToggleSwitch :model-value="authorProviderEnabled" :disabled="authorSaving" @update:model-value="() => toggleAuthorProvider()" />
</div>
<div class="px-5 py-4 flex items-center justify-between gap-6">
<div>
<p class="settings-label">Write mode</p>
<p class="settings-hint">Choose whether auto enrichment only fills missing fields or always refreshes descriptions.</p>
</div>
<select class="select-field w-48" :value="authorWriteMode" :disabled="authorSaving" @change="onAuthorWriteModeChange">
<option value="missing_only">Missing only (recommended)</option>
<option value="always_refetch">Always refetch</option>
</select>
</div>
<div class="px-5 py-4 flex items-center justify-between gap-6">
<div>
<p class="settings-label">Backfill existing authors</p>
<p class="settings-hint">Queue all currently linked authors for enrichment.</p>
</div>
<button class="settings-btn-outline" :disabled="authorBackfillRunning" @click="runAuthorBackfill">
<RefreshCw :size="13" :class="authorBackfillRunning || isAuthorEnrichmentTaskRunning ? 'animate-spin' : ''" />
{{ authorBackfillRunning ? 'Queueing...' : isAuthorEnrichmentTaskRunning ? 'Task Running' : 'Queue All' }}
</button>
</div>
<div class="px-5 py-3 bg-muted/30 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>Queued: {{ authorEnrichmentStatus.queued }}</span>
<span>Processing: {{ authorEnrichmentStatus.processing }}</span>
<span>Rate limited: {{ authorEnrichmentStatus.rateLimited }}</span>
<span>Failed: {{ authorEnrichmentStatus.failed }}</span>
<span>Done: {{ authorEnrichmentStatus.done }}</span>
</div>
</div>
</div>
<div class="mt-8">
<p class="settings-group-label">Metadata File Sync</p>
<div class="border border-border rounded-lg bg-card divide-y divide-border">
<!-- Master toggle -->
<div class="px-5 py-4 flex items-start justify-between gap-6">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileEdit :size="16" class="text-primary" />
</div>
<div>
<p class="settings-label">Write metadata to original files</p>
<p class="settings-hint leading-relaxed max-w-sm">
When enabled, saving metadata updates the physical file on disk. Configure each format below.
</p>
</div>
</div>
<ToggleSwitch
:model-value="writeSettings.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.enabled,
(v) => (writeSettings.enabled = v),
)
"
/>
</div>
<template v-if="writeSettings.enabled">
<!-- Cover toggle -->
<div class="px-5 py-4 flex items-center justify-between gap-4">
<div>
<p class="settings-label">Include cover image</p>
<p class="settings-hint">Writes the stored cover back into the file (EPUB only).</p>
</div>
<ToggleSwitch
:model-value="writeSettings.writeCover"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.writeCover,
(v) => (writeSettings.writeCover = v),
)
"
/>
</div>
<!-- EPUB row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">EPUB</p>
<p class="settings-hint">Writes metadata into the OPF file inside the EPUB archive.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.epub.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.epub.enabled,
(v) => (writeSettings.epub.enabled = v),
)
"
/>
</div>
<div v-if="writeSettings.epub.enabled" class="flex items-center justify-between gap-4 pl-0">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="epubMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</div>
<!-- PDF row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">PDF</p>
<p class="settings-hint">Embeds metadata into PDF Info dictionary and XMP stream.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.pdf.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.pdf.enabled,
(v) => (writeSettings.pdf.enabled = v),
)
"
/>
</div>
<div v-if="writeSettings.pdf.enabled" class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="pdfMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</div>
<!-- Comic archives row -->
<div class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">Comic archives</p>
<p class="settings-hint">Writes ComicInfo.xml into CBZ and CB7 archives.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.cbx.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.cbx.enabled,
(v) => (writeSettings.cbx.enabled = v),
)
"
/>
</div>
<template v-if="writeSettings.cbx.enabled">
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="fmt in ['cbz', 'cb7'] as const"
:key="fmt"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-colors disabled:opacity-50"
:class="
writeSettings.cbx.formats.includes(fmt)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:border-primary/50'
"
:disabled="writeSaving"
@click="toggleCbxFormat(fmt)"
>
{{ fmt.toUpperCase() }}
</button>
<span
class="flex items-center px-3 py-1 rounded-full text-xs font-medium border border-border bg-muted text-muted-foreground cursor-default select-none"
>
CBR not writable
</span>
</div>
<div class="flex items-center justify-between gap-4">
<p class="text-sm text-muted-foreground">Max file size (MB)</p>
<input
v-model.number="cbxMaxMb"
type="number"
min="1"
max="5000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MetadataPreferencesSettings from './metadata-preferences/MetadataPreferencesSettings.vue'
import MetadataScoreWeightsSettings from './MetadataScoreWeightsSettings.vue'
import BookMetadataFetchSettings from './metadata-auto-fetch/BookMetadataFetchSettings.vue'
import AuthorEnrichmentSettings from './AuthorEnrichmentSettings.vue'
import MetadataFileSyncSettings from './MetadataFileSyncSettings.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
type Tab = 'providers' | 'score' | 'auto-fetch' | 'authors' | 'file-sync'
const route = useRoute()
const router = useRouter()
function normalizeTab(value: unknown): Tab {
if (value === 'providers' || value === 'score' || value === 'auto-fetch' || value === 'authors' || value === 'file-sync') return value
return 'providers'
}
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
if (!route.query.tab) {
router.replace({ name: 'settings-admin-metadata', query: { ...route.query, tab: activeTab.value } })
}
watch(
() => route.query.tab,
(value) => {
activeTab.value = normalizeTab(value)
},
)
const tabs: { id: Tab; label: string; subtitle: string }[] = [
{
id: 'providers',
label: 'Source & Priority',
subtitle: 'Define how book information is collected from external services and prioritized across your libraries.',
},
{ id: 'score', label: 'Confidence Score', subtitle: 'Assign weights to metadata fields to calculate how much to trust fetched results.' },
{
id: 'auto-fetch',
label: 'Book Auto-Fetch',
subtitle: 'Automatically fetch covers, descriptions, and other details when new books are added to your library.',
},
{
id: 'authors',
label: 'Author Auto-Fetch',
subtitle: 'Automatically fetch biographies and profile photos when new authors appear in your library.',
},
{ id: 'file-sync', label: 'File Write-Back', subtitle: 'Configure which metadata fields are saved directly into your book files on disk.' },
]
const activeTabInfo = computed(() => tabs.find((t) => t.id === activeTab.value)!)
const tabWidths: Record<Tab, string> = {
providers: 'max-w-5xl',
score: 'max-w-3xl',
'auto-fetch': 'max-w-3xl',
authors: 'max-w-3xl',
'file-sync': 'max-w-3xl',
}
function selectTab(tab: Tab) {
activeTab.value = tab
router.replace({ name: 'settings-admin-metadata', query: { ...route.query, tab } })
}
</script>
<template>
<div>
<SettingsPageHeader :title="activeTabInfo.label" :subtitle="activeTabInfo.subtitle" />
<div class="flex gap-1 mb-6 border-b border-border overflow-x-auto">
<button
v-for="tab in tabs"
:key="tab.id"
class="px-3 py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors"
:class="
activeTab === tab.id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
"
@click="selectTab(tab.id)"
>
{{
tab.id === 'providers'
? 'Providers'
: tab.id === 'score'
? 'Score'
: tab.id === 'auto-fetch'
? 'Books'
: tab.id === 'authors'
? 'Authors'
: 'File Sync'
}}
</button>
</div>
<div :class="tabWidths[activeTab]">
<MetadataPreferencesSettings v-if="activeTab === 'providers'" />
<MetadataScoreWeightsSettings v-else-if="activeTab === 'score'" />
<BookMetadataFetchSettings v-else-if="activeTab === 'auto-fetch'" />
<AuthorEnrichmentSettings v-else-if="activeTab === 'authors'" />
<MetadataFileSyncSettings v-else-if="activeTab === 'file-sync'" />
</div>
</div>
</template>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { FileEdit } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import type { GlobalFileWriteSettings } from '@projectx/types'
import { DEFAULT_FILE_WRITE_SETTINGS } from '@projectx/types'
import { api } from '@/lib/api'
const writeSettings = ref<GlobalFileWriteSettings>(structuredClone(DEFAULT_FILE_WRITE_SETTINGS))
const writeSaving = ref(false)
const epubMaxMb = computed({
get: () => Math.round(writeSettings.value.epub.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.epub.maxFileSizeBytes = mb * 1024 * 1024
},
})
const pdfMaxMb = computed({
get: () => Math.round(writeSettings.value.pdf.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.pdf.maxFileSizeBytes = mb * 1024 * 1024
},
})
const cbxMaxMb = computed({
get: () => Math.round(writeSettings.value.cbx.maxFileSizeBytes / (1024 * 1024)),
set: (mb: number) => {
writeSettings.value.cbx.maxFileSizeBytes = mb * 1024 * 1024
},
})
onMounted(async () => {
const res = await api('/api/v1/app-settings/file-write-settings')
if (res.ok) writeSettings.value = await res.json()
})
async function saveWriteSettings() {
if (writeSaving.value) return
writeSaving.value = true
try {
const res = await api('/api/v1/app-settings/file-write-settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(writeSettings.value),
})
if (res.ok) {
writeSettings.value = await res.json()
toast.success('Metadata sync settings saved')
} else {
toast.error('Failed to save metadata sync settings')
}
} catch {
toast.error('Failed to save metadata sync settings')
} finally {
writeSaving.value = false
}
}
function toggle(path: () => boolean, set: (v: boolean) => void) {
set(!path())
void saveWriteSettings()
}
function toggleCbxFormat(fmt: 'cbz' | 'cb7') {
const formats = writeSettings.value.cbx.formats
const idx = formats.indexOf(fmt)
writeSettings.value.cbx.formats = idx === -1 ? [...formats, fmt] : formats.filter((f) => f !== fmt)
void saveWriteSettings()
}
</script>
<template>
<p class="settings-group-label">Configuration</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="px-5 py-4 flex items-start justify-between gap-6 bg-card">
<div class="flex items-start gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileEdit :size="16" class="text-primary" />
</div>
<div>
<p class="settings-label">Write metadata to original files</p>
<p class="settings-hint">When enabled, saving metadata updates the physical file on disk. Configure each format below.</p>
</div>
</div>
<ToggleSwitch
:model-value="writeSettings.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.enabled,
(v) => (writeSettings.enabled = v),
)
"
/>
</div>
<template v-if="writeSettings.enabled">
<div class="px-5 py-4 flex items-center justify-between gap-4 bg-card">
<div>
<p class="settings-label">Include cover image</p>
<p class="settings-hint">Writes the stored cover back into the file (EPUB only).</p>
</div>
<ToggleSwitch
:model-value="writeSettings.writeCover"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.writeCover,
(v) => (writeSettings.writeCover = v),
)
"
/>
</div>
<div class="px-5 py-4 space-y-3 bg-card">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">EPUB</p>
<p class="settings-hint">Writes metadata into the OPF file inside the EPUB archive.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.epub.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.epub.enabled,
(v) => (writeSettings.epub.enabled = v),
)
"
/>
</div>
<div v-if="writeSettings.epub.enabled" class="flex items-center justify-between gap-4">
<p class="settings-hint">Max file size (MB)</p>
<input
v-model.number="epubMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</div>
<div class="px-5 py-4 space-y-3 bg-card">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">PDF</p>
<p class="settings-hint">Embeds metadata into PDF Info dictionary and XMP stream.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.pdf.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.pdf.enabled,
(v) => (writeSettings.pdf.enabled = v),
)
"
/>
</div>
<div v-if="writeSettings.pdf.enabled" class="flex items-center justify-between gap-4">
<p class="settings-hint">Max file size (MB)</p>
<input
v-model.number="pdfMaxMb"
type="number"
min="1"
max="2000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</div>
<div class="px-5 py-4 space-y-3 bg-card">
<div class="flex items-center justify-between gap-4">
<div>
<p class="settings-label">Comic archives</p>
<p class="settings-hint">Writes ComicInfo.xml into CBZ and CB7 archives.</p>
</div>
<ToggleSwitch
:model-value="writeSettings.cbx.enabled"
:disabled="writeSaving"
@update:model-value="
toggle(
() => writeSettings.cbx.enabled,
(v) => (writeSettings.cbx.enabled = v),
)
"
/>
</div>
<template v-if="writeSettings.cbx.enabled">
<div class="flex items-center gap-2 flex-wrap">
<button
v-for="fmt in ['cbz', 'cb7'] as const"
:key="fmt"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-colors disabled:opacity-50"
:class="
writeSettings.cbx.formats.includes(fmt)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-muted-foreground border-border hover:border-primary/50'
"
:disabled="writeSaving"
@click="toggleCbxFormat(fmt)"
>
{{ fmt.toUpperCase() }}
</button>
<span
class="flex items-center px-3 py-1 rounded-full text-xs font-medium border border-border bg-muted text-muted-foreground cursor-default select-none"
>
CBR not writable
</span>
</div>
<div class="flex items-center justify-between gap-4">
<p class="settings-hint">Max file size (MB)</p>
<input
v-model.number="cbxMaxMb"
type="number"
min="1"
max="5000"
:disabled="writeSaving"
class="input-field w-24"
@change="saveWriteSettings"
/>
</div>
</template>
</div>
</template>
</div>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { RefreshCw, Save } from 'lucide-vue-next'
import { RefreshCw, Save, RotateCcw } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import type { MetadataScoreWeights, MetadataScoreField } from '@projectx/types'
import { DEFAULT_METADATA_SCORE_WEIGHTS, METADATA_SCORE_FIELDS, METADATA_SCORE_GROUP_LABELS, type MetadataScoreGroup } from '@projectx/types'
@@ -12,6 +12,8 @@ const { resetFetchCache } = useMetadataScoreWeights()
const weights = reactive<MetadataScoreWeights>({ ...DEFAULT_METADATA_SCORE_WEIGHTS })
const saving = ref(false)
const recalculating = ref(false)
const resetConfirming = ref(false)
let resetConfirmTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
const res = await api('/api/v1/metadata-score/weights')
@@ -80,60 +82,72 @@ async function recalculateAll() {
recalculating.value = false
}
}
function handleResetClick() {
if (!resetConfirming.value) {
resetConfirming.value = true
resetConfirmTimer = setTimeout(() => {
resetConfirming.value = false
}, 3000)
return
}
if (resetConfirmTimer) clearTimeout(resetConfirmTimer)
resetConfirmTimer = null
resetConfirming.value = false
Object.assign(weights, DEFAULT_METADATA_SCORE_WEIGHTS)
toast.success('Weights reset to defaults. Save to apply.')
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-sm font-semibold">Metadata Score Weights</h2>
<p class="text-xs text-muted-foreground mt-0.5">
Assign a weight to each field. Fields with weight 0 are excluded from the score. Total max weight:
<span class="font-medium text-foreground">{{ totalWeight }}</span
>.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors disabled:opacity-50"
:disabled="recalculating"
@click="recalculateAll"
>
<RefreshCw class="size-3.5" :class="{ 'animate-spin': recalculating }" />
Recalculate All
</button>
<button
type="button"
class="flex items-center gap-1.5 h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="saving"
@click="saveWeights"
>
<Save class="size-3.5" />
Save
</button>
</div>
<div class="flex items-center justify-between mb-4">
<div>
<p class="settings-group-label !mb-0">Score Weights</p>
<p class="settings-hint mt-0.5">
Assign a weight to each field. Total weight:
<span class="font-medium text-foreground">{{ totalWeight }}</span
>.
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
type="button"
class="settings-btn-outline"
:class="resetConfirming ? '!border-destructive !text-destructive hover:!bg-destructive/10' : ''"
@click="handleResetClick"
>
<RotateCcw class="size-3.5" />
{{ resetConfirming ? 'Are you sure?' : 'Reset to defaults' }}
</button>
<button type="button" class="settings-btn-outline" :disabled="recalculating" @click="recalculateAll">
<RefreshCw class="size-3.5" :class="{ 'animate-spin': recalculating }" />
Recalculate all
</button>
<button type="button" class="settings-btn-primary" :disabled="saving" @click="saveWeights">
<Save class="size-3.5" />
Save
</button>
</div>
</div>
<div class="space-y-6">
<div v-for="group in groups" :key="group.group">
<div class="flex items-baseline justify-between mb-3">
<h3 class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{{ group.label }}</h3>
<span class="text-xs text-muted-foreground">subtotal: {{ groupTotal(group) }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div v-for="entry in group.fields" :key="entry.field" class="flex items-center gap-2">
<label :for="`weight-${entry.field}`" class="text-sm text-foreground flex-1 min-w-0 truncate">
{{ entry.label }}
</label>
<input
:id="`weight-${entry.field}`"
v-model.number="weights[entry.field]"
type="number"
min="0"
class="w-16 h-8 px-2 text-sm text-right rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div v-for="group in groups" :key="group.group" class="px-5 py-4 bg-card">
<div class="flex items-center justify-between mb-3">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wide">{{ group.label }}</p>
<span class="text-xs text-muted-foreground">subtotal: {{ groupTotal(group) }}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2.5">
<div v-for="entry in group.fields" :key="entry.field" class="flex items-center gap-2">
<input
:id="`weight-${entry.field}`"
v-model.number="weights[entry.field]"
type="number"
min="0"
class="w-14 h-7 px-2 text-xs text-center rounded-md border border-border bg-background focus:outline-none focus:ring-1 focus:ring-ring shrink-0"
/>
<label :for="`weight-${entry.field}`" class="settings-label truncate">
{{ entry.label }}
</label>
</div>
</div>
</div>
@@ -13,12 +13,16 @@ const route = useRoute()
const router = useRouter()
function normalizeTab(value: unknown): Tab {
if (value === 'ebook' || value === 'pdf' || value === 'comics') return value
return 'general'
if (value === 'general' || value === 'ebook' || value === 'pdf' || value === 'comics') return value
return 'ebook'
}
const activeTab = ref<Tab>(normalizeTab(route.query.tab))
if (!route.query.tab) {
router.replace({ name: 'settings-reader-general', query: { ...route.query, tab: activeTab.value } })
}
watch(
() => route.query.tab,
(value) => {
@@ -35,13 +39,7 @@ const tabs: { id: Tab; label: string }[] = [
function selectTab(tab: Tab) {
activeTab.value = tab
const query = { ...route.query }
if (tab === 'general') {
delete query.tab
} else {
query.tab = tab
}
router.replace({ name: 'settings-reader-general', query })
router.replace({ name: 'settings-reader-general', query: { ...route.query, tab } })
}
</script>
@@ -0,0 +1,42 @@
import { ref, watch, type Ref } from 'vue'
import type { AuthorEnrichmentConditions } from '@projectx/types'
import { api } from '@/lib/api'
export function useAuthorEligibleCountPreview(conditions: Ref<AuthorEnrichmentConditions | null>) {
const count = ref<number | null>(null)
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
async function fetchCount() {
const current = conditions.value
if (!current) {
count.value = null
return
}
loading.value = true
try {
const res = await api('/api/v1/authors/enrichment/preview-count', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conditions: current }),
})
if (res.ok) {
const data: { count: number } = await res.json()
count.value = data.count
}
} finally {
loading.value = false
}
}
watch(
conditions,
() => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(fetchCount, 400)
},
{ deep: true, immediate: true },
)
return { count, loading }
}
@@ -0,0 +1,26 @@
import { api } from '@/lib/api'
export function useAuthorEnrichmentActions() {
async function pause(): Promise<void> {
const res = await api('/api/v1/authors/enrichment/pause', { method: 'POST' })
if (!res.ok) throw new Error('Failed to pause')
}
async function resume(): Promise<void> {
const res = await api('/api/v1/authors/enrichment/resume', { method: 'POST' })
if (!res.ok) throw new Error('Failed to resume')
}
async function cancelPending(): Promise<void> {
const res = await api('/api/v1/authors/enrichment/cancel', { method: 'POST' })
if (!res.ok) throw new Error('Failed to cancel')
}
async function retryFailed(): Promise<{ requeued: number }> {
const res = await api('/api/v1/authors/enrichment/retry-failed', { method: 'POST' })
if (!res.ok) throw new Error('Failed to retry')
return res.json()
}
return { pause, resume, cancelPending, retryFailed }
}
@@ -1,15 +1,19 @@
import { ref } from 'vue'
import { io, Socket } from 'socket.io-client'
import type { AuthorEnrichmentStatus } from '@projectx/types'
import type { AuthorEnrichmentStatusEvent } from '@projectx/types'
import { getAccessToken } from '@/lib/api'
const status = ref<AuthorEnrichmentStatus>({
const status = ref<AuthorEnrichmentStatusEvent>({
queued: 0,
processing: 0,
rateLimited: 0,
failed: 0,
done: 0,
total: 0,
paused: false,
sessionTotal: 0,
sessionDone: 0,
currentItemName: null,
})
const socketConnected = ref(true)
let socket: Socket | null = null
@@ -24,7 +28,7 @@ function getSocket(): Socket {
reconnectionDelayMax: 10000,
})
socket.on('author-enrichment:status', (data: AuthorEnrichmentStatus) => {
socket.on('author-enrichment:status', (data: AuthorEnrichmentStatusEvent) => {
status.value = data
})
@@ -46,3 +50,23 @@ export function useAuthorEnrichmentStatus() {
return { status, socketConnected, subscribe }
}
export function disconnectAuthorEnrichmentSocket() {
if (socket) {
socket.disconnect()
socket = null
}
status.value = {
queued: 0,
processing: 0,
rateLimited: 0,
failed: 0,
done: 0,
total: 0,
paused: false,
sessionTotal: 0,
sessionDone: 0,
currentItemName: null,
}
socketConnected.value = true
}
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { BookMetadataFetchConfig } from '@projectx/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import GlobalAutoFetchConfig from './components/GlobalAutoFetchConfig.vue'
import LibraryAutoFetchConfig from './components/LibraryAutoFetchConfig.vue'
const { globalConfig, fetchGlobalConfig } = useBookMetadataFetchConfig()
const { libraries, fetchLibraries } = useLibraries()
const loading = ref(true)
onMounted(async () => {
try {
await Promise.all([fetchGlobalConfig(), fetchLibraries()])
} finally {
loading.value = false
}
})
function handleGlobalUpdated(updated: BookMetadataFetchConfig) {
globalConfig.value = updated
}
</script>
<template>
<div v-if="loading" class="text-sm text-muted-foreground">Loading...</div>
<template v-else>
<p class="settings-group-label">Global Settings</p>
<GlobalAutoFetchConfig v-if="globalConfig" :config="globalConfig" @updated="handleGlobalUpdated" />
<template v-if="libraries.length > 0">
<p class="settings-group-label mt-8">Per-Library Overrides</p>
<div class="flex flex-col gap-3">
<LibraryAutoFetchConfig v-for="lib in libraries" :key="lib.id" :library="lib" :global-config="globalConfig!" />
</div>
</template>
</template>
</template>
@@ -0,0 +1,95 @@
<script setup lang="ts">
import type { BookMetadataFetchConditions, MetadataField } from '@projectx/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import MissingFieldsPicker from './MissingFieldsPicker.vue'
const props = defineProps<{
modelValue: BookMetadataFetchConditions
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [BookMetadataFetchConditions] }>()
function updateScoreEnabled(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, scoreThreshold: { ...props.modelValue.scoreThreshold, enabled } })
}
function updateScoreThreshold(value: string) {
const num = Math.max(0, Math.min(100, parseInt(value, 10) || 0))
emit('update:modelValue', { ...props.modelValue, scoreThreshold: { ...props.modelValue.scoreThreshold, threshold: num } })
}
function updateMissingEnabled(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, missingFields: { ...props.modelValue.missingFields, enabled } })
}
function updateMissingFields(fields: MetadataField[]) {
emit('update:modelValue', { ...props.modelValue, missingFields: { ...props.modelValue.missingFields, fields } })
}
function updateNeverFetched(enabled: boolean) {
emit('update:modelValue', { ...props.modelValue, neverFetched: { enabled } })
}
</script>
<template>
<div class="flex flex-col gap-5">
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.neverFetched.enabled"
:disabled="props.disabled"
@update:model-value="updateNeverFetched"
class="mt-0.5 shrink-0"
/>
<div>
<p class="settings-label">Never fetched</p>
<p class="settings-hint">Fetch books that have never had a metadata fetch attempt.</p>
</div>
</div>
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.scoreThreshold.enabled"
:disabled="props.disabled"
@update:model-value="updateScoreEnabled"
class="mt-0.5 shrink-0"
/>
<div class="flex-1">
<p class="settings-label">Low metadata score</p>
<p class="settings-hint">Fetch if the metadata score is below a threshold.</p>
<div v-if="modelValue.scoreThreshold.enabled" class="flex items-center gap-2 mt-2">
<span class="text-xs text-muted-foreground">Score below</span>
<input
type="number"
min="0"
max="100"
:value="modelValue.scoreThreshold.threshold"
:disabled="props.disabled"
@input="updateScoreThreshold(($event.target as HTMLInputElement).value)"
class="w-16 h-7 px-2 text-xs border border-border rounded-md bg-background text-foreground"
/>
<span class="text-xs text-muted-foreground">/ 100</span>
</div>
</div>
</div>
<div class="flex items-start gap-3">
<ToggleSwitch
:model-value="modelValue.missingFields.enabled"
:disabled="props.disabled"
@update:model-value="updateMissingEnabled"
class="mt-0.5 shrink-0"
/>
<div class="flex-1">
<p class="settings-label">Missing fields</p>
<p class="settings-hint">Fetch if any of the selected fields are empty.</p>
<MissingFieldsPicker
v-if="modelValue.missingFields.enabled"
:model-value="modelValue.missingFields.fields"
:disabled="props.disabled"
class="mt-2"
@update:model-value="updateMissingFields"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Save } from 'lucide-vue-next'
import type { BookMetadataFetchConfig } from '@projectx/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import ConditionConfigurator from './ConditionConfigurator.vue'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import { useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
const { saveGlobalConfig } = useBookMetadataFetchConfig()
const { triggerGlobal } = useBookMetadataFetchActions()
const props = defineProps<{ config: BookMetadataFetchConfig }>()
const emit = defineEmits<{ updated: [BookMetadataFetchConfig] }>()
const local = ref<BookMetadataFetchConfig>(JSON.parse(JSON.stringify(props.config)))
const saving = ref(false)
const triggering = ref(false)
const triggerResult = ref<string | null>(null)
const conditions = computed(() => local.value.conditions)
const { count: eligibleCount, loading: countLoading } = useEligibleCountPreview(conditions)
watch(
() => props.config,
(c) => {
local.value = JSON.parse(JSON.stringify(c))
},
{ deep: true },
)
async function handleSave() {
saving.value = true
try {
const updated = await saveGlobalConfig(local.value)
emit('updated', updated)
} finally {
saving.value = false
}
}
async function handleTrigger() {
triggering.value = true
triggerResult.value = null
try {
const { queued } = await triggerGlobal()
triggerResult.value = queued > 0 ? `Queued ${queued} books` : 'No eligible books found'
} finally {
triggering.value = false
}
}
</script>
<template>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Enable auto-fetch</p>
<p class="settings-hint">Automatically fetch metadata for eligible books.</p>
</div>
<ToggleSwitch v-model="local.enabled" />
</div>
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">Trigger on import</p>
<p class="settings-hint">Queue eligible books when they are first added to a library.</p>
</div>
<ToggleSwitch v-model="local.triggerOnImport" :disabled="!local.enabled" />
</div>
<div class="px-5 py-4 bg-card">
<p class="settings-label mb-0.5">Eligibility conditions</p>
<p class="settings-hint mb-4">A book is eligible if it matches any enabled condition.</p>
<ConditionConfigurator v-model="local.conditions" />
</div>
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<button :disabled="saving" class="settings-btn-primary" @click="handleSave">
<Save class="size-3.5" />
{{ saving ? 'Saving...' : 'Save' }}
</button>
<div class="w-px h-4 bg-border shrink-0" />
<button :disabled="triggering" class="settings-btn-outline" @click="handleTrigger">
{{ triggering ? 'Running...' : 'Run for eligible books' }}
</button>
<span v-if="triggerResult" class="text-xs text-muted-foreground">{{ triggerResult }}</span>
<span v-else-if="eligibleCount !== null" class="text-xs text-muted-foreground">
{{ countLoading ? '...' : `~${eligibleCount} eligible` }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Save } from 'lucide-vue-next'
import type { BookMetadataFetchConfig, BookMetadataFetchLibraryConfig, Library } from '@projectx/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import ConditionConfigurator from './ConditionConfigurator.vue'
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
import { useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
const props = defineProps<{
library: Library
globalConfig: BookMetadataFetchConfig
}>()
const { fetchLibraryConfig, saveLibraryConfig } = useBookMetadataFetchConfig()
const { triggerForLibrary } = useBookMetadataFetchActions()
const libraryData = ref<BookMetadataFetchLibraryConfig | null>(null)
const inheriting = ref(true)
const local = ref<BookMetadataFetchConfig | null>(null)
const saving = ref(false)
const triggering = ref(false)
const triggerResult = ref<string | null>(null)
const loading = ref(true)
const conditions = computed(() => (inheriting.value ? props.globalConfig.conditions : (local.value?.conditions ?? null)))
const { count: eligibleCount, loading: countLoading } = useEligibleCountPreview(conditions, props.library.id)
const lastRunLabel = computed(() => {
if (!libraryData.value?.lastRunAt) return null
const date = new Date(libraryData.value.lastRunAt)
const diffMs = Date.now() - date.getTime()
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffMins = Math.floor(diffMs / (1000 * 60))
let when: string
if (diffMins < 2) when = 'just now'
else if (diffMins < 60) when = `${diffMins}m ago`
else if (diffHours < 24) when = `${diffHours}h ago`
else if (diffDays === 1) when = 'yesterday'
else when = `${diffDays} days ago`
const queued = libraryData.value.lastQueuedCount
if (queued === null) return `Last run: ${when}`
return queued > 0 ? `Last run: ${when} - queued ${queued} books` : `Last run: ${when} - no eligible books`
})
onMounted(async () => {
try {
libraryData.value = await fetchLibraryConfig(props.library.id)
local.value = JSON.parse(JSON.stringify(libraryData.value))
} finally {
loading.value = false
}
})
const displayConfig = computed(() => (inheriting.value ? props.globalConfig : local.value) ?? props.globalConfig)
function handleInheritToggle(isInheriting: boolean) {
inheriting.value = isInheriting
if (isInheriting) {
local.value = JSON.parse(JSON.stringify(props.globalConfig))
}
}
async function handleSave() {
if (!local.value) return
saving.value = true
try {
const override = inheriting.value ? null : local.value
await saveLibraryConfig(props.library.id, override)
} finally {
saving.value = false
}
}
async function handleTrigger() {
triggering.value = true
triggerResult.value = null
try {
const { queued } = await triggerForLibrary(props.library.id)
triggerResult.value = queued > 0 ? `Queued ${queued} books` : 'No eligible books found'
if (libraryData.value) {
libraryData.value.lastRunAt = new Date().toISOString()
libraryData.value.lastQueuedCount = queued
}
} finally {
triggering.value = false
}
}
</script>
<template>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border">
<div class="flex items-center justify-between px-5 py-4 bg-card">
<div>
<p class="settings-label">{{ props.library.name }}</p>
<p v-if="lastRunLabel" class="text-xs text-muted-foreground mt-0.5">{{ lastRunLabel }}</p>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">Inherit from global</span>
<ToggleSwitch :model-value="inheriting" @update:model-value="handleInheritToggle" />
</div>
</div>
<div v-if="loading" class="px-5 py-4 bg-card text-xs text-muted-foreground">Loading...</div>
<template v-else>
<div v-if="inheriting" class="px-5 py-4 bg-card">
<p class="text-xs text-muted-foreground italic">Using global defaults.</p>
</div>
<div v-else class="px-5 py-4 bg-card">
<ConditionConfigurator v-model="local!.conditions" :disabled="!displayConfig.enabled" />
</div>
<div class="flex items-center gap-3 px-5 py-4 bg-card">
<button v-if="!inheriting" :disabled="saving" class="settings-btn-primary" @click="handleSave">
<Save class="size-3.5" />
{{ saving ? 'Saving...' : 'Save override' }}
</button>
<button :disabled="triggering" class="settings-btn-outline" @click="handleTrigger">
{{ triggering ? 'Running...' : 'Run now' }}
</button>
<span v-if="triggerResult" class="text-xs text-muted-foreground">{{ triggerResult }}</span>
<span v-else-if="eligibleCount !== null" class="text-xs text-muted-foreground">
{{ countLoading ? '...' : `~${eligibleCount} eligible` }}
</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { MetadataField } from '@projectx/types'
import { ALL_METADATA_FIELDS } from '@projectx/types'
const props = defineProps<{
modelValue: MetadataField[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [MetadataField[]] }>()
const FIELD_LABELS: Record<MetadataField, string> = {
title: 'Title',
subtitle: 'Subtitle',
description: 'Description',
cover: 'Cover',
authors: 'Authors',
publisher: 'Publisher',
publishedYear: 'Published year',
language: 'Language',
pageCount: 'Page count',
seriesName: 'Series name',
seriesIndex: 'Series index',
genres: 'Genres',
}
function toggle(field: MetadataField) {
if (props.disabled) return
const current = new Set(props.modelValue)
if (current.has(field)) {
current.delete(field)
} else {
current.add(field)
}
emit('update:modelValue', [...current])
}
</script>
<template>
<div class="flex flex-wrap gap-1.5">
<button
v-for="field in ALL_METADATA_FIELDS"
:key="field"
type="button"
@click="toggle(field)"
:disabled="props.disabled"
:class="[
'px-2 py-0.5 text-xs rounded-full border transition-colors',
props.modelValue.includes(field)
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent text-muted-foreground border-border hover:border-primary/50',
props.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
]"
>
{{ FIELD_LABELS[field] }}
</button>
</div>
</template>
@@ -8,8 +8,6 @@ import LibraryPreferencePanel from './components/LibraryPreferencePanel.vue'
import { useProviderConfig } from './composables/useProviderConfig'
import { useMetadataPreferences } from './composables/useMetadataPreferences'
import { Info } from 'lucide-vue-next'
import SettingsPageHeader from '../SettingsPageHeader.vue'
import MetadataScoreWeightsSettings from '../MetadataScoreWeightsSettings.vue'
const { config, statuses, saving: savingProviders, fetchConfig, saveConfig } = useProviderConfig()
const { globalPrefs, libraryPrefs, savingGlobal, savingField, fetchGlobal, saveGlobal, fetchLibrary, saveFieldOverride, resetLibrary } =
@@ -38,15 +36,10 @@ async function onResetLibrary(libraryId: number) {
</script>
<template>
<div class="space-y-10 pb-20">
<SettingsPageHeader
title="Metadata Preferences"
subtitle="Configure how metadata is fetched and merged from different providers. Control priority, merge behavior, and library-specific overrides."
/>
<div class="space-y-8">
<!-- Provider Configuration -->
<section class="space-y-4">
<p class="settings-group-label">Metadata Providers</p>
<p class="settings-group-label">External Connections</p>
<ProviderConfigPanel
:config="config"
:statuses="statuses"
@@ -57,16 +50,28 @@ async function onResetLibrary(libraryId: number) {
<!-- Field Preferences -->
<section class="space-y-6">
<div class="space-y-1">
<p class="settings-group-label">Field Preferences</p>
<div class="flex items-start gap-3 p-4 rounded-xl bg-primary/5 border border-primary/10 max-w-6xl">
<Info :size="18" class="text-primary shrink-0 mt-0.5" />
<p class="text-sm text-muted-foreground leading-relaxed">
Choose which providers to use for each field and in what order (drag to reorder). The merge strategy controls how values are applied:
<span class="font-semibold text-foreground">Fill missing</span> only writes when empty,
<span class="font-semibold text-foreground">Overwrite if provided</span> writes whenever a provider returns a value, and
<span class="font-semibold text-foreground">Always overwrite</span> replaces existing values unconditionally.
</p>
<div class="space-y-3">
<p class="settings-group-label">Field-Level Rules</p>
<div class="p-4 rounded-xl bg-primary/5 border border-primary/10 max-w-5xl">
<div class="flex items-start gap-3 mb-3">
<Info :size="18" class="text-primary shrink-0 mt-0.5" />
<p class="text-sm font-medium text-foreground">How Field Rules Work</p>
</div>
<div class="grid sm:grid-cols-2 gap-x-8 gap-y-4 text-xs leading-relaxed text-muted-foreground ml-7">
<div>
<p class="font-semibold text-foreground mb-1">Priority Order</p>
<p>
Drag providers in the list to reorder them. The top-most provider that returns a result will be the primary source for that field.
</p>
</div>
<div>
<p class="font-semibold text-foreground mb-1">Merge Strategy</p>
<ul class="space-y-1">
<li><span class="text-foreground">Fill missing:</span> Only write if current value is empty.</li>
<li><span class="text-foreground">Overwrite:</span> Replace whenever a provider has data.</li>
</ul>
</div>
</div>
</div>
</div>
@@ -93,10 +98,5 @@ async function onResetLibrary(libraryId: number) {
</div>
</div>
</section>
<!-- Metadata Score Weights -->
<section class="space-y-4 pt-4 border-t border-border">
<MetadataScoreWeightsSettings />
</section>
</div>
</template>
@@ -29,16 +29,20 @@ const GROUPS: { label: string; fields: MetadataField[] }[] = [
<template>
<div class="space-y-0">
<!-- Reservoir with context -->
<div class="px-6 py-4 bg-muted/30 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="px-5 py-4 bg-muted/30 border-b border-border flex flex-col gap-3">
<div class="flex items-center gap-2.5">
<Info :size="14" class="text-primary" />
<p class="settings-hint !mt-0 uppercase tracking-wider">Drag providers from here onto any field to add them</p>
<Info :size="14" class="text-primary shrink-0" />
<p class="text-[11px] font-medium text-muted-foreground uppercase tracking-widest leading-none">
Drag providers from here onto any field to add them
</p>
</div>
<div class="flex items-center">
<ProviderReservoir :statuses="statuses" />
</div>
<ProviderReservoir :statuses="statuses" />
</div>
<!-- Table Header (desktop) -->
<div class="hidden md:flex items-center gap-4 px-6 py-3 bg-muted/10 border-b border-border/60">
<div class="hidden md:flex items-center gap-4 px-5 py-3 bg-muted/10 border-b border-border/60">
<div class="w-48 shrink-0 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Field</div>
<div class="flex-1 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Active Providers (ordered by priority)</div>
<div class="w-44 shrink-0 text-xs font-semibold text-muted-foreground uppercase tracking-wider">Merge Strategy</div>
@@ -53,16 +53,12 @@ function setGenreMerge(enabled: boolean) {
<template>
<div class="border border-border rounded-xl bg-card overflow-hidden shadow-sm">
<div class="px-6 py-5 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4 bg-muted/20">
<div class="px-5 py-4 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4 bg-muted/30">
<div>
<p class="settings-label">Global Defaults</p>
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">Global Defaults</span>
<p class="settings-hint">Default rules applied to every library. Override per-library below.</p>
</div>
<button
class="inline-flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="saving || !draft"
@click="save"
>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Defaults</span>
@@ -51,7 +51,7 @@ function onReset() {
class="border border-border rounded-xl bg-card overflow-hidden shadow-sm transition-all"
:class="open ? 'ring-1 ring-primary/20' : 'hover:border-primary/30'"
>
<div class="flex items-center gap-3 px-6 py-4 cursor-pointer select-none" @click="open = !open">
<div class="flex items-center gap-3 px-5 py-4 cursor-pointer select-none" @click="open = !open">
<div class="flex items-center gap-4 flex-1 min-w-0">
<div
class="flex items-center justify-center w-8 h-8 rounded-lg bg-muted shrink-0 text-muted-foreground transition-colors"
@@ -50,27 +50,27 @@ const rows: { key: keyof ProviderConfigurations; label: string; hint?: string; f
{
key: 'google',
label: 'Google Books',
hint: 'Recommended for higher rate limits.',
hint: "Broad coverage from Google's book index. Add an API key to avoid rate limits.",
fields: [{ key: 'apiKey', label: 'API Key', type: 'password' }],
},
{
key: 'amazon',
label: 'Amazon',
hint: 'Provides richer metadata and avoids rate limiting.',
hint: 'Pulls from Amazon product pages. A session cookie is highly recommended.',
fields: [
{ key: 'domain', label: 'Region', type: 'select', options: AMAZON_DOMAINS },
{ key: 'cookie', label: 'Cookie', type: 'password' },
],
},
{ key: 'goodreads', label: 'Goodreads', hint: 'Standard community metadata.', fields: [] },
{ key: 'goodreads', label: 'Goodreads', hint: 'The largest reading community on the web. No setup required.', fields: [] },
{
key: 'hardcover',
label: 'Hardcover',
hint: 'Social book tracking platform.',
hint: 'A modern alternative to Goodreads. Free API key required from hardcover.app.',
fields: [{ key: 'apiKey', label: 'API Key', type: 'password' }],
},
{ key: 'openLibrary', label: 'Open Library', hint: 'The internet archive for books.', fields: [] },
{ key: 'itunes', label: 'iTunes', hint: 'Apple Books and Audiobooks.', fields: [] },
{ key: 'openLibrary', label: 'Open Library', hint: "The Internet Archive's free book catalog. No setup required.", fields: [] },
{ key: 'itunes', label: 'iTunes', hint: "Apple's digital book catalog. No setup required.", fields: [] },
]
function statusFor(key: string) {
@@ -86,45 +86,41 @@ function save() {
<template>
<div class="border border-border rounded-xl bg-card overflow-hidden shadow-sm">
<!-- Card Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between bg-muted/20">
<div class="px-5 py-4 border-b border-border flex items-center justify-between bg-muted/30">
<div class="flex items-center gap-2">
<span class="settings-group-label !mb-0">Available Sources</span>
<span class="text-xs font-bold text-muted-foreground uppercase tracking-widest">Available Sources</span>
</div>
<button
class="inline-flex items-center gap-2 px-4 py-2 text-xs font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
:disabled="saving || !draft"
@click="save"
>
<button class="settings-btn-primary h-8 px-3" :disabled="saving || !draft" @click="save">
<Loader2 v-if="saving" :size="14" class="animate-spin" />
<Save v-else :size="14" />
<span>Save Changes</span>
</button>
</div>
<div v-if="draft" class="divide-y divide-border/50">
<div v-for="row in rows" :key="row.key" class="px-6 py-5 flex flex-col lg:flex-row lg:items-center justify-between gap-6">
<div v-if="draft" class="divide-y divide-border">
<div
v-for="row in rows"
:key="row.key"
class="px-5 py-4 flex flex-col lg:flex-row lg:items-center justify-between gap-6 bg-card transition-colors hover:bg-muted/30"
>
<!-- Left: Provider Info -->
<div class="space-y-1.5">
<div class="space-y-1">
<div class="flex items-center gap-3">
<span class="settings-label">{{ row.label }}</span>
<template v-if="statusFor(row.key)">
<Badge
v-if="!statusFor(row.key)?.configured"
variant="destructive"
class="h-4.5 px-1.5 text-[9px] font-black uppercase tracking-tighter"
>
<Badge v-if="!statusFor(row.key)?.configured" variant="destructive" class="h-4 px-1.5 text-[9px] font-bold uppercase tracking-wide">
Setup Required
</Badge>
<Badge
v-else
variant="outline"
class="h-4.5 px-1.5 text-[9px] font-black uppercase tracking-tighter text-emerald-600 border-emerald-500/30 bg-emerald-500/5"
class="h-4 px-1.5 text-[9px] font-bold uppercase tracking-wide text-emerald-600 border-emerald-500/30 bg-emerald-500/5"
>
Ready
</Badge>
</template>
</div>
<p v-if="row.hint" class="text-xs text-muted-foreground max-w-sm leading-relaxed">
<p v-if="row.hint" class="settings-hint max-w-sm">
{{ row.hint }}
</p>
</div>
@@ -138,7 +134,7 @@ function save() {
v-if="field.type === 'select'"
v-model="(draft[row.key] as unknown as Record<string, string>)[field.key]"
:disabled="!draft[row.key].enabled"
class="h-8 rounded border border-input bg-background px-2.5 text-xs font-medium focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
class="h-9 rounded-md border border-input bg-background px-3 text-xs font-medium focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
>
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
@@ -148,7 +144,7 @@ function save() {
:type="field.type"
:placeholder="field.label"
:disabled="!draft[row.key].enabled"
class="h-8 w-96 rounded border border-input bg-background px-2.5 text-xs font-medium placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
class="h-9 w-64 lg:w-80 rounded-md border border-input bg-background px-3 text-xs font-medium placeholder:text-muted-foreground/40 focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-40 transition-all"
/>
</div>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { GripVertical } from 'lucide-vue-next'
import type { ProviderStatus } from '@projectx/types'
import { providerChipStyle, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
@@ -32,7 +33,8 @@ function onDragStart(key: string, e: DragEvent) {
"
@dragstart="onDragStart(status.key, $event)"
>
{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}
<GripVertical v-if="status.enabled && status.configured" :size="10" class="shrink-0 -ml-1 opacity-50" />
<span>{{ PROVIDER_SHORT_LABELS[status.key] ?? status.key }}</span>
</div>
</div>
</template>
+7 -2
View File
@@ -75,8 +75,13 @@ const router = createRouter({
{
path: 'admin/metadata',
name: 'settings-admin-metadata',
component: () => import('@/features/settings/metadata-preferences/MetadataPreferencesSettings.vue'),
meta: { maxWidth: 'max-w-5xl' },
component: () => import('@/features/settings/MetadataAllSettings.vue'),
meta: { maxWidth: 'max-w-7xl' },
},
{
path: 'admin/metadata-auto-fetch',
name: 'settings-admin-metadata-auto-fetch',
redirect: { name: 'settings-admin-metadata', query: { tab: 'auto-fetch' } },
},
{
path: 'admin/oidc',