feat: the Latest feed uses a wide window — filmstrip cards, a day gutter and a filter rail (407 A, D, E)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m10s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 6s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 1m1s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m10s
On the operator's 3432px window the feed was a 900px column, 26% of the width. They picked three options from a to-scale layout study. A — filmstrip card (PostCard.vue): - A card measures itself with a ResizeObserver. At 1100px or wider its hero gets a fixed height, clamp(260px, 34vh, 460px), and the extra images move into a 2-column grid of squares beside it. The grid cells are sized from the hero height, so the grid ends flush with the hero. - The rail cap is 4 cells in this layout (2×2, the last becoming "+N") and 5 in the narrow layout, which is unchanged. - The description clamp drops to 4 lines, because long reads happen in the expanded view. - The hero has a height, not a width, so a wide card can't grow into a full-screen post. That was the operator's constraint. D — day gutter (PostsView.vue): - The normal feed groups consecutive posts by local day (Today, Yesterday, a weekday, or a date), with post and artist counts for what has loaded. - Runs rather than date buckets, because the sort key includes resurfaced_at, which the payload doesn't carry. A resurfaced grouping gets its own heading where it actually appears, instead of being pulled out of order. E — filter rail (PostsView.vue): - At 1600px and wider, the filters and status ribbon stack in a sticky 280px left rail, and each day's heading sits in a sticky 150px gutter beside its posts. - Below 1600px the layout is exactly the old one, including the 900px column. The in-context (post_id) view gets the wide column but no rail or day grouping, so anchor scrolling is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<v-card class="fc-post-card" variant="outlined">
|
||||
<v-card
|
||||
ref="cardEl" class="fc-post-card" variant="outlined"
|
||||
:class="{ 'fc-post-card--wide': wide }"
|
||||
>
|
||||
<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. -->
|
||||
@@ -58,7 +61,7 @@
|
||||
</button>
|
||||
<div
|
||||
v-if="rail.length || moreCount" class="fc-post-card__rail"
|
||||
:style="{ '--fc-rail-cols': railCols }"
|
||||
:style="{ '--fc-rail-cols': railCols, '--fc-grid-cols': Math.min(2, railCols) }"
|
||||
>
|
||||
<button
|
||||
v-for="t in rail" :key="t.image_id" type="button"
|
||||
@@ -221,20 +224,33 @@ const synthesisTitle = computed(() => {
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
// Filmstrip layout (milestone #407, option A). On a very wide window a card
|
||||
// that just grew would give one post the whole screen, so a WIDE card instead
|
||||
// pins the hero to a fixed height and moves the extra images into a 2-column
|
||||
// grid BESIDE it. Measured on the card rather than the viewport because the
|
||||
// same card renders in the Latest feed, Browse, and the in-context view, each
|
||||
// at a different width.
|
||||
const WIDE_CARD_PX = 1100
|
||||
const cardEl = ref(null)
|
||||
const wide = ref(false)
|
||||
|
||||
// The narrow layout's strip spans the hero's full width (CSS grid, equal
|
||||
// columns); the wide layout's grid is 2×2. Show up to that many cells; when
|
||||
// there are more images than fit, the last cell becomes a "+N" overflow tile so
|
||||
// the count stays accurate.
|
||||
const RAIL_MAX_NARROW = 5
|
||||
const RAIL_MAX_WIDE = 4
|
||||
const serverMore = computed(() => props.post.thumbnails_more || 0)
|
||||
const afterHero = computed(() => images.value.slice(1))
|
||||
const railMax = computed(() => (wide.value ? RAIL_MAX_WIDE : RAIL_MAX_NARROW))
|
||||
const hasOverflow = computed(
|
||||
() => serverMore.value > 0 || afterHero.value.length > RAIL_MAX,
|
||||
() => serverMore.value > 0 || afterHero.value.length > railMax.value,
|
||||
)
|
||||
const rail = computed(() =>
|
||||
hasOverflow.value
|
||||
? afterHero.value.slice(0, RAIL_MAX - 1)
|
||||
: afterHero.value.slice(0, RAIL_MAX),
|
||||
? afterHero.value.slice(0, railMax.value - 1)
|
||||
: afterHero.value.slice(0, railMax.value),
|
||||
)
|
||||
const visibleCount = computed(() => (images.value.length ? 1 + rail.value.length : 0))
|
||||
const moreCount = computed(() => {
|
||||
@@ -349,8 +365,17 @@ function measureOverflow () {
|
||||
}
|
||||
|
||||
let ro = null
|
||||
let cardRo = null
|
||||
onMounted(() => {
|
||||
nextTick(measureOverflow)
|
||||
const root = cardEl.value?.$el
|
||||
if (typeof ResizeObserver !== 'undefined' && root) {
|
||||
cardRo = new ResizeObserver((entries) => {
|
||||
const w = entries[0]?.contentRect?.width ?? 0
|
||||
wide.value = w >= WIDE_CARD_PX
|
||||
})
|
||||
cardRo.observe(root)
|
||||
}
|
||||
// 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) {
|
||||
@@ -358,7 +383,10 @@ onMounted(() => {
|
||||
ro.observe(descEl.value)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } })
|
||||
onBeforeUnmount(() => {
|
||||
if (ro) { ro.disconnect(); ro = null }
|
||||
if (cardRo) { cardRo.disconnect(); cardRo = null }
|
||||
})
|
||||
|
||||
async function toggleDesc () {
|
||||
if (!descExpanded.value) {
|
||||
@@ -495,6 +523,40 @@ function formatBytes (n) {
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
|
||||
/* --- Filmstrip layout (wide cards, #407 A) ---------------------------------
|
||||
The hero has a HEIGHT, not a width: a wide card must not turn into a
|
||||
full-screen post, so its height stays roughly a third of the viewport
|
||||
whatever the window's width. The extra images sit beside it as square cells
|
||||
whose size derives from that same height, so the grid always ends flush with
|
||||
the hero's bottom edge. */
|
||||
.fc-post-card--wide {
|
||||
--fc-hero-h: clamp(260px, 34vh, 460px);
|
||||
--fc-grid-gap: 8px;
|
||||
--fc-cell: calc((var(--fc-hero-h) - var(--fc-grid-gap)) / 2);
|
||||
}
|
||||
.fc-post-card--wide .fc-post-card__body { flex-direction: row; gap: 24px; }
|
||||
.fc-post-card--wide .fc-post-card__media {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: var(--fc-grid-gap);
|
||||
}
|
||||
.fc-post-card--wide .fc-post-card__hero {
|
||||
width: auto;
|
||||
height: var(--fc-hero-h);
|
||||
}
|
||||
.fc-post-card--wide .fc-post-card__rail {
|
||||
margin-top: 0;
|
||||
gap: var(--fc-grid-gap);
|
||||
grid-template-columns: repeat(var(--fc-grid-cols, 2), var(--fc-cell));
|
||||
grid-auto-rows: var(--fc-cell);
|
||||
}
|
||||
.fc-post-card--wide .fc-post-card__rail-cell,
|
||||
.fc-post-card--wide .fc-post-card__rail-more { height: 100%; }
|
||||
.fc-post-card--wide .fc-post-card__text { flex: 1 1 0; min-width: 0; }
|
||||
/* Text is secondary here — long reads happen in the expanded view — so the
|
||||
clamp keeps the text column no taller than the images beside it. */
|
||||
.fc-post-card--wide .fc-post-card__desc--clamped { -webkit-line-clamp: 4; }
|
||||
|
||||
.fc-post-card__title {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 18px; font-weight: 700;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<v-container class="pt-2 pb-6" max-width="900">
|
||||
<!-- Width is set in CSS, not with `max-width` here: below 1600px it is
|
||||
today's 900px column, and above it the feed widens and gains the rail
|
||||
and day gutter (milestone #407). -->
|
||||
<v-container fluid class="pt-2 pb-6 fc-posts">
|
||||
<!-- In-context view: deep-linked to one post, with bidirectional infinite
|
||||
scroll — newer posts load above, older posts below. -->
|
||||
<template v-if="postIdFilter != null">
|
||||
@@ -48,15 +51,19 @@
|
||||
</template>
|
||||
|
||||
<!-- Normal feed -->
|
||||
<template v-else>
|
||||
<FeedStatusRibbon v-if="statusRibbon" />
|
||||
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
@update:filters="onFilters"
|
||||
/>
|
||||
<div v-else class="fc-posts__layout">
|
||||
<!-- On a wide window this is a sticky left rail (#407 E); below the
|
||||
breakpoint it lays out exactly as the old inline header did. -->
|
||||
<aside class="fc-posts__rail">
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
@update:filters="onFilters"
|
||||
/>
|
||||
<FeedStatusRibbon v-if="statusRibbon" />
|
||||
</aside>
|
||||
|
||||
<div class="fc-posts__main">
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mb-3">
|
||||
{{ String(store.error) }}
|
||||
</v-alert>
|
||||
@@ -74,14 +81,28 @@
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<PostCard v-for="p in store.items" :key="p.id" :post="p" />
|
||||
<!-- Day groups (#407 D). The heading sits above its posts on a narrow
|
||||
window and in a sticky left gutter on a wide one. -->
|
||||
<section v-for="d in days" :key="d.key" class="fc-posts__day">
|
||||
<header class="fc-posts__day-head">
|
||||
<span class="fc-posts__day-label">{{ d.label }}</span>
|
||||
<span class="fc-posts__day-count">
|
||||
{{ d.posts.length }} post{{ d.posts.length === 1 ? '' : 's' }}
|
||||
· {{ d.artistCount }} artist{{ d.artistCount === 1 ? '' : 's' }}
|
||||
</span>
|
||||
</header>
|
||||
<div class="fc-posts__day-posts">
|
||||
<PostCard v-for="p in d.posts" :key="p.id" :post="p" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div ref="sentinel" class="fc-posts__sentinel">
|
||||
<v-progress-circular v-if="store.loading" indeterminate color="accent" size="24" />
|
||||
<span v-else-if="store.done" class="fc-posts__end">End of stream</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -121,6 +142,48 @@ const hasActiveFilter = computed(() =>
|
||||
artistFilter.value != null || platformFilter.value != null || searchFilter.value != null
|
||||
)
|
||||
|
||||
// --- day groups (#407 D) ---
|
||||
// CONSECUTIVE runs, not a bucket per date. The feed's sort key includes
|
||||
// `resurfaced_at` (a Discord grouping that grew moves back to the top), which
|
||||
// the payload does not carry, so a resurfaced post can sit above newer ones.
|
||||
// Bucketing by date would pull it out of order; a run gives it its own heading
|
||||
// where it actually appears. Counts cover what has LOADED, and grow as the
|
||||
// infinite scroll fetches more of the same day.
|
||||
function dayKey (iso) {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
|
||||
}
|
||||
function dayLabel (iso) {
|
||||
const d = new Date(iso)
|
||||
const today = new Date()
|
||||
const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime()
|
||||
const days = Math.round((startOf(today) - startOf(d)) / 86400000)
|
||||
if (days === 0) return 'Today'
|
||||
if (days === 1) return 'Yesterday'
|
||||
if (days > 1 && days < 7) return d.toLocaleDateString(undefined, { weekday: 'long' })
|
||||
const sameYear = d.getFullYear() === today.getFullYear()
|
||||
return d.toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }),
|
||||
})
|
||||
}
|
||||
const days = computed(() => {
|
||||
const groups = []
|
||||
for (const p of store.items) {
|
||||
const iso = p.post_date || p.downloaded_at
|
||||
const key = dayKey(iso)
|
||||
let g = groups[groups.length - 1]
|
||||
if (!g || g.dayKey !== key) {
|
||||
// Suffix with the run index so a day that appears twice (see above)
|
||||
// still has a unique v-for key.
|
||||
g = { key: `${key}#${groups.length}`, dayKey: key, label: dayLabel(iso), posts: [], artists: new Set() }
|
||||
groups.push(g)
|
||||
}
|
||||
g.posts.push(p)
|
||||
if (p.artist?.id != null) g.artists.add(p.artist.id)
|
||||
}
|
||||
return groups.map((g) => ({ ...g, artistCount: g.artists.size }))
|
||||
})
|
||||
|
||||
// Drop only `post_id` and stay where we are — keeps Browse's `tab=posts` (and
|
||||
// any active artist/platform scope) intact instead of resetting the surface.
|
||||
const allPostsTarget = computed(() => {
|
||||
@@ -232,6 +295,80 @@ onUnmounted(() => { teardownFeed(); teardownAround() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Below the breakpoint: today's layout exactly — a 900px column with the
|
||||
filters and ribbon inline above the feed. */
|
||||
.fc-posts { max-width: 900px; }
|
||||
.fc-posts__day-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
padding: 4px 0 8px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-posts__day-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-posts__day-count { font-size: 0.78rem; }
|
||||
|
||||
/* Wide window (#407 D + E). The rail holds filters and status; each day's
|
||||
heading moves into a sticky gutter beside its posts; the column widens and
|
||||
the cards switch to their filmstrip layout on their own (PostCard measures
|
||||
itself). 1600px is where a 280px rail and a 150px gutter still leave a card
|
||||
wide enough to be worth the change. */
|
||||
@media (min-width: 1600px) {
|
||||
.fc-posts { max-width: 2360px; }
|
||||
.fc-posts__layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
.fc-posts__rail {
|
||||
position: sticky;
|
||||
top: calc(var(--fc-nav-h, 64px) + 16px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-posts-filters) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-posts-filters__artist),
|
||||
.fc-posts__rail :deep(.fc-posts-filters__platform) {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
.fc-posts__rail :deep(.fc-ribbon) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.fc-posts__main { max-width: 1900px; }
|
||||
.fc-posts__day {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
gap: 0 24px;
|
||||
}
|
||||
.fc-posts__day-head {
|
||||
position: sticky;
|
||||
top: calc(var(--fc-nav-h, 64px) + 16px);
|
||||
align-self: start;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
padding-top: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
.fc-posts__day-label { font-size: 1.1rem; }
|
||||
}
|
||||
|
||||
.fc-posts__loading,
|
||||
.fc-posts__empty {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user