Files
FabledCurator/frontend/src/components/posts/PostCard.vue
T
bvandeusen 2f66de2928
CI / lint (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Failing after 2m15s
CI / intapi (push) Failing after 2m18s
CI / intcore (push) Failing after 2m17s
feat(model): nullable Post.source_id + denormalized Post.artist_id; retire sidecar synthetics
Operator-asked 2026-06-01 after the Dymkens orphan investigation
(Scribe plan #540). The pre-2030 sidecar-synthetic Source pattern
(`sidecar:<platform>:<slug>` enabled=false rows) existed solely to
satisfy `Post.source_id NOT NULL`, and leaked into the Subscriptions
UI as phantom subscriptions. Now the data model says what's true:
filesystem-imported content with no live subscription has NULL
source_id, full stop.

## Schema (alembic 0030)

- `post.artist_id` — NEW NOT NULL FK to artist (CASCADE). Backfilled
  from source.artist_id in the migration. Indexed for the artist-filter
  queries.
- `post.source_id` — NOT NULL → nullable; FK ondelete CASCADE → SET
  NULL. Deleting a Source detaches its Posts instead of destroying
  archived content (subscription ends, archive stays).
- `image_provenance.source_id` — same nullable + SET NULL.
- Partial unique index `uq_post_artist_external_id_null_source` on
  (artist_id, external_post_id) WHERE source_id IS NULL — guards
  filesystem-import dedup since the existing source-bound unique
  ignores NULLs (Postgres treats NULL != NULL).
- Sidecar synthetic Sources deleted: NULL out FKs in post,
  image_provenance first, then DELETE FROM source WHERE url LIKE
  'sidecar:%'. The Dymkens cleanup.

## Model + service changes

- `Post.source_id` → `Mapped[int | None]`; new `Post.artist_id`
  denormalized.
- `ImageProvenance.source_id` → `Mapped[int | None]`.
- Importer: `_source_for_sidecar` (synthetic-creating) →
  `_lookup_source_for_sidecar` (returns None when no subscription).
  `_find_or_create_post` takes required `artist_id`; matches on
  (source_id, external_post_id) for source-bound posts or
  (artist_id, external_post_id) for NULL-source posts.
- Service queries switched off the Source detour to use Post.artist_id
  directly: post_feed_service.scroll/around/get_post (LEFT JOIN to
  Source so NULL-source posts surface); artist_service date_row/
  activity/post_count; provenance_service.for_image/for_post (LEFT
  JOIN); gallery_service._provenance_exists_where_artist via
  Post.artist_id instead of ImageProvenance.source_id → Source.
- `_to_dict` and provenance dict-builders emit `"source": null` for
  NULL-source rows.

## Frontend

- `ProvenancePanel.vue` + `PostCard.vue`: render `e.source?.platform
  ?? 'filesystem import'` so NULL-source posts get a clear
  "filesystem import" affordance instead of a NaN crash.

## Tests

- `test_importer_upsert_helpers`: removed the four synthetic-anchor
  tests; added `_find_or_create_post_idempotent_with_null_source`
  (dedup via the partial unique index) and
  `_lookup_source_for_sidecar_returns_*` (existing-subscription +
  none cases). The existing `_find_or_create_post_idempotent` now
  also passes `artist_id` and asserts it.
- 8 other test files updated: every direct `Post(...)` construction
  gains `artist_id=<artist>.id`. The `_seed_post` helper in
  `test_post_feed_service` looks up artist_id from the source row so
  callsites stay one-arg.

## Verification on deploy

After alembic 0030 runs:
- `SELECT COUNT(*) FROM source WHERE url LIKE 'sidecar:%'` → 0.
- `SELECT COUNT(*) FROM post WHERE source_id IS NULL` → count of
  filesystem-imported posts (Dymkens + any other historical).
- Every `post.artist_id` non-null; consistent with source.artist_id
  for source-bound rows.
- Subscriptions tab: no Dymkens phantom row.
- Artist detail → Posts/Gallery: Dymkens's content still reachable
  via Post.artist_id.
- Provenance panel renders "filesystem import" chip for NULL-source
  posts; PostCard same.

## Out of scope

- UI to manage/delete orphan NULL-source Posts. Data model is right;
  UI follows if operator wants it.
2026-06-01 14:17:52 -04:00

428 lines
13 KiB
Vue

<template>
<v-card
:class="['fc-post-card', expanded && 'fc-post-card--expanded']"
variant="outlined"
:tabindex="expanded ? -1 : 0"
@click="onCardClick"
@keydown.enter="onCardClick"
>
<div class="fc-post-card__head">
<!-- Posts with no live subscription have source=null (alembic
0030); show a "filesystem import" affordance instead of a
platform chip. -->
<v-chip size="x-small" variant="tonal">
{{ post.source?.platform ?? 'filesystem import' }}
</v-chip>
<RouterLink
:to="{ name: 'artist', params: { slug: post.artist.slug } }"
class="fc-post-card__artist"
@click.stop
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="expanded && images.length" class="fc-post-card__meta">
· {{ images.length }} image{{ images.length === 1 ? '' : 's' }}
</span>
<span v-if="expanded && attachments.length" class="fc-post-card__meta">
· {{ attachments.length }} attachment{{ attachments.length === 1 ? '' : 's' }}
</span>
<v-spacer />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
icon="mdi-open-in-new" size="x-small" variant="text"
:aria-label="`open original post on ${post.source?.platform ?? 'web'}`"
@click.stop
/>
<v-btn
:icon="expanded ? 'mdi-chevron-up' : 'mdi-chevron-down'"
size="x-small" variant="text"
:aria-label="expanded ? 'Collapse post' : 'Expand post'"
@click.stop="toggleExpanded"
/>
</div>
<!-- Compact body: collapsed card. Hero + thumb rail + truncated text. -->
<div v-if="!expanded" class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="images.length">
<div class="fc-post-card__hero">
<img :src="hero.thumbnail_url" :alt="`hero thumbnail`" loading="lazy" />
</div>
<div v-if="rail.length" class="fc-post-card__rail">
<div v-for="t in rail" :key="t.image_id" class="fc-post-card__rail-cell">
<img :src="t.thumbnail_url" :alt="`thumbnail`" loading="lazy" />
</div>
<div v-if="moreCount > 0" class="fc-post-card__rail-more">
+{{ moreCount }}
</div>
</div>
</template>
<PostEmptyThumbs v-else />
</div>
<div class="fc-post-card__text">
<h3 v-if="plainTitle" class="fc-post-card__title">
{{ plainTitle }}
</h3>
<h3 v-else class="fc-post-card__title fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h3>
<p v-if="post.description_plain" class="fc-post-card__desc">
{{ post.description_plain }}
</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description)
</p>
<div v-if="post.attachments?.length" class="fc-post-card__atts">
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
{{ post.attachments.length }} attachment{{ post.attachments.length === 1 ? '' : 's' }}
</div>
</div>
</div>
<!-- Expanded body: title, full mosaic, full sanitized HTML description,
attachments. Lazy-loaded detail via getPostFull. -->
<div v-else class="fc-post-card__expanded">
<h2 v-if="plainTitle" class="fc-post-card__title-full">
{{ plainTitle }}
</h2>
<h2 v-else class="fc-post-card__title-full fc-post-card__title--missing">
Post {{ post.external_post_id }}
</h2>
<section v-if="images.length" class="fc-post-card__sec">
<PostImageGrid :thumbnails="images" />
<div v-if="!detailLoaded" class="fc-post-card__loading-hint">
Loading full image list
</div>
</section>
<section v-if="descriptionHtml" class="fc-post-card__sec">
<div class="fc-post-card__desc-full" v-html="descriptionHtml" />
</section>
<section v-else-if="detailLoaded" class="fc-post-card__sec">
<p class="fc-post-card__desc fc-post-card__desc--missing">(no description)</p>
</section>
<section v-if="attachments.length" class="fc-post-card__sec">
<h3 class="fc-post-card__h3">Attachments</h3>
<div class="fc-post-card__atts-full">
<a
v-for="att in attachments" :key="att.id"
:href="att.download_url" download
class="fc-post-card__att"
@click.stop
>
<v-icon size="small" class="fc-post-card__att-icon">mdi-paperclip</v-icon>
<span>{{ att.original_filename }}</span>
<span class="fc-post-card__att-size">({{ formatBytes(att.size_bytes) }})</span>
</a>
</div>
</section>
</div>
</v-card>
</template>
<script setup>
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { usePostsStore } from '../../stores/posts.js'
import { sanitizeHtml, toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostImageGrid from './PostImageGrid.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postsStore = usePostsStore()
// Per-card expand state. No global modal — each PostCard owns its own
// view-mode and lazy-loaded detail.
const expanded = ref(false)
const detail = ref(null)
const detailLoaded = ref(false)
const detailError = ref(null)
// When expanded + detail loaded, prefer the uncapped detail thumbnails +
// full description. Falls back to feed shape if detail fetch is in flight
// or failed.
const merged = computed(() => detail.value || props.post)
const images = computed(() => merged.value.thumbnails || [])
const attachments = computed(() => merged.value.attachments || [])
// Titles can arrive as stored HTML (e.g. "<strong>…</strong>"); render as
// plain text — the CSS makes the title bold.
const plainTitle = computed(() => toPlainText(props.post.post_title))
// Compact-view hero+rail derived from the feed-shape (capped 6).
const hero = computed(() => props.post.thumbnails?.[0])
const rail = computed(() => (props.post.thumbnails || []).slice(1, 4))
const moreCount = computed(() => {
const more = props.post.thumbnails_more || 0
const railLen = rail.value.length
const extraShown = Math.max(0, (props.post.thumbnails?.length || 0) - 1 - railLen)
return more + extraShown
})
const sortDateIso = computed(() => props.post.post_date || props.post.downloaded_at)
const absoluteDate = computed(() => new Date(sortDateIso.value).toLocaleString())
const relativeDate = computed(() => {
const then = new Date(sortDateIso.value).getTime()
const diff = (Date.now() - then) / 1000
if (diff < 60) return `${Math.floor(diff)}s ago`
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
if (diff < 86400 * 30) return `${Math.floor(diff / 86400)}d ago`
return new Date(sortDateIso.value).toLocaleDateString()
})
const descriptionHtml = computed(() => {
// Detail endpoint returns description_full as plain text (the service
// uses html_to_plain on the stored description). Render plain text in
// <p> wrappers; sanitize defensively in case the backend ever returns
// raw HTML.
const raw = merged.value.description_full || merged.value.description_plain
if (!raw) return ''
if (/[<>]/.test(raw)) return sanitizeHtml(raw)
const esc = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return esc
.split(/\n\s*\n/)
.map((p) => `<p>${p.replace(/\n/g, '<br>')}</p>`)
.join('')
})
async function loadDetailIfNeeded () {
if (detailLoaded.value || detail.value) return
try {
detail.value = await postsStore.getPostFull(props.post.id)
detailLoaded.value = true
} catch (e) {
detailError.value = e.message
// Leave merged on feed-shape; the card still renders the truncated
// body so the operator isn't staring at a blank panel.
}
}
function toggleExpanded () {
expanded.value = !expanded.value
if (expanded.value) loadDetailIfNeeded()
}
function onCardClick (e) {
// Inner interactive elements use @click.stop so they never reach here.
// Whole-card click expands a collapsed card; collapsing is chevron-only
// so a mosaic-image click on an expanded card can never accidentally
// collapse the surrounding card.
if (expanded.value) return
expanded.value = true
loadDetailIfNeeded()
}
function formatBytes (n) {
if (!n) return '0 B'
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
}
</script>
<style scoped>
.fc-post-card {
padding: 1rem;
margin-bottom: 1rem;
container-type: inline-size;
transition: border-color 0.15s ease;
}
.fc-post-card:not(.fc-post-card--expanded) {
cursor: pointer;
}
.fc-post-card:not(.fc-post-card--expanded):hover {
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card:focus-visible {
outline: 2px solid rgb(var(--v-theme-accent));
outline-offset: 2px;
}
.fc-post-card--expanded {
border-color: rgb(var(--v-theme-accent) / 0.6);
}
.fc-post-card__head {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 12px;
flex-wrap: wrap;
}
.fc-post-card__artist {
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-weight: 600;
}
.fc-post-card__artist:hover { color: rgb(var(--v-theme-accent)); }
.fc-post-card__date,
.fc-post-card__meta { white-space: nowrap; }
/* ---- COMPACT BODY ---- */
.fc-post-card__body {
display: flex;
flex-direction: column;
gap: 16px;
}
@container (min-width: 800px) {
.fc-post-card__body {
flex-direction: row;
gap: 24px;
}
.fc-post-card__media { flex: 0 0 50%; }
.fc-post-card__text { flex: 1 1 0; min-width: 0; }
}
.fc-post-card__hero {
width: 100%;
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: 6px;
}
.fc-post-card__hero img {
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail {
display: flex; gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px; height: 80px;
overflow: hidden; border-radius: 4px;
}
.fc-post-card__rail-cell img {
width: 100%; height: 100%;
object-fit: cover; display: block;
}
.fc-post-card__rail-more {
width: 80px; height: 80px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed rgb(var(--v-theme-on-surface-variant));
border-radius: 4px;
color: rgb(var(--v-theme-on-surface-variant));
font-size: 0.85rem;
}
.fc-post-card__title {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 700;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fc-post-card__title--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__title {
font-size: 20px;
-webkit-line-clamp: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
}
.fc-post-card__desc {
font-size: 0.9rem;
line-height: 1.5;
color: rgb(var(--v-theme-on-surface));
margin: 0 0 12px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.fc-post-card__desc--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
@container (min-width: 800px) {
.fc-post-card__desc { -webkit-line-clamp: 5; }
}
.fc-post-card__atts {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__att-icon { color: rgb(var(--v-theme-on-surface-variant)); }
/* ---- EXPANDED BODY ---- */
.fc-post-card__expanded {
display: flex;
flex-direction: column;
gap: 20px;
}
.fc-post-card__title-full {
font-family: 'Fraunces', Georgia, serif;
font-size: 22px;
font-weight: 700;
margin: 0;
color: rgb(var(--v-theme-on-surface));
}
@container (min-width: 800px) {
.fc-post-card__title-full { font-size: 26px; }
}
.fc-post-card__sec { margin: 0; }
.fc-post-card__h3 {
font-family: 'Fraunces', Georgia, serif;
font-size: 16px;
font-weight: 500;
margin: 0 0 8px 0;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__loading-hint {
margin-top: 8px;
font-size: 0.8rem;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__desc-full {
font-size: 0.95rem;
line-height: 1.55;
color: rgb(var(--v-theme-on-surface));
}
.fc-post-card__desc-full :deep(p) { margin: 0 0 12px 0; }
.fc-post-card__desc-full :deep(a) { color: rgb(var(--v-theme-accent)); }
.fc-post-card__atts-full {
display: flex; flex-wrap: wrap; gap: 8px;
}
.fc-post-card__att {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid rgb(var(--v-theme-on-surface-variant));
border-radius: 999px;
color: rgb(var(--v-theme-on-surface));
text-decoration: none;
font-size: 0.85rem;
}
.fc-post-card__att:hover {
color: rgb(var(--v-theme-accent));
border-color: rgb(var(--v-theme-accent));
}
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>