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.
This commit is contained in:
2026-05-28 01:12:20 -04:00
parent 104cac5dca
commit e1fc65bd1b
6 changed files with 203 additions and 2 deletions
@@ -37,9 +37,13 @@
<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"
v-if="e.post.description_html && expanded[e.provenance_id]"
class="fc-prov__desc" v-html="e.post.description_html"
/>
</article>
@@ -74,7 +78,7 @@
</template>
<script setup>
import { computed, watch } from 'vue'
import { computed, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { useProvenanceStore } from '../../stores/provenance.js'
@@ -84,6 +88,16 @@ 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) },