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:
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user