feat(artist): "new since last visit" badge + banner
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.
This commit is contained in:
@@ -20,6 +20,22 @@
|
||||
: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
|
||||
@@ -39,7 +55,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useArtistStore } from '../stores/artist.js'
|
||||
@@ -60,6 +76,10 @@ const { tab, resolve } = useTabQuery(
|
||||
() => ((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)
|
||||
@@ -67,6 +87,7 @@ watch(slug, async (s) => {
|
||||
? `${store.overview.name} — FabledCurator`
|
||||
: 'FabledCurator'
|
||||
tab.value = resolve()
|
||||
unseenBanner.value = (store.overview?.unseen_count_at_visit || 0) > 0
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -74,4 +95,7 @@ watch(slug, async (s) => {
|
||||
.fc-artist__loading {
|
||||
display: flex; justify-content: center; padding: 64px 0;
|
||||
}
|
||||
.fc-artist__unseen-msg {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user