feat(series): Add-to-series control + Series browse view + nav (FC-6.2)
Completes FC-6.2 with the UI. - PostSeriesMenu: a "Series ▾" control on each post card — "New series from this post" (promote → navigates to manage) and "Add to existing series…" (dialog with a browsable picker loaded from GET /api/series, client-side filtered — avoids the empty-autocomplete #712 trap). - SeriesView (/series): a top-level Series browse grid — cover, name, artist, chapter/page counts, gap badge; sort recent|name|size; cards → manage/read. meta.title adds it to the nav automatically (peer of Posts). - seriesBrowse store for the list. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
<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-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-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useSeriesBrowseStore()
|
||||
|
||||
const sortItems = [
|
||||
{ title: 'Recently updated', value: 'recent' },
|
||||
{ title: 'Name', value: 'name' },
|
||||
{ title: 'Size', value: 'size' }
|
||||
]
|
||||
|
||||
function manage(id) {
|
||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||
}
|
||||
function read(id) {
|
||||
router.push({ name: 'series-read', params: { tagId: id } })
|
||||
}
|
||||
|
||||
onMounted(() => store.load())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-series-browse__controls {
|
||||
display: flex; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.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; right: 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)));
|
||||
}
|
||||
.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; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user