Files
FabledCurator/frontend/src/components/modal/ProvenancePanel.vue
T
bvandeusen e1fc65bd1b feat(provenance+tags): collapse provenance descriptions; purge retired-kind tags
Operator-flagged 2026-05-28, two asks.

**1. Provenance posts ate the panel.** Each post card rendered its full
description (180px scroll box) inline, so a few posts pushed everything
else off-screen. ProvenancePanel now collapses the description by
default behind a per-post "Show description ▾ / Hide ▴" toggle (state
keyed by provenance_id, reset when the viewed image changes). Cards stay
compact — platform/date/title/meta/actions — and the operator expands
only the descriptions they want.

**2. Purge tags of retired/system kinds.** The IR migration left
`archive`/`post`/`artist`-kind tags (e.g. `BlenderKnight:Hannah_BJ_Loops`)
that FC no longer creates — the tag input only makes
character/fandom/series/general, and provenance + artists are their own
systems now. (meta/rating were already hard-deleted by alembic 0023.)

- cleanup_service.purge_tags_by_kind(kinds=PURGEABLE_TAG_KINDS) — counts
  (dry_run) or deletes tags whose kind ∈ (archive, post, artist).
  CASCADE clears the image_tag / alias / allowlist / etc. rows.
- POST /api/admin/tags/purge-retired-kinds (Tier-A, dry-run preview
  returns per-kind counts + sample names — the preview IS the
  verification of exactly what'll be deleted before committing).
- Tag Maintenance card gets a second section: "Preview retired-kind
  tags" → per-kind breakdown + sample → "Delete N retired-kind tag(s)".

Tests: dry-run counts by kind (general survives), commit deletes only
the retired kinds (general + character survive, retired count → 0).

NOTE: a dry-run preview will show exactly which kinds/counts are
present. If the operator's noisy tags turn out to be `general` (e.g. an
IR `source:patreon` that fell back to general during migration), they
won't be caught by the kind purge — the preview makes that visible so
we can decide on a name-based pass separately.
2026-05-28 01:12:20 -04:00

194 lines
6.5 KiB
Vue

<template>
<section v-if="show" class="fc-prov" aria-label="Provenance">
<h3 class="fc-prov__title">Provenance</h3>
<v-progress-circular
v-if="state && state.loading" indeterminate color="accent" size="22"
/>
<v-alert
v-else-if="state && state.error" type="error" variant="tonal"
density="compact"
>{{ state.error }}</v-alert>
<template v-else>
<article
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
>
<div class="fc-prov__head">
<span class="fc-prov__platform">{{ e.source.platform }}</span>
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
</div>
<div class="fc-prov__post">
{{ e.post.title || `Post ${e.post.external_post_id}` }}
</div>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${e.artist.slug}`">
by {{ e.artist.name }}
</RouterLink>
<span v-if="e.post.attachment_count != null">
· {{ e.post.attachment_count }} files
</span>
</div>
<div class="fc-prov__actions">
<a
v-if="e.post.url" :href="e.post.url"
target="_blank" rel="noopener noreferrer"
> View original post</a>
<a href="#" @click.prevent="openPost(e.post.id)">
View images from this post
</a>
<a
v-if="e.post.description_html"
href="#" @click.prevent="toggleDesc(e.provenance_id)"
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
</div>
<div
v-if="e.post.description_html && expanded[e.provenance_id]"
class="fc-prov__desc" v-html="e.post.description_html"
/>
</article>
<!-- Fallback: image has artist_id set (folder-derived from
filesystem import or set by tag_apply) but no ImageProvenance
row exists. Show a minimal "by <artist>" line so the modal
isn't blank for IR-imported images that aren't in any post. -->
<article
v-if="showArtistFallback"
class="fc-prov__card"
>
<div class="fc-prov__meta">
<RouterLink :to="`/artist/${fallbackArtist.slug}`">
by {{ fallbackArtist.name }}
</RouterLink>
</div>
</article>
</template>
<div v-if="attachments.length" class="fc-prov__attach">
<h4 class="fc-prov__attach-title">Attachments</h4>
<a
v-for="at in attachments" :key="at.id"
class="fc-prov__attach-row"
:href="at.download_url" :download="at.original_filename"
> {{ at.original_filename }}
<span class="fc-prov__attach-size">({{ at.size_bytes }} B)</span>
</a>
</div>
</section>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js'
import { formatPostDate } from '../../utils/date.js'
const modal = useModalStore()
const prov = useProvenanceStore()
const router = useRouter()
// Per-post description collapse state (keyed by provenance_id). Default
// collapsed so multiple posts don't each eat ~180px of the panel — the
// operator flagged the descriptions consuming a lot of real estate
// 2026-05-28. Reset when the viewed image changes.
const expanded = reactive({})
function toggleDesc(id) { expanded[id] = !expanded[id] }
watch(() => modal.currentImageId, () => {
for (const k of Object.keys(expanded)) delete expanded[k]
})
watch(
() => modal.currentImageId,
(id) => { if (id != null) prov.loadForImage(id) },
{ immediate: true }
)
const state = computed(() =>
modal.currentImageId == null ? null : prov.imageProv(modal.currentImageId)
)
const fallbackArtist = computed(() => modal.current?.artist || null)
const showArtistFallback = computed(() => {
const st = state.value
// Only show the fallback when provenance is genuinely empty (no
// entries, not loading, no error) AND the image has a direct
// artist_id we can surface.
if (!st || st.loading || st.error) return false
if (Array.isArray(st.entries) && st.entries.length > 0) return false
return fallbackArtist.value != null
})
// Show the panel while loading or on error, when there is >=1 entry,
// or when the artist fallback applies (image_record.artist_id set but
// no ImageProvenance row, e.g. filesystem-imported IR images).
const show = computed(() => {
const st = state.value
if (!st) return false
if (st.loading || st.error) return true
if (Array.isArray(st.entries) && st.entries.length > 0) return true
return showArtistFallback.value
})
const attachments = computed(() => state.value?.attachments || [])
function postDate(e) { return formatPostDate(e.post.date) }
function openPost(postId) {
router.push({ path: '/gallery', query: { post_id: postId } })
modal.close()
}
</script>
<style scoped>
.fc-prov { padding: 16px 16px 0; }
.fc-prov__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 500;
color: rgb(var(--v-theme-on-surface));
margin-bottom: 12px;
}
.fc-prov__card {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
}
.fc-prov__head {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
text-transform: lowercase;
}
.fc-prov__post {
font-weight: 600; margin: 4px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-prov__meta {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
display: flex; gap: 6px; flex-wrap: wrap;
}
.fc-prov__meta a { color: rgb(var(--v-theme-accent)); text-decoration: none; }
.fc-prov__actions {
display: flex; gap: 12px; flex-wrap: wrap;
margin-top: 6px; font-size: 13px;
}
.fc-prov__actions a {
color: rgb(var(--v-theme-accent)); text-decoration: none;
}
.fc-prov__desc {
margin-top: 8px; font-size: 13px; line-height: 1.5;
max-height: 180px; overflow: auto;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-prov__desc :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-prov__attach { padding: 0 0 12px; }
.fc-prov__attach-title {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin: 8px 0 4px;
}
.fc-prov__attach-row {
display: block; font-size: 13px; text-decoration: none;
color: rgb(var(--v-theme-accent)); padding: 2px 0;
}
.fc-prov__attach-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>