From cd6174cf4c77fb296908256ce986840fea0257a2 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 09:50:06 +0000 Subject: [PATCH 1/3] self-hosted: give Magazine a hero-plus-rows archive Magazine was a colour and type treatment on the shared list, so its name promised a structure it did not have. The newest entry now renders as a hero (full-width image, display headline, roomier excerpt) with the rest as ordinary rows. The feed frame is extracted so this costs no duplication: fetching, paging and the four failure states move to ArchiveFrame, and both the default archive and Magazine's supply only the layout. Copying that logic was the alternative, and it is exactly the copy that drifts and then loses a reader's place in the feed on a failed page. --- .../blog/components/archive-frame.tsx | 132 +++++++++++++++++ .../blog/components/blog-posts-list.tsx | 115 ++------------- .../features/shared/failure-states.test.ts | 6 +- .../src/themes/magazine/magazine-archive.tsx | 133 ++++++++++++++++++ apps/self-hosted/src/themes/registry.test.ts | 15 +- apps/self-hosted/src/themes/registry.ts | 12 +- 6 files changed, 306 insertions(+), 107 deletions(-) create mode 100644 apps/self-hosted/src/features/blog/components/archive-frame.tsx create mode 100644 apps/self-hosted/src/themes/magazine/magazine-archive.tsx diff --git a/apps/self-hosted/src/features/blog/components/archive-frame.tsx b/apps/self-hosted/src/features/blog/components/archive-frame.tsx new file mode 100644 index 0000000000..2576d9f601 --- /dev/null +++ b/apps/self-hosted/src/features/blog/components/archive-frame.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { type ReactNode, useEffect, useRef } from 'react'; +import type { Entry } from '@ecency/sdk'; +import { t } from '@/core'; +import { DetectBottom } from './detect-bottom'; +import { useArchiveFeed } from '../hooks/use-archive-feed'; +import { chooseFeedRetry } from '../utils/feed-retry'; +import { ErrorMessage } from '@/features/shared/error-message'; +import { InlineError } from '@/features/shared/inline-error'; +import { + nothingToShow, + resolveQueryOutcome, +} from '@/features/shared/query-outcome'; + +interface RenderArgs { + posts: Entry[]; + /** + * Index of a post WITHIN the page it arrived in, which is what the cards + * use to stagger their entrance. Whole-list index would restart the + * animation for everything already on screen each time a page lands. + */ + batchIndexOf: (index: number) => number; +} + +interface Props { + filter?: string; + limit?: number; + children: (args: RenderArgs) => ReactNode; +} + +/** + * Everything an archive needs that is not layout: the feed query, paging on + * scroll, and the four states a feed can be in (nothing yet, nothing at all, + * failed with nothing to show, failed with pages already read). + * + * Extracted so a theme can lay entries out differently without owning any of + * it. Magazine renders the newest entry as a hero and the rest as rows; + * without this frame that theme would have had to copy the retry and + * outcome handling, which is exactly the kind of copy that drifts and then + * loses somebody's place in the feed on a failed page. + * + * The `.blog-posts-list` wrapper stays here: components.css styles it and + * Gallery's grid targets it, so every archive keeps the same hook. + */ +export function ArchiveFrame({ filter = 'posts', limit = 20, children }: Props) { + const { + data = [], + fetchNextPage, + isFetching, + hasNextPage, + isEnabled, + isError, + isFetchNextPageError, + isRefetchError, + isSuccess, + refetch, + } = useArchiveFeed(filter, limit); + + // Was: `if (isError) return ` above the map. query-core keeps + // `data` through an error, so that discarded every page already rendered and + // the reader's place in them because one later page failed. + const outcome = resolveQueryOutcome({ + isEnabled, + isError, + isSuccess, + hasContent: data.length > 0, + }); + + const previousLengthRef = useRef(0); + + useEffect(() => { + // If data length decreased (e.g., filter changed), reset the ref + if (data.length < previousLengthRef.current) { + previousLengthRef.current = 0; + } else { + previousLengthRef.current = data.length; + } + }, [data.length]); + + const batchIndexOf = (index: number) => + index >= previousLengthRef.current ? index - previousLengthRef.current : 0; + + // Nothing loaded and the request failed: there is no content to protect, so + // the full panel is still right. It is the only branch that may take over. + if (outcome === 'failed') { + return refetch()} />; + } + + return ( +
+ {nothingToShow(outcome) && !isFetching && ( +
{t('noPosts')}
+ )} + + {children({ posts: data, batchIndexOf })} + + {/* Unmounted while the last page is failing. The reader is sitting at the + bottom of the feed, so leaving it mounted would refire the same fetch + the moment the retries stop, in a loop. The strip below carries the + retry instead, as a deliberate one. */} + {hasNextPage && outcome !== 'stale' && ( + fetchNextPage()} /> + )} + + {isFetching && ( +
+ {t('loadingMore')} +
+ )} + + {/* Asked, no answer, not fetching: a fetch paused while offline looks + exactly like this. It is not evidence that the author has no posts. */} + {outcome === 'pending' && !isFetching && ( +
{t('loading')}
+ )} + + {outcome === 'stale' && !isFetching && ( + + chooseFeedRetry({ isFetchNextPageError, isRefetchError }) === + 'next-page' + ? fetchNextPage() + : refetch() + } + /> + )} +
+ ); +} diff --git a/apps/self-hosted/src/features/blog/components/blog-posts-list.tsx b/apps/self-hosted/src/features/blog/components/blog-posts-list.tsx index 3a429a6702..0ef1e23dbd 100644 --- a/apps/self-hosted/src/features/blog/components/blog-posts-list.tsx +++ b/apps/self-hosted/src/features/blog/components/blog-posts-list.tsx @@ -1,120 +1,33 @@ 'use client'; -import { useEffect, useRef } from 'react'; -import { t } from '@/core'; import { useThemeComponents } from '@/themes/use-theme-components'; -import { DetectBottom } from './detect-bottom'; -import { useArchiveFeed } from '../hooks/use-archive-feed'; -import { chooseFeedRetry } from '../utils/feed-retry'; -import { ErrorMessage } from '@/features/shared/error-message'; -import { InlineError } from '@/features/shared/inline-error'; -import { - nothingToShow, - resolveQueryOutcome, -} from '@/features/shared/query-outcome'; +import { ArchiveFrame } from './archive-frame'; interface Props { filter?: string; limit?: number; } +/** + * The default archive: every entry as a card, in order. The frame owns + * fetching, paging and the failure states; this file is only the layout, and + * the card resolves through the theme registry so a theme can restyle every + * entry without owning either. + */ export function BlogPostsList({ filter = 'posts', limit = 20 }: Props) { - // The card resolves through the theme registry: a theme can restyle every - // entry without owning the whole feed (fetching, paging, error states). const { PostCard } = useThemeComponents(); - // Fetching lives in the shared hook, so a theme's own archive surface (the - // Reader rail) pages through exactly the same queries as this seam default. - const { - data = [], - fetchNextPage, - isFetching, - hasNextPage, - isEnabled, - isError, - isFetchNextPageError, - isRefetchError, - isSuccess, - refetch, - } = useArchiveFeed(filter, limit); - - // Was: `if (isError) return ` above the map. query-core keeps - // `data` through an error, so that discarded every page already rendered and - // the reader's place in them because one later page failed. - const outcome = resolveQueryOutcome({ - isEnabled, - isError, - isSuccess, - hasContent: data.length > 0, - }); - - const previousLengthRef = useRef(0); - - useEffect(() => { - // If data length decreased (e.g., filter changed), reset the ref - if (data.length < previousLengthRef.current) { - previousLengthRef.current = 0; - } else { - previousLengthRef.current = data.length; - } - }, [data.length]); - - // Nothing loaded and the request failed: there is no content to protect, so - // the full panel is still right. It is the only branch that may take over. - if (outcome === 'failed') { - return refetch()} />; - } - return ( -
- {nothingToShow(outcome) && !isFetching && ( -
{t('noPosts')}
- )} - - {data.map((post, index) => { - const isNewItem = index >= previousLengthRef.current; - const batchIndex = isNewItem ? index - previousLengthRef.current : 0; - return ( + + {({ posts, batchIndexOf }) => + posts.map((post, index) => ( - ); - })} - - {/* Unmounted while the last page is failing. The reader is sitting at the - bottom of the feed, so leaving it mounted would refire the same fetch - the moment the retries stop, in a loop. The strip below carries the - retry instead, as a deliberate one. */} - {hasNextPage && outcome !== 'stale' && ( - fetchNextPage()} /> - )} - - {isFetching && ( -
- {t('loadingMore')} -
- )} - - {/* Asked, no answer, not fetching: a fetch paused while offline looks - exactly like this. It is not evidence that the author has no posts. */} - {outcome === 'pending' && !isFetching && ( -
{t('loading')}
- )} - - {outcome === 'stale' && !isFetching && ( - - chooseFeedRetry({ isFetchNextPageError, isRefetchError }) === - 'next-page' - ? fetchNextPage() - : refetch() - } - /> - )} -
+ )) + } + ); } diff --git a/apps/self-hosted/src/features/shared/failure-states.test.ts b/apps/self-hosted/src/features/shared/failure-states.test.ts index 5c256ce7d5..bbc9a7fa8a 100644 --- a/apps/self-hosted/src/features/shared/failure-states.test.ts +++ b/apps/self-hosted/src/features/shared/failure-states.test.ts @@ -44,7 +44,7 @@ const EMPTINESS_CLAIMS = new Set([ * looked at rather than inheriting the first one's cover. */ const GUARDED_CLAIMS: Record = { - 'src/features/blog/components/blog-posts-list.tsx:noPosts': 1, + 'src/features/blog/components/archive-frame.tsx:noPosts': 1, 'src/themes/reader/reader-rail.tsx:noPosts': 1, 'src/features/blog/components/blog-post-page.tsx:postNotFound': 1, 'src/features/blog/components/blog-post-discussion.tsx:comments_empty': 1, @@ -73,7 +73,7 @@ const UNGUARDED_CLAIMS: Record< * the exact shape that threw away content already on screen. */ const READING_SURFACES = [ - 'src/features/blog/components/blog-posts-list.tsx', + 'src/features/blog/components/archive-frame.tsx', 'src/features/blog/components/blog-post-page.tsx', 'src/features/blog/components/blog-post-discussion.tsx', 'src/features/blog/layout/blog-sidebar.tsx', @@ -471,7 +471,7 @@ describe('keeping content is paired with a cache that was filtered', () => { }); describe('a failed page does not turn into a retry storm', () => { - const file = 'src/features/blog/components/blog-posts-list.tsx'; + const file = 'src/features/blog/components/archive-frame.tsx'; const sf = parse(join(APP, file)); it('takes the bottom sentinel down while the feed is failing', () => { diff --git a/apps/self-hosted/src/themes/magazine/magazine-archive.tsx b/apps/self-hosted/src/themes/magazine/magazine-archive.tsx new file mode 100644 index 0000000000..b2f28cc285 --- /dev/null +++ b/apps/self-hosted/src/themes/magazine/magazine-archive.tsx @@ -0,0 +1,133 @@ +import { Link } from '@tanstack/react-router'; +import type { Entry } from '@ecency/sdk'; +import { buildSrcSet, catchPostImage, postBodySummary } from '@ecency/render-helper'; +import { useMemo } from 'react'; +import { formatDate, t } from '@/core'; +import { estimateReadMinutes } from '@/features/blog/utils/read-time'; +import { useThemeComponents, useThemeShowsReadTime } from '@/themes/use-theme-components'; +import { ArchiveFrame } from '@/features/blog/components/archive-frame'; + +/** + * The Magazine archive: the newest entry as a hero, everything after it as + * ordinary rows. Magazine was a colour and type treatment sitting on the + * shared list, so the name promised a structure it did not have; this is + * that structure, and the tokens are untouched. + * + * Only the archive is overridden. Cards stay the shared default so search + * results keep their look, the same split Reader uses. + */ +export function MagazineArchive({ filter, limit }: { filter?: string; limit?: number }) { + const { PostCard } = useThemeComponents(); + + return ( + + {({ posts, batchIndexOf }) => { + if (posts.length === 0) return null; + const [lead, ...rest] = posts; + return ( + <> + + {rest.map((post, index) => ( + + ))} + + ); + }} + + ); +} + +/** + * The lead entry, set larger than the rows beneath it: full-width image, + * headline at display size, excerpt with room to breathe. + * + * A lead post with no image is NOT given an empty frame. It keeps the hero's + * type scale and drops the picture, so a text-led blog on this theme reads + * as a front page rather than a broken one. And a blog with exactly one post + * is all hero and no rows, which is correct: one post IS the front page. + */ +function MagazineHero({ entry }: { entry: Entry }) { + const entryData = entry.original_entry || entry; + + const imageUrl = useMemo( + () => catchPostImage(entryData, 1000, 560) || null, + [entryData], + ); + const summary = useMemo( + () => + entryData.json_metadata?.description || + postBodySummary(entryData.body, 280), + [entryData], + ); + + const showsReadTime = useThemeShowsReadTime(); + const readTime = useMemo( + () => (showsReadTime ? estimateReadMinutes(entryData.body) : null), + [showsReadTime, entryData.body], + ); + + const postParams = useMemo( + () => ({ author: `@${entryData.author}`, permlink: entryData.permlink }), + [entryData.author, entryData.permlink], + ); + const postSearch = { raw: undefined }; + + return ( +
+ {imageUrl && ( + + + + )} + +

+ + {entryData.community && entryData.community_title && ( + · {entryData.community_title} + )} + {readTime !== null && ( + + {' '} + · {readTime} {t('minRead')} + + )} +

+ +

+ + {entryData.title} + +

+ + {summary && ( +

{summary}

+ )} +
+ ); +} diff --git a/apps/self-hosted/src/themes/registry.test.ts b/apps/self-hosted/src/themes/registry.test.ts index dab5a9cb92..c4569eb7d0 100644 --- a/apps/self-hosted/src/themes/registry.test.ts +++ b/apps/self-hosted/src/themes/registry.test.ts @@ -19,7 +19,7 @@ describe('theme manifest registry', () => { // The no-op migration proof for the pre-manifest templates: their rendered // tree is exactly the shared defaults. Journal is the first structural // theme and is asserted separately below. - const cssOnly = ['medium', 'minimal', 'magazine', 'developer', 'modern-gradient']; + const cssOnly = ['medium', 'minimal', 'developer', 'modern-gradient']; for (const manifest of allThemeManifests()) { if (cssOnly.includes(manifest.id)) { expect(manifest.components, `${manifest.id} must not override components`).toBeUndefined(); @@ -67,7 +67,7 @@ const { DEFAULT_THEME_COMPONENTS, resolveThemeComponents } = await import( describe('component resolution', () => { it('every CSS-only template resolves to exactly the shared defaults', () => { - const layoutThemes = new Set(['journal', 'reader']); + const layoutThemes = new Set(['journal', 'reader', 'magazine']); for (const id of STYLE_TEMPLATES.filter((t) => !layoutThemes.has(t))) { const resolved = resolveThemeComponents(id); // Identity per seam, not just deep equality: the no-op migration means @@ -93,6 +93,17 @@ describe('component resolution', () => { expect(resolved.ArchiveList).toBe(DEFAULT_THEME_COMPONENTS.ArchiveList); }); + it('magazine owns its archive and nothing else', () => { + const magazine = getThemeManifest('magazine'); + const resolved = resolveThemeComponents('magazine'); + expect(resolved.ArchiveList).toBe(magazine.components?.ArchiveList); + // The card stays shared on purpose: search results render through the + // PostCard seam, and a hero has no meaning in a list of search hits. + expect(resolved.PostCard).toBe(DEFAULT_THEME_COMPONENTS.PostCard); + expect(resolved.Shell).toBe(DEFAULT_THEME_COMPONENTS.Shell); + expect(resolved.Sidebar).toBe(DEFAULT_THEME_COMPONENTS.Sidebar); + }); + it('reader resolves its own shell and archive pane, defaults for the rest', () => { const reader = getThemeManifest('reader'); const resolved = resolveThemeComponents('reader'); diff --git a/apps/self-hosted/src/themes/registry.ts b/apps/self-hosted/src/themes/registry.ts index a29e418568..16e65c8360 100644 --- a/apps/self-hosted/src/themes/registry.ts +++ b/apps/self-hosted/src/themes/registry.ts @@ -6,6 +6,7 @@ import { import { JournalPostCard } from './journal/journal-post-card'; import { JournalShell } from './journal/journal-shell'; import { ReaderHome } from './reader/reader-home'; +import { MagazineArchive } from './magazine/magazine-archive'; import { ReaderShell } from './reader/reader-shell'; import type { ThemeManifest, ThemeOptionKey } from './manifest'; @@ -20,7 +21,16 @@ import type { ThemeManifest, ThemeOptionKey } from './manifest'; const MANIFESTS: Record = { medium: { id: 'medium', tier: 'free', showsReadTime: true }, minimal: { id: 'minimal', tier: 'free' }, - magazine: { id: 'magazine', tier: 'free', showsReadTime: true }, + // Magazine was tokens on the shared list, so its name promised a structure + // it did not have. It now owns the archive: newest entry as a hero, the + // rest as rows. Cards stay the shared default, so search results keep + // their look; the same split Reader uses. + magazine: { + id: 'magazine', + tier: 'free', + showsReadTime: true, + components: { ArchiveList: MagazineArchive }, + }, developer: { id: 'developer', tier: 'free' }, 'modern-gradient': { id: 'modern-gradient', tier: 'free' }, // The first layout-level design: its own shell (single column, author From 99bb3a9cdb929178ac4f06b7e6f0cc09077f4159 Mon Sep 17 00:00:00 2001 From: feruzm Date: Thu, 13 Aug 2026 10:25:11 +0000 Subject: [PATCH 2/3] self-hosted: span the Magazine hero across the grid, and let grid tracks shrink In the grid feed the hero was an ordinary cell: one column wide with the second post beside it, which is the opposite of a hero. It now spans every column. That exposed an older bug underneath. A bare 1fr is minmax(auto, 1fr), so a track cannot shrink below its content's min-content width; the post cards are wider than a third of the reading measure, so three tracks blew out to 1179px inside a 768px column. Every template's grid feed had it. The tracks are now minmax(0, 1fr). --- apps/self-hosted/src/styles/components.css | 12 ++++++++++-- .../src/themes/magazine/magazine-archive.tsx | 9 ++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/self-hosted/src/styles/components.css b/apps/self-hosted/src/styles/components.css index 13be13edf8..87f0c9c921 100644 --- a/apps/self-hosted/src/styles/components.css +++ b/apps/self-hosted/src/styles/components.css @@ -260,15 +260,23 @@ gap: var(--theme-grid-gap); } +/* + * minmax(0, 1fr) rather than 1fr: a bare `1fr` is `minmax(auto, 1fr)`, so a + * track refuses to shrink below its content's min-content width. The post + * cards are wider than a third of the reading measure, so three tracks blew + * out to 1179px inside a 768px column and the grid overflowed to the right. + * Every template's grid feed had this; it only became obvious when an entry + * spanned the whole row. + */ @media (min-width: 768px) { [data-list-type="grid"] .blog-posts-list { - grid-template-columns: repeat(var(--theme-grid-columns-tablet), 1fr); + grid-template-columns: repeat(var(--theme-grid-columns-tablet), minmax(0, 1fr)); } } @media (min-width: 1024px) { [data-list-type="grid"] .blog-posts-list { - grid-template-columns: repeat(var(--theme-grid-columns-desktop), 1fr); + grid-template-columns: repeat(var(--theme-grid-columns-desktop), minmax(0, 1fr)); } } diff --git a/apps/self-hosted/src/themes/magazine/magazine-archive.tsx b/apps/self-hosted/src/themes/magazine/magazine-archive.tsx index b2f28cc285..a392e7c7e6 100644 --- a/apps/self-hosted/src/themes/magazine/magazine-archive.tsx +++ b/apps/self-hosted/src/themes/magazine/magazine-archive.tsx @@ -79,7 +79,14 @@ function MagazineHero({ entry }: { entry: Entry }) { const postSearch = { raw: undefined }; return ( -
+ /* + * col-span-full: Magazine still offers the grid feed, and in that mode + * the archive is a real CSS grid, so without this the hero is just + * another cell. It rendered one column wide with the second post beside + * it, which is the opposite of a hero. Inert in list mode, where the + * archive is a flex column and grid-column means nothing. + */ +
{imageUrl && ( Date: Thu, 13 Aug 2026 10:33:13 +0000 Subject: [PATCH 3/3] self-hosted: let the card metadata row wrap in narrow cards --- .../features/blog/components/blog-post-item.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/self-hosted/src/features/blog/components/blog-post-item.tsx b/apps/self-hosted/src/features/blog/components/blog-post-item.tsx index d6b5a7540c..07d7afb2cd 100644 --- a/apps/self-hosted/src/features/blog/components/blog-post-item.tsx +++ b/apps/self-hosted/src/features/blog/components/blog-post-item.tsx @@ -204,15 +204,25 @@ export function BlogPostItem({ entry }: Props) { )} -
+ {/* + Wraps rather than one rigid row. This row carries author, date, read + time, likes, comments and sometimes a payout, which needs ~386px; in + the grid feed a card can be 235px, and without wrapping the row spilled + out of the card and over its neighbour. `gap-y` keeps the wrapped lines + from touching. In the list feed there is room, so it never wraps and + nothing changes. + */} +