fix(posts): post-card thumbnail strip spans the full hero width
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m1s

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>
This commit is contained in:
2026-06-06 17:04:23 -04:00
parent 6332ae13fd
commit 9374f63953
2 changed files with 56 additions and 8 deletions
+32 -8
View File
@@ -34,7 +34,10 @@
>
<img :src="hero.thumbnail_url" alt="hero thumbnail" loading="lazy" />
</button>
<div v-if="rail.length || moreCount" class="fc-post-card__rail">
<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"
@@ -117,13 +120,29 @@ const totalImages = computed(() => images.value.length + (props.post.thumbnails_
const plainTitle = computed(() => toPlainText(props.post.post_title))
const hero = computed(() => images.value[0])
const rail = computed(() => images.value.slice(1, 4))
// 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(() => {
const more = props.post.thumbnails_more || 0
const extraShown = Math.max(0, images.value.length - visibleCount.value)
return more + extraShown
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())
@@ -279,11 +298,16 @@ function formatBytes (n) {
.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: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(var(--fc-rail-cols, 3), 1fr);
gap: 6px; margin-top: 6px;
}
.fc-post-card__rail-cell {
width: 80px; height: 80px;
width: 100%; height: 84px;
overflow: hidden; border-radius: 4px;
}
.fc-post-card__rail-cell img {
@@ -291,7 +315,7 @@ function formatBytes (n) {
object-fit: cover; display: block;
}
.fc-post-card__rail-more {
width: 80px; height: 80px;
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;
+24
View File
@@ -40,6 +40,30 @@ describe('PostCard', () => {
expect(full.text()).not.toContain('Show more')
})
const thumbs = (n) =>
Array.from({ length: n }, (_, i) => ({ image_id: 100 + i, thumbnail_url: `/t${i}` }))
it('fills the rail to full width (5 cells, no overflow) for a 6-image post', () => {
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 0 }
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
// hero + 5 rail thumbnails, no "+N" tile.
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(5)
expect(w.find('.fc-post-card__rail-more').exists()).toBe(false)
// The grid spans the hero width via a column count == cells shown.
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
})
it('reserves the last cell for a "+N" overflow tile when there are more images', () => {
const post = { ...BASE, description_plain: 'd', thumbnails: thumbs(6), thumbnails_more: 4 }
const w = mountComponent(PostCard, { props: { post }, pinia: freshPinia() })
// 4 thumbnails + 1 overflow tile == 5 columns; tile counts every hidden image.
expect(w.findAll('.fc-post-card__rail-cell')).toHaveLength(4)
const more = w.find('.fc-post-card__rail-more')
expect(more.exists()).toBe(true)
expect(more.text()).toBe('+5')
expect(w.find('.fc-post-card__rail').attributes('style')).toContain('--fc-rail-cols: 5')
})
it('clicking an image opens the post-scoped modal (never expands the card)', async () => {
const pinia = freshPinia()
const modal = useModalStore()