83c1745fd0
PostCard + modal ProvenancePanel show the English title/description by default when a translation exists, with a per-card "show original (<lang>)" toggle — translated bodies render as plain text, originals keep their sanitized HTML. New TranslationCard in Settings → Ingestion & filters: enable switch, Interpreter base URL (generic placeholder, no default host), target language, a reachability indicator + untranslated-posts count + "Translate now". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
291 lines
11 KiB
Vue
291 lines
11 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>
|
|
|
|
<!-- Cards scroll independently of the section title + attachments
|
|
below them. Cap at ~2.5 cards visible (operator-asked 2026-06-01:
|
|
keeps the Tags section anchored below at a consistent position;
|
|
the half-visible third card hints there's more). -->
|
|
<div v-else class="fc-prov__cards">
|
|
<article
|
|
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
|
|
>
|
|
<div class="fc-prov__head">
|
|
<!-- Posts with no live subscription have source=null (alembic
|
|
0030); render an explicit "filesystem import" affordance
|
|
instead of a platform chip. -->
|
|
<span class="fc-prov__platform">
|
|
{{ e.source?.platform ?? 'filesystem import' }}
|
|
</span>
|
|
<span v-if="postDate(e)" class="fc-prov__date">{{ postDate(e) }}</span>
|
|
</div>
|
|
<button
|
|
type="button" class="fc-prov__post"
|
|
:title="`Open ${postTitle(e)} in the posts feed for ${e.artist.name}`"
|
|
@click="openPost(e.post.id, e.artist.id)"
|
|
>
|
|
{{ postTitle(e) }}
|
|
</button>
|
|
<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 v-if="hasTranslation(e)" class="fc-prov__actions">
|
|
<a
|
|
href="#" @click.prevent="toggleOrig(e.provenance_id)"
|
|
>{{ showOrig[e.provenance_id] ? 'Show translation' : `Show original${langLabel(e)}` }}</a>
|
|
</div>
|
|
<div v-if="e.post.description_html" class="fc-prov__actions">
|
|
<a
|
|
href="#" @click.prevent="toggleDesc(e.provenance_id)"
|
|
>{{ expanded[e.provenance_id] ? 'Hide description ▴' : 'Show description ▾' }}</a>
|
|
</div>
|
|
<!-- Translated body = plain text; original = sanitized HTML (#143). -->
|
|
<p
|
|
v-if="showTranslated(e) && expanded[e.provenance_id] && e.post.description_translated"
|
|
class="fc-prov__desc"
|
|
>{{ e.post.description_translated }}</p>
|
|
<div
|
|
v-else-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>
|
|
</div>
|
|
|
|
<div v-if="attachments.length" class="fc-prov__attach">
|
|
<h4 class="fc-prov__attach-title">
|
|
{{ attachments.length === 1 ? 'Attachment' : `Attachments (${attachments.length})` }}
|
|
</h4>
|
|
<!-- Scroll-capped: a single post can carry dozens of archives (HR
|
|
bundle posts), which previously ballooned the panel past the
|
|
viewport. Mirror the cards' independent-scroll treatment. -->
|
|
<div class="fc-prov__attach-list">
|
|
<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>
|
|
</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'
|
|
import { toPlainText } from '../../utils/htmlSanitize.js'
|
|
|
|
// `imageId`/`image` let a non-modal surface (the Explore workspace) render
|
|
// provenance for its anchor. Default to the modal store's current image so the
|
|
// image modal is unchanged. Provenance is its own system (loaded by id via the
|
|
// provenance store), so it only needs the right id + the artist fallback.
|
|
const props = defineProps({
|
|
imageId: { type: Number, default: null },
|
|
image: { type: Object, default: null },
|
|
})
|
|
const modal = useModalStore()
|
|
const prov = useProvenanceStore()
|
|
const router = useRouter()
|
|
const effectiveId = computed(() => props.imageId ?? modal.currentImageId)
|
|
const effectiveImage = computed(() => props.image ?? modal.current)
|
|
|
|
// 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] }
|
|
// Per-entry "show the original (untranslated) text" toggle (#143).
|
|
const showOrig = reactive({})
|
|
function toggleOrig(id) { showOrig[id] = !showOrig[id] }
|
|
watch(() => effectiveId.value, () => {
|
|
for (const k of Object.keys(expanded)) delete expanded[k]
|
|
for (const k of Object.keys(showOrig)) delete showOrig[k]
|
|
})
|
|
|
|
watch(
|
|
() => effectiveId.value,
|
|
(id) => { if (id != null) prov.loadForImage(id) },
|
|
{ immediate: true }
|
|
)
|
|
|
|
const state = computed(() =>
|
|
effectiveId.value == null ? null : prov.imageProv(effectiveId.value)
|
|
)
|
|
|
|
const fallbackArtist = computed(() => effectiveImage.value?.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) }
|
|
// Translation-forward (#143): show the English by default when present.
|
|
function hasTranslation(e) {
|
|
return !!(e.post.title_translated || e.post.description_translated)
|
|
}
|
|
function showTranslated(e) {
|
|
return hasTranslation(e) && !showOrig[e.provenance_id]
|
|
}
|
|
function langLabel(e) {
|
|
return e.post.translated_source_lang ? ` (${e.post.translated_source_lang})` : ''
|
|
}
|
|
function postTitle(e) {
|
|
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render
|
|
// as plain text (the CSS makes it bold).
|
|
const t = showTranslated(e) && e.post.title_translated
|
|
? e.post.title_translated
|
|
: e.post.title
|
|
return toPlainText(t) || `Post ${e.post.external_post_id}`
|
|
}
|
|
|
|
function openPost(postId, artistId) {
|
|
// Land on the post in the posts feed (in context), not the gallery
|
|
// image grid. Scope the feed to this artist so the user lands in
|
|
// that creator's stream, not the global one — operator-flagged
|
|
// 2026-06-01. PostsView reads `artist_id` from the query string
|
|
// (PostsView.vue line ~92) and filters via post_feed_service.
|
|
router.push({
|
|
path: '/posts',
|
|
query: { post_id: postId, artist_id: artistId },
|
|
})
|
|
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__cards {
|
|
/* 2.5 cards-worth at the typical collapsed card height (~108px each
|
|
incl. 10px gap). Slightly under to ensure the third card's bottom
|
|
edge is clipped — the visual cue that there's more below. */
|
|
max-height: 270px;
|
|
overflow-y: auto;
|
|
/* Hairline scrollbar that doesn't compete with content. */
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
|
|
/* Pad-right so the scrollbar gutter doesn't squeeze card borders. */
|
|
padding-right: 4px;
|
|
}
|
|
.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 {
|
|
/* Clickable title — opens the post in the artist-scoped feed
|
|
(operator-flagged 2026-06-01: title IS the primary action, the
|
|
prior "View post" link was redundant). Styled as a button-link:
|
|
accent color, underline on hover, focus ring for keyboard nav. */
|
|
display: block; width: 100%; text-align: left;
|
|
background: none; border: none; padding: 0;
|
|
font: inherit; font-weight: 700;
|
|
margin: 4px 0;
|
|
color: rgb(var(--v-theme-accent));
|
|
cursor: pointer;
|
|
}
|
|
.fc-prov__post:hover { text-decoration: underline; }
|
|
.fc-prov__post:focus-visible {
|
|
outline: 2px solid rgb(var(--v-theme-accent));
|
|
outline-offset: 2px; border-radius: 3px;
|
|
}
|
|
.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-list {
|
|
/* ~7 rows visible before scrolling; the clipped row hints at more.
|
|
Hairline scrollbar matching .fc-prov__cards. */
|
|
max-height: 180px;
|
|
overflow-y: auto;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: rgb(var(--v-theme-surface-light)) transparent;
|
|
padding-right: 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>
|