Wide-window feed, Discord downloads named and dated correctly, and a repair for the broken ones #253

Merged
bvandeusen merged 3 commits from dev into main 2026-09-13 20:57:06 -04:00
2 changed files with 221 additions and 22 deletions
Showing only changes of commit 98c3b74260 - Show all commits
+73 -11
View File
@@ -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;
+148 -11
View File
@@ -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;