Files
FabledCurator/frontend/src/views/SeriesView.vue
T
bvandeusen 67c7ca8603
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m44s
fix(ui): measure real nav height (--fc-nav-h), stop Explore breadcrumb tucking under nav (#1481)
The app uses a plain sticky TopNav (no v-main), and the nav's height was
hardcoded as 64px in ~6 places: the Explore + Subscriptions full-height
workspaces (height: calc(100vh - 64px)) and every sticky sub-header pinned
beneath the nav (top: 64px — Gallery filter bar, Browse/Series/Settings tabs).

Vuetify 4's MD3 sizing changed the real nav height, so 64px was wrong: the
Explore workspace was sized taller than the space below the nav, overflowed the
viewport, and its breadcrumb tucked under the (taller) nav on 1080p.

TopNav now measures its own height via ResizeObserver and publishes it as
--fc-nav-h on documentElement (default 64px in app.css). Every consumer uses
var(--fc-nav-h) instead of the magic number, so the layout self-corrects to the
nav's real height and stays correct as it reflows (per-view teleported actions,
mobile breakpoint). Also tightens the new chrome-gradient seam — sub-headers now
pin at the nav's exact bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:27:37 -04:00

400 lines
14 KiB
Vue

<template>
<v-container fluid class="pt-2 pb-6">
<!-- Tabs + the active tab's controls live in one sticky block pinned under
the 64px TopNav (operator-asked 2026-06-12), so the axis switcher and
search/sort stay reachable on a long grid. The controls can't sit
inside v-window (it clips sticky children), so they're hoisted here. -->
<div class="fc-series__head fc-chrome-continues">
<v-tabs v-model="tab" density="compact">
<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>
<div v-if="tab === 'browse'" class="fc-series-browse__controls">
<v-text-field
v-model="query"
density="compact" variant="outlined" hide-details clearable
label="Search series" prepend-inner-icon="mdi-magnify"
style="max-width: 320px;"
/>
<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 v-else 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>
</div>
<v-window v-model="tab">
<!-- ---- Browse ---- -->
<v-window-item value="browse">
<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
v-else-if="!store.loading && visibleSeries.length === 0"
class="fc-series-browse__empty"
>
No series match {{ query }}.
</div>
<div class="fc-series-browse__grid">
<article
v-for="s in visibleSeries" :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>
<KebabMenu
class="fc-sbcard__kebab" variant="flat"
:label="`Actions for ${s.name}`"
>
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item
prepend-icon="mdi-delete" base-color="error"
@click.stop="openDelete(s)"
>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</KebabMenu>
</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>
<v-dialog v-model="renameDialog" max-width="420">
<TagRenameDialog
v-if="renameTarget"
:tag="renameTarget"
@renamed="onRenamed" @cancel="renameDialog = false"
/>
</v-dialog>
<v-dialog v-model="deleteDialog" max-width="440">
<v-card v-if="deleteTarget">
<v-card-title class="text-body-1">Delete {{ deleteTarget.name }}?</v-card-title>
<v-card-text>
<p class="text-body-2">
Removes the series and its chapter/page ordering. The images
themselves are kept only the series grouping is deleted.
</p>
<v-alert
v-if="deleteError" type="error" variant="tonal"
density="compact" class="mt-3"
>{{ deleteError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="deleting" @click="deleteDialog = false">Cancel</v-btn>
<v-btn
color="error" variant="flat" rounded="pill"
:loading="deleting" @click="confirmDelete"
>Delete series</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<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">
<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>
</v-window-item>
</v-window>
</v-container>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
import KebabMenu from '../components/common/KebabMenu.vue'
const router = useRouter()
const store = useSeriesBrowseStore()
const sug = useSeriesSuggestionsStore()
const tab = ref('browse')
const sortItems = [
{ title: 'Recently updated', value: 'recent' },
{ title: 'Name', value: 'name' },
{ title: 'Size', value: 'size' }
]
// Client-side name/artist filter — the whole list is already loaded (homelab
// scale), so search is instant and needs no round-trip.
const query = ref('')
const visibleSeries = computed(() => {
const q = (query.value || '').trim().toLowerCase()
if (!q) return store.series
return store.series.filter(s =>
s.name?.toLowerCase().includes(q)
|| s.artist_name?.toLowerCase().includes(q)
)
})
function manage(id) {
router.push({ name: 'series-manage', params: { tagId: id } })
}
function read(id) {
router.push({ name: 'series-read', params: { tagId: id } })
}
// --- per-series kebab: rename + delete ----------------------------------
const renameDialog = ref(false)
const renameTarget = ref(null)
function openRename(s) {
// TagRenameDialog renames the underlying Tag(kind=series); pass the kind so
// its collision copy reads correctly.
renameTarget.value = { id: s.id, name: s.name, kind: 'series' }
renameDialog.value = true
}
function onRenamed(updated) {
renameDialog.value = false
if (updated?.id != null && updated?.name) {
store.applyRename(updated.id, updated.name)
}
}
const deleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const deleteError = ref(null)
function openDelete(s) {
deleteTarget.value = s
deleteError.value = null
deleteDialog.value = true
}
async function confirmDelete() {
deleting.value = true
deleteError.value = null
try {
await store.remove(deleteTarget.value.id)
deleteDialog.value = false
} catch (e) {
deleteError.value = e.message
} finally {
deleting.value = false
}
}
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>
/* Sticky header (tabs + active-tab controls) pinned under the 64px TopNav, so
content scrolls cleanly beneath it. Background is the shared
.fc-chrome-continues fade — continues the nav's gradient rather than a solid
band (operator 2026-07-13). */
.fc-series__head {
position: sticky;
top: var(--fc-nav-h, 64px); /* pins at the nav's real measured bottom (#1481) */
z-index: 4;
padding-bottom: 12px;
}
.fc-series-browse__controls {
display: flex; flex-wrap: wrap; gap: 12px; padding-top: 12px;
}
.fc-series-browse__empty {
padding: 48px; text-align: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series-browse__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.fc-sbcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px; overflow: hidden; cursor: pointer;
background: rgb(var(--v-theme-surface));
transition: border-color 120ms ease, transform 120ms ease;
}
.fc-sbcard:hover {
border-color: rgb(var(--v-theme-accent), 0.6);
transform: translateY(-2px);
}
.fc-sbcard__cover {
position: relative; aspect-ratio: 3 / 2; background: rgb(var(--v-theme-surface-light));
}
.fc-sbcard__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-sbcard__cover--empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-sbcard__gap {
position: absolute; top: 6px; left: 6px;
display: inline-flex; align-items: center; gap: 2px;
padding: 1px 6px; border-radius: 999px; font-size: 11px;
background: rgba(20, 23, 26, 0.75);
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
/* Kebab sits top-right of the cover, opposite the gap badge. Tinted backing so
it stays legible over any cover image. */
.fc-sbcard__kebab {
position: absolute; top: 4px; right: 4px;
background: rgba(20, 23, 26, 0.6) !important;
border-radius: 50%;
color: rgb(var(--v-theme-on-surface));
}
.fc-sbcard__kebab:hover { background: rgba(20, 23, 26, 0.85) !important; }
.fc-sbcard__body { padding: 8px 10px; }
.fc-sbcard__name {
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
margin: 0 0 4px; color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-sbcard__meta {
display: flex; justify-content: space-between; gap: 8px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.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; padding-top: 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>