feat(artist-view): ArtistView rewrite — sticky frosted ArtistHeader (name + stats + tabs) replaces the in-body h1; three lazy tabs (Posts default, Gallery fallback, Management); ?tab= URL state; cross-artist store reset; document.title set on slug change
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
|
import { usePostsStore } from './posts.js'
|
||||||
|
|
||||||
const PAGE = 60
|
const PAGE = 60
|
||||||
|
|
||||||
@@ -15,7 +16,11 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
let started = false
|
let started = false
|
||||||
|
|
||||||
async function load(slug) {
|
async function load (slug) {
|
||||||
|
// Cross-artist reset: clear this store AND the posts store so the new
|
||||||
|
// artist doesn't briefly render with the previous artist's content
|
||||||
|
// when the user is on the Posts tab. (Gallery tab uses this artist
|
||||||
|
// store's own images list — cleared above.)
|
||||||
overview.value = null
|
overview.value = null
|
||||||
images.value = []
|
images.value = []
|
||||||
nextCursor.value = null
|
nextCursor.value = null
|
||||||
@@ -23,6 +28,7 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
started = false
|
started = false
|
||||||
error.value = null
|
error.value = null
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
usePostsStore().$reset?.()
|
||||||
try {
|
try {
|
||||||
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
overview.value = await api.get(`/api/artist/${encodeURIComponent(slug)}`)
|
||||||
await loadMoreImages(slug)
|
await loadMoreImages(slug)
|
||||||
@@ -34,7 +40,7 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMoreImages(slug) {
|
async function loadMoreImages (slug) {
|
||||||
if (imagesLoading.value) return
|
if (imagesLoading.value) return
|
||||||
if (started && nextCursor.value === null) return
|
if (started && nextCursor.value === null) return
|
||||||
imagesLoading.value = true
|
imagesLoading.value = true
|
||||||
@@ -55,9 +61,13 @@ export const useArtistStore = defineStore('artist', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
const hasMoreImages = computed(() => !started || nextCursor.value !== null)
|
||||||
|
const postCount = computed(() => overview.value?.post_count ?? null)
|
||||||
|
const imageCount = computed(() => overview.value?.image_count ?? null)
|
||||||
|
const lastAdded = computed(() => overview.value?.date_range?.max ?? null)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview, images, loading, imagesLoading, error, notFound,
|
overview, images, loading, imagesLoading, error, notFound,
|
||||||
hasMoreImages, load, loadMoreImages
|
hasMoreImages, postCount, imageCount, lastAdded,
|
||||||
|
load, loadMoreImages,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,220 +1,96 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
|
||||||
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-alert v-else-if="store.notFound" type="warning" variant="tonal">
|
<v-container v-else-if="store.notFound" class="py-6">
|
||||||
Artist not found.
|
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
||||||
</v-alert>
|
</v-container>
|
||||||
|
|
||||||
<v-alert v-else-if="store.error" type="error" variant="tonal" closable>
|
<v-container v-else-if="store.error && !store.overview" class="py-6">
|
||||||
{{ store.error }}
|
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
||||||
</v-alert>
|
</v-container>
|
||||||
|
|
||||||
<template v-else-if="store.overview">
|
<template v-else-if="store.overview">
|
||||||
<header class="fc-artist__head">
|
<ArtistHeader
|
||||||
<h1 class="fc-h1">{{ store.overview.name }}</h1>
|
v-model="tab"
|
||||||
<div class="fc-artist__stats">
|
:name="store.overview.name"
|
||||||
<span>{{ store.overview.image_count }} images</span>
|
:image-count="store.imageCount"
|
||||||
<span v-if="dateRange">· {{ dateRange }}</span>
|
:post-count="store.postCount"
|
||||||
</div>
|
:last-added="store.lastAdded"
|
||||||
</header>
|
/>
|
||||||
|
<v-container fluid class="py-4">
|
||||||
<div class="fc-artist__fc4">
|
|
||||||
<v-chip
|
|
||||||
size="small"
|
|
||||||
:variant="store.overview.is_subscription ? 'flat' : 'outlined'"
|
|
||||||
:color="store.overview.is_subscription ? 'accent' : undefined"
|
|
||||||
prepend-icon="mdi-rss"
|
|
||||||
>{{ store.overview.is_subscription ? 'Subscription' : 'One-off' }}</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" prepend-icon="mdi-link-variant"
|
|
||||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
|
||||||
>{{ store.overview.sources.length }} source{{ store.overview.sources.length === 1 ? '' : 's' }}</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" prepend-icon="mdi-rss"
|
|
||||||
:to="`/posts?artist_id=${store.overview.id}`"
|
|
||||||
>View posts</v-chip>
|
|
||||||
|
|
||||||
<v-chip
|
|
||||||
size="small" variant="outlined" disabled
|
|
||||||
prepend-icon="mdi-clock-outline"
|
|
||||||
>Credential health · FC-3b</v-chip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs split (2026-05-25): Settings was previously slotted at the
|
|
||||||
bottom of the page after the infinite-scroll image grid, which
|
|
||||||
made it effectively unreachable for any artist with more than
|
|
||||||
a couple of pages of content. The Settings tab now hosts
|
|
||||||
destructive admin actions (artist+content cascade-delete) and
|
|
||||||
any future per-artist management UI. v-tabs is `position:
|
|
||||||
sticky; top: 64px` (under the 64px AppShell TopNav) so it
|
|
||||||
stays parked while the gallery scrolls. -->
|
|
||||||
<v-tabs
|
|
||||||
v-model="tab" color="accent" class="mb-4"
|
|
||||||
style="position: sticky; top: 64px; z-index: 4;
|
|
||||||
background: rgb(var(--v-theme-surface));"
|
|
||||||
>
|
|
||||||
<v-tab value="overview">Overview</v-tab>
|
|
||||||
<v-tab value="settings">Settings</v-tab>
|
|
||||||
</v-tabs>
|
|
||||||
|
|
||||||
<v-window v-model="tab">
|
<v-window v-model="tab">
|
||||||
<v-window-item value="overview">
|
<v-window-item value="posts">
|
||||||
<section v-if="store.overview.cooccurring_tags.length" class="fc-artist__sec">
|
<ArtistPostsTab
|
||||||
<h2 class="fc-h2">Frequent tags</h2>
|
|
||||||
<div class="fc-artist__tags">
|
|
||||||
<v-chip
|
|
||||||
v-for="t in store.overview.cooccurring_tags" :key="t.id"
|
|
||||||
size="small" @click="openTag(t.id)"
|
|
||||||
>{{ t.name }} <span class="fc-artist__tagc">{{ t.count }}</span></v-chip>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="store.overview.activity.length" class="fc-artist__sec">
|
|
||||||
<h2 class="fc-h2">Activity</h2>
|
|
||||||
<svg class="fc-artist__spark" :viewBox="`0 0 ${sparkW} ${sparkH}`"
|
|
||||||
preserveAspectRatio="none" role="img" aria-label="posts over time">
|
|
||||||
<polyline :points="sparkPoints" fill="none"
|
|
||||||
stroke="rgb(var(--v-theme-accent))" stroke-width="2" />
|
|
||||||
</svg>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section v-if="store.overview.sources.length" class="fc-artist__sec">
|
|
||||||
<div class="fc-artist__sec-head">
|
|
||||||
<h2 class="fc-h2">Sources</h2>
|
|
||||||
<RouterLink
|
|
||||||
:to="`/subscriptions?artist_id=${store.overview.id}`"
|
|
||||||
class="fc-artist__manage"
|
|
||||||
>Manage subscriptions →</RouterLink>
|
|
||||||
</div>
|
|
||||||
<v-table density="compact">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Platform</th><th>URL</th><th class="text-right">Images</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="s in store.overview.sources" :key="s.id">
|
|
||||||
<td>{{ s.platform }}</td>
|
|
||||||
<td class="fc-artist__url">{{ s.url }}</td>
|
|
||||||
<td class="text-right">{{ s.image_count }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="fc-artist__sec">
|
|
||||||
<h2 class="fc-h2">Images</h2>
|
|
||||||
<MasonryGrid
|
|
||||||
:items="store.images"
|
|
||||||
:loading="store.imagesLoading"
|
|
||||||
:has-more="store.hasMoreImages"
|
|
||||||
@load-more="store.loadMoreImages(slug)"
|
|
||||||
@open="openImage"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</v-window-item>
|
|
||||||
|
|
||||||
<v-window-item value="settings">
|
|
||||||
<ArtistDangerZone
|
|
||||||
:slug="slug"
|
|
||||||
:artist-id="store.overview.id"
|
:artist-id="store.overview.id"
|
||||||
:artist-name="store.overview.name"
|
@switch-tab="(t) => tab = t"
|
||||||
/>
|
/>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
<v-window-item value="gallery">
|
||||||
|
<ArtistGalleryTab :slug="slug" />
|
||||||
|
</v-window-item>
|
||||||
|
<v-window-item value="management">
|
||||||
|
<ArtistManagementTab :overview="store.overview" />
|
||||||
|
</v-window-item>
|
||||||
</v-window>
|
</v-window>
|
||||||
</template>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useArtistStore } from '../stores/artist.js'
|
import { useArtistStore } from '../stores/artist.js'
|
||||||
import { useModalStore } from '../stores/modal.js'
|
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
||||||
import MasonryGrid from '../components/discovery/MasonryGrid.vue'
|
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
||||||
import ArtistDangerZone from '../components/artist/ArtistDangerZone.vue'
|
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
||||||
|
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
|
||||||
|
|
||||||
|
const VALID_TABS = ['posts', 'gallery', 'management']
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useArtistStore()
|
const store = useArtistStore()
|
||||||
const modal = useModalStore()
|
|
||||||
|
|
||||||
const slug = computed(() => route.params.slug)
|
const slug = computed(() => route.params.slug)
|
||||||
// Per-artist tab — defaults to Overview. Settings tab hosts destructive
|
const tab = ref('posts')
|
||||||
// admin actions (DangerZone). Switching artists resets to Overview so the
|
|
||||||
// destructive surface isn't re-shown by accident when navigating between
|
|
||||||
// artists.
|
|
||||||
const tab = ref('overview')
|
|
||||||
|
|
||||||
watch(slug, (s) => {
|
function resolveDefaultTab () {
|
||||||
if (s) {
|
const fromUrl = route.query.tab
|
||||||
store.load(s)
|
if (VALID_TABS.includes(fromUrl)) return fromUrl
|
||||||
tab.value = 'overview'
|
if ((store.postCount ?? 0) > 0) return 'posts'
|
||||||
}
|
return 'gallery'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(slug, async (s) => {
|
||||||
|
if (!s) return
|
||||||
|
await store.load(s)
|
||||||
|
document.title = store.overview
|
||||||
|
? `${store.overview.name} — FabledCurator`
|
||||||
|
: 'FabledCurator'
|
||||||
|
tab.value = resolveDefaultTab()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
const dateRange = computed(() => {
|
// Reflect tab changes back into the URL so refresh/back/forward work.
|
||||||
const r = store.overview?.date_range
|
watch(tab, (newTab) => {
|
||||||
if (!r || !r.min) return null
|
if (route.query.tab === newTab) return
|
||||||
const fmt = (iso) => iso.slice(0, 10)
|
router.replace({
|
||||||
return r.min === r.max ? fmt(r.min) : `${fmt(r.min)} → ${fmt(r.max)}`
|
query: { ...route.query, tab: newTab },
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const sparkW = 600
|
// React to URL-tab changes (e.g., back/forward).
|
||||||
const sparkH = 80
|
watch(() => route.query.tab, (q) => {
|
||||||
const sparkPoints = computed(() => {
|
if (q && VALID_TABS.includes(q) && tab.value !== q) {
|
||||||
const a = store.overview?.activity ?? []
|
tab.value = q
|
||||||
if (a.length === 0) return ''
|
}
|
||||||
const max = Math.max(...a.map(p => p.count), 1)
|
|
||||||
const stepX = a.length > 1 ? sparkW / (a.length - 1) : 0
|
|
||||||
return a.map((p, i) => {
|
|
||||||
const x = i * stepX
|
|
||||||
const y = sparkH - (p.count / max) * (sparkH - 4) - 2
|
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
|
||||||
}).join(' ')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function openImage(id) {
|
|
||||||
modal.open(id)
|
|
||||||
}
|
|
||||||
function openTag(tagId) {
|
|
||||||
router.push({ name: 'gallery', query: { tag_id: tagId } })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-h1 {
|
.fc-artist__loading {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
display: flex; justify-content: center; padding: 64px 0;
|
||||||
font-size: 32px; font-weight: 500;
|
|
||||||
color: rgb(var(--v-theme-on-surface));
|
|
||||||
}
|
}
|
||||||
.fc-h2 {
|
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
|
||||||
font-size: 20px; font-weight: 500; margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.fc-artist__loading { display: flex; justify-content: center; padding: 64px 0; }
|
|
||||||
.fc-artist__head { margin-bottom: 12px; }
|
|
||||||
.fc-artist__stats { opacity: 0.75; margin-top: 4px; }
|
|
||||||
.fc-artist__fc4 { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
||||||
.fc-artist__sec { margin-bottom: 28px; }
|
|
||||||
.fc-artist__tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
||||||
.fc-artist__tagc { opacity: 0.6; margin-left: 4px; font-variant-numeric: tabular-nums; }
|
|
||||||
.fc-artist__spark { width: 100%; height: 80px; }
|
|
||||||
.fc-artist__url {
|
|
||||||
max-width: 380px; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.fc-artist__sec-head {
|
|
||||||
display: flex; align-items: baseline; justify-content: space-between;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
.fc-artist__manage {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: rgb(var(--v-theme-accent));
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.fc-artist__manage:hover { text-decoration: underline; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user