feat(series): Suggestions tab + matcher controls — frontend (FC-6.3)
Completes FC-6.3 with the UI. - SeriesView gains tabs: Browse (the existing grid) + Suggestions. - Suggestions tab: pending matches as rows (post → series, per-signal strength chips, score), Add (→ chapter) / Skip (→ dismiss); a "Matching on" toggle and a threshold field (both DB-backed via /settings/import), and a Rescan button that enqueues the background matcher. - seriesSuggestions store wires load / accept / dismiss / rescan / settings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '../composables/useApi.js'
|
||||
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
||||
import { toast } from '../utils/toast.js'
|
||||
|
||||
// Backs the Series → Suggestions tab (FC-6.3). The matcher is confirm-only:
|
||||
// accept turns a suggestion into a chapter; skip dismisses it. enabled +
|
||||
// threshold are the operator-tunable knobs (DB-backed via /settings/import).
|
||||
export const useSeriesSuggestionsStore = defineStore('seriesSuggestions', () => {
|
||||
const api = useApi()
|
||||
const suggestions = ref([])
|
||||
const enabled = ref(true)
|
||||
const threshold = ref(0.5)
|
||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||
|
||||
async function load() {
|
||||
await run(async () => {
|
||||
const body = await api.get('/api/series/suggestions')
|
||||
suggestions.value = body.suggestions || []
|
||||
})
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await api.get('/api/settings/import')
|
||||
enabled.value = s.series_suggest_enabled
|
||||
threshold.value = s.series_suggest_threshold
|
||||
}
|
||||
|
||||
async function saveSettings(patch) {
|
||||
await api.patch('/api/settings/import', { body: patch })
|
||||
}
|
||||
|
||||
async function setEnabled(v) {
|
||||
enabled.value = v
|
||||
await saveSettings({ series_suggest_enabled: v })
|
||||
}
|
||||
|
||||
async function setThreshold(v) {
|
||||
threshold.value = v
|
||||
await saveSettings({ series_suggest_threshold: v })
|
||||
}
|
||||
|
||||
async function accept(id) {
|
||||
try {
|
||||
await api.post(`/api/series/suggestions/${id}/accept`, {})
|
||||
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||
toast({ text: 'Added to series', type: 'success' })
|
||||
} catch (e) {
|
||||
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function dismiss(id) {
|
||||
try {
|
||||
await api.post(`/api/series/suggestions/${id}/dismiss`, {})
|
||||
suggestions.value = suggestions.value.filter(s => s.id !== id)
|
||||
} catch (e) {
|
||||
toast({ text: `Skip failed: ${e.message}`, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function rescan() {
|
||||
await api.post('/api/series/suggestions/rescan', {})
|
||||
toast({
|
||||
text: 'Rescan queued — runs in the background; reload to see new matches.',
|
||||
type: 'info'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions, enabled, threshold, loading, error,
|
||||
load, loadSettings, setEnabled, setThreshold, accept, dismiss, rescan
|
||||
}
|
||||
})
|
||||
@@ -1,77 +1,154 @@
|
||||
<template>
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-series-browse__controls">
|
||||
<v-select
|
||||
:model-value="store.sort"
|
||||
:items="sortItems"
|
||||
density="compact" variant="outlined" hide-details
|
||||
label="Sort" style="max-width: 220px;"
|
||||
@update:model-value="store.setSort($event)"
|
||||
/>
|
||||
</div>
|
||||
<v-tabs v-model="tab" density="compact" class="mb-3">
|
||||
<v-tab value="browse">Browse</v-tab>
|
||||
<v-tab value="suggestions">
|
||||
Suggestions
|
||||
<v-badge
|
||||
v-if="sug.suggestions.length" :content="sug.suggestions.length"
|
||||
inline color="accent"
|
||||
/>
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
Failed to load: {{ store.error }}
|
||||
</v-alert>
|
||||
<div
|
||||
v-else-if="!store.loading && store.series.length === 0"
|
||||
class="fc-series-browse__empty"
|
||||
>
|
||||
No series yet. Make one from a post's “Series ▾ → New series from this post”,
|
||||
or create a <code>series:</code> tag and add images.
|
||||
</div>
|
||||
|
||||
<div class="fc-series-browse__grid">
|
||||
<article
|
||||
v-for="s in store.series" :key="s.id" class="fc-sbcard"
|
||||
@click="manage(s.id)"
|
||||
>
|
||||
<div class="fc-sbcard__cover">
|
||||
<img v-if="s.cover_thumbnail_url" :src="s.cover_thumbnail_url" alt="" loading="lazy" />
|
||||
<div v-else class="fc-sbcard__cover--empty">
|
||||
<v-icon size="large">mdi-book-open-page-variant</v-icon>
|
||||
</div>
|
||||
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
||||
</span>
|
||||
<v-window v-model="tab">
|
||||
<!-- ---- Browse ---- -->
|
||||
<v-window-item value="browse">
|
||||
<div class="fc-series-browse__controls">
|
||||
<v-select
|
||||
:model-value="store.sort"
|
||||
:items="sortItems"
|
||||
density="compact" variant="outlined" hide-details
|
||||
label="Sort" style="max-width: 220px;"
|
||||
@update:model-value="store.setSort($event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="fc-sbcard__body">
|
||||
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
||||
<div class="fc-sbcard__meta">
|
||||
<span v-if="s.artist_name" class="fc-sbcard__artist">{{ s.artist_name }}</span>
|
||||
<span class="fc-sbcard__counts">
|
||||
{{ s.chapter_count }} ch · {{ s.page_count }} pg
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-sbcard__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-book-open"
|
||||
:disabled="s.page_count === 0"
|
||||
@click.stop="read(s.id)"
|
||||
>Read</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text"
|
||||
prepend-icon="mdi-pencil" @click.stop="manage(s.id)"
|
||||
>Manage</v-btn>
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
Failed to load: {{ store.error }}
|
||||
</v-alert>
|
||||
<div
|
||||
v-else-if="!store.loading && store.series.length === 0"
|
||||
class="fc-series-browse__empty"
|
||||
>
|
||||
No series yet. Make one from a post's “Series ▾ → New series from this
|
||||
post”, or create a <code>series:</code> tag and add images.
|
||||
</div>
|
||||
|
||||
<div class="fc-series-browse__grid">
|
||||
<article
|
||||
v-for="s in store.series" :key="s.id" class="fc-sbcard"
|
||||
@click="manage(s.id)"
|
||||
>
|
||||
<div class="fc-sbcard__cover">
|
||||
<img v-if="s.cover_thumbnail_url" :src="s.cover_thumbnail_url" alt="" loading="lazy" />
|
||||
<div v-else class="fc-sbcard__cover--empty">
|
||||
<v-icon size="large">mdi-book-open-page-variant</v-icon>
|
||||
</div>
|
||||
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-sbcard__body">
|
||||
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
||||
<div class="fc-sbcard__meta">
|
||||
<span v-if="s.artist_name" class="fc-sbcard__artist">{{ s.artist_name }}</span>
|
||||
<span class="fc-sbcard__counts">
|
||||
{{ s.chapter_count }} ch · {{ s.page_count }} pg
|
||||
</span>
|
||||
</div>
|
||||
<div class="fc-sbcard__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-book-open"
|
||||
:disabled="s.page_count === 0"
|
||||
@click.stop="read(s.id)"
|
||||
>Read</v-btn>
|
||||
<v-btn
|
||||
size="x-small" variant="text"
|
||||
prepend-icon="mdi-pencil" @click.stop="manage(s.id)"
|
||||
>Manage</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
||||
<v-progress-circular indeterminate color="accent" size="28" />
|
||||
</div>
|
||||
</v-window-item>
|
||||
|
||||
<!-- ---- Suggestions ---- -->
|
||||
<v-window-item value="suggestions">
|
||||
<div class="fc-sug__controls">
|
||||
<v-switch
|
||||
:model-value="sug.enabled" color="accent" density="compact"
|
||||
hide-details label="Matching on"
|
||||
@update:model-value="sug.setEnabled($event)"
|
||||
/>
|
||||
<v-text-field
|
||||
:model-value="sug.threshold"
|
||||
type="number" min="0" max="1" step="0.05"
|
||||
density="compact" variant="outlined" hide-details
|
||||
label="Threshold" style="max-width: 130px;"
|
||||
@change="sug.setThreshold(Number($event.target.value))"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="tonal" prepend-icon="mdi-radar" @click="sug.rescan()"
|
||||
>Rescan</v-btn>
|
||||
</div>
|
||||
|
||||
<v-alert v-if="sug.error" type="error" variant="tonal" closable class="my-4">
|
||||
{{ sug.error }}
|
||||
</v-alert>
|
||||
<div
|
||||
v-else-if="!sug.loading && sug.suggestions.length === 0"
|
||||
class="fc-series-browse__empty"
|
||||
>
|
||||
No pending suggestions. Click <strong>Rescan</strong> to look for posts
|
||||
that continue an existing series.
|
||||
</div>
|
||||
|
||||
<div class="fc-sug__list">
|
||||
<div v-for="s in sug.suggestions" :key="s.id" class="fc-sug__row">
|
||||
<div class="fc-sug__main">
|
||||
<div class="fc-sug__title">
|
||||
{{ s.post_title }}
|
||||
<v-icon size="x-small">mdi-arrow-right</v-icon>
|
||||
<span class="fc-sug__series">{{ s.series_name }}</span>
|
||||
</div>
|
||||
<div class="fc-sug__signals">
|
||||
<span class="fc-sug__score">{{ pct(s.score) }}</span>
|
||||
<span
|
||||
v-for="k in signalKeys(s)" :key="k" class="fc-sug__chip"
|
||||
>{{ k }} {{ pct(s.signals[k]) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fc-sug__actions">
|
||||
<v-btn
|
||||
size="small" color="accent" variant="flat"
|
||||
@click="sug.accept(s.id)"
|
||||
>Add</v-btn>
|
||||
<v-btn size="small" variant="text" @click="sug.dismiss(s.id)">Skip</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
||||
<v-progress-circular indeterminate color="accent" size="28" />
|
||||
</div>
|
||||
</v-window-item>
|
||||
</v-window>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
||||
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useSeriesBrowseStore()
|
||||
const sug = useSeriesSuggestionsStore()
|
||||
const tab = ref('browse')
|
||||
|
||||
const sortItems = [
|
||||
{ title: 'Recently updated', value: 'recent' },
|
||||
@@ -86,7 +163,17 @@ function read(id) {
|
||||
router.push({ name: 'series-read', params: { tagId: id } })
|
||||
}
|
||||
|
||||
onMounted(() => store.load())
|
||||
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
|
||||
// Only the signals that actually fired (strength > 0), in a stable order.
|
||||
function signalKeys(s) {
|
||||
return ['title', 'artist', 'pages', 'tags'].filter(k => (s.signals?.[k] || 0) > 0)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.load()
|
||||
sug.loadSettings()
|
||||
sug.load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -142,4 +229,36 @@ onMounted(() => store.load())
|
||||
.fc-sbcard__artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fc-sbcard__counts { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
|
||||
.fc-sbcard__actions { display: flex; gap: 6px; }
|
||||
|
||||
/* Suggestions tab */
|
||||
.fc-sug__controls {
|
||||
display: flex; align-items: center; gap: 16px; margin-bottom: 12px;
|
||||
}
|
||||
.fc-sug__list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.fc-sug__row {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.fc-sug__main { flex: 1; min-width: 0; }
|
||||
.fc-sug__title {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 14px; color: rgb(var(--v-theme-on-surface));
|
||||
overflow: hidden;
|
||||
}
|
||||
.fc-sug__series { color: rgb(var(--v-theme-accent)); font-weight: 600; }
|
||||
.fc-sug__signals {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px;
|
||||
}
|
||||
.fc-sug__score {
|
||||
font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 700;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-sug__chip {
|
||||
font-size: 11px; padding: 1px 7px; border-radius: 999px;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-sug__actions { flex: 0 0 auto; display: flex; gap: 6px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user