67c7ca8603
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>
400 lines
14 KiB
Vue
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>
|