Files
FabledCurator/frontend/src/components/posts/PostCard.vue
T
bvandeusen 9374f63953
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m1s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
fix(posts): post-card thumbnail strip spans the full hero width
The hero collage's thumbnail rail hard-capped at 3 fixed-80px cells, left-
aligned, so it never reached the edge of the (50%-width) hero. Make the rail a
CSS grid of equal columns (1fr) at a fixed height that stretches to the hero's
full width: show up to 5 thumbnails, and when a post has more images than fit,
the last cell becomes the "+N" overflow tile (count unchanged). Column count is
driven by --fc-rail-cols so the strip always reaches the hero edge regardless
of image count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:04:23 -04:00

406 lines
13 KiB
Vue

<template>
<v-card class="fc-post-card" variant="outlined">
<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"
>{{ post.artist.name }}</RouterLink>
<span class="fc-post-card__date" :title="absoluteDate">{{ relativeDate }}</span>
<span v-if="totalImages" class="fc-post-card__meta">
· {{ totalImages }} image{{ totalImages === 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'}`"
/>
</div>
<div class="fc-post-card__body">
<div class="fc-post-card__media">
<template v-if="images.length">
<!-- Images open the post-scoped image modal (look bigger + arrow
through ALL the post's images) the card never expands. -->
<button
type="button" class="fc-post-card__hero"
aria-label="Open images" @click="openModal(hero.image_id)"
>
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
</button>
<div
v-if="rail.length || moreCount" class="fc-post-card__rail"
:style="{ '--fc-rail-cols': railCols }"
>
<button
v-for="t in rail" :key="t.image_id" type="button"
class="fc-post-card__rail-cell"
aria-label="Open image" @click="openModal(t.image_id)"
>
<img :src="t.thumbnail_url" alt="thumbnail" loading="lazy" />
</button>
<button
v-if="moreCount > 0" type="button"
class="fc-post-card__rail-more"
:aria-label="`Open ${moreCount} more images`"
@click="openModalAtMore"
>+{{ moreCount }}</button>
</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="hasDescription" ref="descEl"
class="fc-post-card__desc"
:class="{ 'fc-post-card__desc--clamped': !descExpanded }"
>{{ descText }}</p>
<p v-else class="fc-post-card__desc fc-post-card__desc--missing">
(no description)
</p>
<!-- The ONLY in-place expansion: the post text, and only when it's
actually truncated (server flag or a CSS-clamp overflow). -->
<button
v-if="canExpand" type="button" class="fc-post-card__more"
@click="toggleDesc"
>{{ descExpanded ? 'Show less' : 'Show more' }}</button>
<div v-if="attachments.length" class="fc-post-card__atts">
<a
v-for="att in attachments" :key="att.id"
:href="att.download_url" download class="fc-post-card__att"
>
<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>
</div>
</div>
</v-card>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
const props = defineProps({
post: { type: Object, required: true },
})
const postsStore = usePostsStore()
const modal = useModalStore()
// Full detail (uncapped thumbnails + full description), fetched lazily — only
// when opening the modal for a post with >6 images, or expanding a
// server-truncated description.
const detail = ref(null)
const attachments = computed(() => props.post.attachments || [])
const images = computed(() => props.post.thumbnails || [])
const totalImages = computed(() => images.value.length + (props.post.thumbnails_more || 0))
const plainTitle = computed(() => toPlainText(props.post.post_title))
const hero = computed(() => images.value[0])
// The thumbnail strip spans the hero's full width (CSS grid, equal columns),
// rather than a fixed 3-cell cap. Show up to RAIL_MAX cells; when there are
// more images than fit, the last cell becomes a "+N" overflow tile so the
// count stays accurate.
const RAIL_MAX = 5
const serverMore = computed(() => props.post.thumbnails_more || 0)
const afterHero = computed(() => images.value.slice(1))
const hasOverflow = computed(
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
)
const rail = computed(() =>
hasOverflow.value
? afterHero.value.slice(0, RAIL_MAX - 1)
: afterHero.value.slice(0, RAIL_MAX),
)
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
const moreCount = computed(() => {
if (!hasOverflow.value) return 0
return serverMore.value + Math.max(0, images.value.length - visibleCount.value)
})
// Columns = thumbnails shown plus the overflow tile, so the strip fills the
// hero's full width with equal cells.
const railCols = computed(() => rail.value.length + (moreCount.value > 0 ? 1 : 0))
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()
})
// --- images → post-scoped modal ---------------------------------------
async function fullImageIds () {
// Feed caps thumbnails at 6; load detail for the complete id list only when
// there are more, so the modal can arrow through ALL of the post's images.
if ((props.post.thumbnails_more || 0) === 0) {
return images.value.map((t) => t.image_id)
}
if (!detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* fall back to the capped feed list */ }
}
return (detail.value?.thumbnails || images.value).map((t) => t.image_id)
}
async function openModal (imageId) {
modal.open(imageId, { postImageIds: await fullImageIds() })
}
async function openModalAtMore () {
const ids = await fullImageIds()
const first = ids[visibleCount.value] ?? ids[0]
if (first != null) modal.open(first, { postImageIds: ids })
}
// --- description "Show more" (text-only, in place, only when truncated) ----
const descExpanded = ref(false)
const cssOverflow = ref(false)
const descEl = ref(null)
const hasDescription = computed(() => !!props.post.description_plain)
const fullDescription = computed(() => detail.value?.description_full || null)
const descText = computed(() =>
descExpanded.value
? (fullDescription.value || props.post.description_plain)
: props.post.description_plain,
)
// Show the toggle iff the server truncated the text OR the clamp is cutting it.
const canExpand = computed(
() => props.post.description_truncated === true || cssOverflow.value,
)
function measureOverflow () {
const el = descEl.value
cssOverflow.value = !!el && el.scrollHeight > el.clientHeight + 1
}
let ro = null
onMounted(() => {
nextTick(measureOverflow)
// Re-measure when the card resizes (the container-query clamp differs by
// width). Guarded for happy-dom / older runtimes without ResizeObserver.
if (typeof ResizeObserver !== 'undefined' && descEl.value) {
ro = new ResizeObserver(() => { if (!descExpanded.value) measureOverflow() })
ro.observe(descEl.value)
}
})
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
async function toggleDesc () {
if (!descExpanded.value) {
if (props.post.description_truncated && !fullDescription.value && !detail.value) {
try {
detail.value = await postsStore.getPostFull(props.post.id)
} catch { /* render the truncated text rather than nothing */ }
}
descExpanded.value = true
} else {
descExpanded.value = false
nextTick(measureOverflow)
}
}
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;
}
.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; }
.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; }
}
/* Image tiles are buttons (open the post-scoped modal) — reset button chrome. */
.fc-post-card__hero,
.fc-post-card__rail-cell,
.fc-post-card__rail-more {
display: block; padding: 0; border: 0; background: none;
cursor: pointer;
}
.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;
transition: transform 0.2s ease, filter 0.2s ease;
}
.fc-post-card__hero:hover img,
.fc-post-card__rail-cell:hover img { transform: scale(1.03); filter: brightness(1.08); }
/* Equal columns spanning the hero's full width — cells stretch horizontally
(1fr) at a fixed height, so the strip always reaches the edge of the hero
regardless of how many thumbnails the post has. */
.fc-post-card__rail {
display: grid;
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 100%; height: 84px;
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: 100%; height: 84px;
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__rail-more:hover {
border-color: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-accent));
}
.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;
white-space: pre-wrap;
}
/* Clamp ONLY while collapsed; expanding drops the clamp to show it all. */
.fc-post-card__desc--clamped {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
@container (min-width: 800px) {
.fc-post-card__desc--clamped { -webkit-line-clamp: 5; }
}
.fc-post-card__desc--missing {
font-style: italic;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-post-card__more {
margin-top: 6px;
padding: 0;
background: none; border: 0; cursor: pointer;
color: rgb(var(--v-theme-accent));
font-size: 0.85rem; font-weight: 600;
}
.fc-post-card__more:hover { text-decoration: underline; }
.fc-post-card__atts {
display: flex; flex-wrap: wrap; gap: 8px;
margin-top: 12px;
}
.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-icon { color: rgb(var(--v-theme-on-surface-variant)); }
.fc-post-card__att-size { color: rgb(var(--v-theme-on-surface-variant)); }
</style>