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:
@@ -8,6 +8,15 @@
|
||||
<div v-if="card.preview_thumbnails.length === 0" class="fc-artistcard__noimg">
|
||||
No preview
|
||||
</div>
|
||||
<!-- Accent pill in the corner when this artist has content imported
|
||||
since the operator last opened their detail view. Caps at 99+
|
||||
to keep the layout compact; the actual count appears in the
|
||||
banner inside ArtistView. -->
|
||||
<span
|
||||
v-if="(card.unseen_count || 0) > 0"
|
||||
class="fc-artistcard__unseen"
|
||||
:aria-label="`${card.unseen_count} new since last visit`"
|
||||
>+{{ card.unseen_count > 99 ? '99+' : card.unseen_count }}</span>
|
||||
</div>
|
||||
<v-card-text class="fc-artistcard__body">
|
||||
<div class="fc-artistcard__name">{{ card.name }}</div>
|
||||
@@ -37,6 +46,7 @@ function onCardClick() {
|
||||
<style scoped>
|
||||
.fc-artistcard { cursor: pointer; }
|
||||
.fc-artistcard__previews {
|
||||
position: relative;
|
||||
display: grid; grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2px; aspect-ratio: 3 / 1;
|
||||
/* Explicit floor + ceiling so tall source images can't escape the
|
||||
@@ -45,6 +55,19 @@ function onCardClick() {
|
||||
overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-artistcard__unseen {
|
||||
position: absolute;
|
||||
top: 6px; right: 6px;
|
||||
display: inline-flex; align-items: center;
|
||||
padding: 2px 8px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-on-accent, 0, 0, 0));
|
||||
background: rgb(var(--v-theme-accent));
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
.fc-artistcard__previews img {
|
||||
display: block;
|
||||
width: 100%; height: 100%;
|
||||
|
||||
@@ -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