b65e956ad2
Per-artist "+N" accent pill on the artists directory and a "N new since last visit" banner inside ArtistView. Counts new IMAGES (not posts) so multi-image posts increment correctly. - alembic 0034: artist_visit (artist_id PK, last_viewed_at NOT NULL). Seeds every existing artist with last_viewed_at=NOW() so the badge starts at 0 across the board — no noisy "5000 unseen images" on first deploy. - ArtistService.find_or_create autoseeds a visit row alongside new artists, so freshly imported content doesn't read as unseen. - ArtistService.overview reads pre-visit last_viewed_at, counts images created since, then atomically UPSERTs last_viewed_at=NOW() via postgres ON CONFLICT DO UPDATE (no SELECT-then-INSERT race per reference_scalar_one_or_none_duplicates). Returns the pre-update count as `unseen_count_at_visit` so the banner has data. - ArtistDirectoryService.list_artists adds an `unseen_count` aggregate to each card via LEFT JOIN artist_visit + conditional COUNT. NULL last_viewed_at (artist created before this code shipped) defensively counts as "never visited" → all images unseen. - Frontend: ArtistCard renders an accent pill in the preview-strip corner when unseen_count > 0 (capped at 99+); ArtistView shows a closable v-alert banner on initial load when unseen_count_at_visit > 0, re-arms on slug change. Single-row-per-artist (no user_id) — rule #47 multi-user ACL is aspirational; widens to (user_id, artist_id) PK when User lands, per rule #22. Scribe plan #597.
102 lines
3.3 KiB
Vue
102 lines
3.3 KiB
Vue
<template>
|
|
<div v-if="store.loading && !store.overview" class="fc-artist__loading">
|
|
<v-progress-circular indeterminate color="accent" size="36" />
|
|
</div>
|
|
|
|
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
|
|
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
|
</v-container>
|
|
|
|
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
|
|
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
|
</v-container>
|
|
|
|
<template v-else-if="store.overview">
|
|
<ArtistHeader
|
|
v-model="tab"
|
|
:name="store.overview.name"
|
|
:image-count="store.imageCount"
|
|
:post-count="store.postCount"
|
|
:last-added="store.lastAdded"
|
|
/>
|
|
<v-container fluid class="pt-2 pb-4">
|
|
<!-- "N new since last visit" banner. Visible only on the initial
|
|
load that triggered the visit-mark; dismissable via close
|
|
button or by switching tabs. Re-entry only re-shows if more
|
|
content has arrived (overview returns 0 immediately after a
|
|
previous visit). -->
|
|
<v-alert
|
|
v-if="unseenBanner"
|
|
type="info" variant="tonal" density="compact"
|
|
class="mb-3" closable
|
|
@click:close="unseenBanner = false"
|
|
>
|
|
<span class="fc-artist__unseen-msg">
|
|
<strong>{{ store.overview.unseen_count_at_visit }}</strong>
|
|
new since last visit
|
|
</span>
|
|
</v-alert>
|
|
<v-window v-model="tab">
|
|
<v-window-item value="posts">
|
|
<ArtistPostsTab
|
|
:artist-id="store.overview.id"
|
|
@switch-tab="(t) => tab = t"
|
|
/>
|
|
</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-container>
|
|
</template>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, ref, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
|
|
import { useArtistStore } from '../stores/artist.js'
|
|
import ArtistHeader from '../components/artist/ArtistHeader.vue'
|
|
import ArtistPostsTab from '../components/artist/ArtistPostsTab.vue'
|
|
import ArtistGalleryTab from '../components/artist/ArtistGalleryTab.vue'
|
|
import ArtistManagementTab from '../components/artist/ArtistManagementTab.vue'
|
|
import { useTabQuery } from '../composables/useTabQuery.js'
|
|
|
|
const VALID_TABS = ['posts', 'gallery', 'management']
|
|
|
|
const route = useRoute()
|
|
const store = useArtistStore()
|
|
|
|
const slug = computed(() => route.params.slug)
|
|
const { tab, resolve } = useTabQuery(
|
|
VALID_TABS,
|
|
() => ((store.postCount ?? 0) > 0 ? 'posts' : 'gallery'),
|
|
)
|
|
|
|
// One-shot banner — reset on each new artist-slug load so it re-appears
|
|
// when navigating between artists that each have unseen content.
|
|
const unseenBanner = ref(false)
|
|
|
|
watch(slug, async (s) => {
|
|
if (!s) return
|
|
await store.load(s)
|
|
document.title = store.overview
|
|
? `${store.overview.name} — FabledCurator`
|
|
: 'FabledCurator'
|
|
tab.value = resolve()
|
|
unseenBanner.value = (store.overview?.unseen_count_at_visit || 0) > 0
|
|
}, { immediate: true })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-artist__loading {
|
|
display: flex; justify-content: center; padding: 64px 0;
|
|
}
|
|
.fc-artist__unseen-msg {
|
|
font-size: 14px;
|
|
}
|
|
</style>
|