feat(series): SeriesManageView + /series/:tagId route + TagCard manage affordance

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:38:20 -04:00
parent b19aff198e
commit 886284d182
4 changed files with 153 additions and 4 deletions
@@ -19,6 +19,12 @@
class="fc-tagcard__edit" size="14" icon="mdi-pencil"
@click.stop="startEdit"
/>
<v-icon
v-if="card.kind === 'series'"
class="fc-tagcard__edit" size="14"
icon="mdi-book-open-page-variant"
title="Manage series" @click.stop="$emit('manage', card.id)"
/>
</template>
<v-text-field
v-else
@@ -43,7 +49,7 @@
import { ref } from 'vue'
const props = defineProps({ card: { type: Object, required: true } })
const emit = defineEmits(['open', 'rename'])
const emit = defineEmits(['open', 'rename', 'manage'])
const editing = ref(false)
const draft = ref('')
@@ -76,8 +82,8 @@ function submit() {
.fc-tagcard__previews img { width: 100%; height: 100%; object-fit: cover; }
.fc-tagcard__noimg {
grid-column: 1 / -1; display: flex; align-items: center;
justify-content: center; color: rgb(var(--v-theme-on-surface));
opacity: 0.5; font-size: 12px;
justify-content: center;
color: rgb(var(--v-theme-on-surface-variant)); font-size: 12px;
}
.fc-tagcard__body { padding: 8px 12px; }
.fc-tagcard__name { font-weight: 600; }
+3
View File
@@ -5,6 +5,7 @@ import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
import TagsView from './views/TagsView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesManageView from './views/SeriesManageView.vue'
// The application's front door. `/` redirects here. Changing the front door
// is a one-line edit (e.g. '/gallery' or '/tags').
@@ -20,6 +21,8 @@ const routes = [
{ path: '/tags', name: 'tags', component: TagsView, meta: { title: 'Tags' } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series management — no meta.title (reached from a series tag card).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone
+136
View File
@@ -0,0 +1,136 @@
<template>
<v-container fluid class="py-6">
<div class="fc-series__head">
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
</div>
<div class="fc-series__body">
<div class="fc-series__pages">
<div
v-for="(p, idx) in store.pages" :key="p.image_id"
class="fc-series__page" draggable="true"
@dragstart="dragFrom = idx"
@dragover.prevent
@drop="onDrop(idx)"
>
<span class="fc-series__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-series__pageactions">
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
title="Make cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="text" icon="mdi-close"
title="Remove" @click="store.remove(p.image_id)" />
</div>
</div>
<div v-if="store.pages.length === 0" class="fc-series__empty">
No pages yet add images from the right.
</div>
</div>
<div class="fc-series__picker">
<div class="fc-series__pickerhead">
<span>{{ store.pickerSelection.length }} selected</span>
<v-btn
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to series</v-btn>
</div>
<div class="fc-series__pickergrid">
<div
v-for="img in store.picker" :key="img.id"
class="fc-series__pick"
:class="{ on: store.pickerSelection.includes(img.id) }"
@click="store.togglePick(img.id)"
>
<img :src="img.thumbnail_url" alt="" loading="lazy" />
</div>
</div>
<div ref="sentinel" class="fc-series__sentinel" />
</div>
</div>
</v-container>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
const route = useRoute()
const store = useSeriesManageStore()
const dragFrom = ref(null)
const sentinel = ref(null)
let observer = null
function onDrop(toIdx) {
if (dragFrom.value === null || dragFrom.value === toIdx) return
const ordered = moveItem(
store.pages.map(p => p.image_id), dragFrom.value, toIdx
)
dragFrom.value = null
store.reorder(ordered)
}
onMounted(async () => {
await store.load(parseInt(route.params.tagId, 10))
await store.loadPicker(true)
observer = new IntersectionObserver(([e]) => {
if (e.isIntersecting) store.loadPicker()
}, { rootMargin: '600px' })
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => observer && observer.disconnect())
</script>
<style scoped>
.fc-series__head {
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
}
.fc-series__name {
font-family: 'Fraunces', Georgia, serif; font-size: 22px;
}
.fc-series__count {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__body { display: flex; gap: 16px; align-items: flex-start; }
.fc-series__pages { flex: 1; min-width: 0; display: flex;
flex-direction: column; gap: 6px; }
.fc-series__page {
display: flex; align-items: center; gap: 10px; padding: 6px;
background: rgb(var(--v-theme-surface)); border-radius: 6px;
cursor: grab;
}
.fc-series__page img {
width: 64px; height: 64px; object-fit: cover; border-radius: 4px;
}
.fc-series__pn {
width: 28px; text-align: center;
color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-series__pageactions { margin-left: auto; }
.fc-series__empty {
padding: 32px; text-align: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series__picker { flex: 1; min-width: 0; }
.fc-series__pickerhead {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 8px;
}
.fc-series__pickergrid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 6px;
}
.fc-series__pick { cursor: pointer; aspect-ratio: 1; overflow: hidden;
border-radius: 4px; outline: 2px solid transparent; }
.fc-series__pick img { width: 100%; height: 100%; object-fit: cover; }
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
.fc-series__sentinel { height: 40px; }
@media (max-width: 900px) {
.fc-series__body { flex-direction: column; }
}
</style>
+5 -1
View File
@@ -26,7 +26,7 @@
<div class="fc-tags__grid">
<TagCard
v-for="c in store.cards" :key="c.id" :card="c"
@open="openTag" @rename="onRename"
@open="openTag" @rename="onRename" @manage="onManage"
/>
</div>
@@ -97,6 +97,10 @@ onUnmounted(() => observer && observer.disconnect())
function openTag(tagId) {
router.push({ name: 'gallery', query: { tag_id: tagId } })
}
function onManage(id) {
router.push({ name: 'series-manage', params: { tagId: id } })
}
</script>
<style scoped>