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-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