diff --git a/apps/self-hosted/hosting/api/src/style-template-display.ts b/apps/self-hosted/hosting/api/src/style-template-display.ts index 4e82d533ee..1b1646b75a 100644 --- a/apps/self-hosted/hosting/api/src/style-template-display.ts +++ b/apps/self-hosted/hosting/api/src/style-template-display.ts @@ -92,6 +92,17 @@ export const STYLE_TEMPLATE_DISPLAY = { }, headingStyle: 'serif', }, + reader: { + name: 'Reader', + tagline: 'Your archive beside the open post, the way a feed reader works', + colors: { + background: '#ffffff', + surface: '#f6f6f4', + accent: '#17677a', + text: '#1c1e21', + }, + headingStyle: 'sans', + }, } satisfies Record; export function templateCatalog() { diff --git a/apps/self-hosted/hosting/api/src/style-templates.ts b/apps/self-hosted/hosting/api/src/style-templates.ts index c22087a573..89162ff628 100644 --- a/apps/self-hosted/hosting/api/src/style-templates.ts +++ b/apps/self-hosted/hosting/api/src/style-templates.ts @@ -24,6 +24,7 @@ export const STYLE_TEMPLATES = Object.freeze([ 'developer', 'modern-gradient', 'journal', + 'reader', ] as const); export type StyleTemplate = (typeof STYLE_TEMPLATES)[number]; diff --git a/apps/self-hosted/src/core/i18n-strings.ts b/apps/self-hosted/src/core/i18n-strings.ts index cf069624ed..496bcccf76 100644 --- a/apps/self-hosted/src/core/i18n-strings.ts +++ b/apps/self-hosted/src/core/i18n-strings.ts @@ -259,6 +259,9 @@ export type TranslationKey = | 'panel_configuration_general_style_template_developer_option' | 'panel_configuration_general_style_template_modern_gradient_option' | 'panel_configuration_general_style_template_journal_option' + | 'panel_configuration_general_style_template_reader_option' + | 'reader_home_hint' + | 'reader_home_keys' | 'panel_configuration_general_language_label' | 'panel_configuration_general_language_description' | 'panel_configuration_general_language_en_option' @@ -593,6 +596,10 @@ export const translations: { en: Translations } & Record< panel_configuration_general_style_template_modern_gradient_option: 'Modern Gradient', panel_configuration_general_style_template_journal_option: 'Journal (single column, serif, no sidebar)', + panel_configuration_general_style_template_reader_option: + 'Reader (split view, archive rail beside the post)', + reader_home_hint: 'Pick a post from the list to start reading.', + reader_home_keys: 'Tip: j and k move between posts.', panel_configuration_general_language_label: 'Language', panel_configuration_general_language_description: 'Default language', panel_configuration_general_language_en_option: 'English', 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 a934ad1c33..3a429a6702 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,15 +1,10 @@ 'use client'; -import { - getAccountPostsInfiniteQueryOptions, - getPostsRankedInfiniteQueryOptions, -} from '@ecency/sdk'; -import { useInfiniteQuery } from '@tanstack/react-query'; -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { t } from '@/core'; import { useThemeComponents } from '@/themes/use-theme-components'; import { DetectBottom } from './detect-bottom'; -import { useInstanceConfig } from '../hooks/use-instance-config'; +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'; @@ -23,57 +18,13 @@ interface Props { limit?: number; } -// Map blog filters to community sort options -const communityFilterMap: Record = { - posts: 'created', - blog: 'created', - trending: 'trending', - hot: 'hot', - new: 'created', - payout: 'payout', - muted: 'muted', -}; - 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(); - const { username, communityId, isCommunityMode } = useInstanceConfig(); - - // Use different query based on instance type - const communitySort = communityFilterMap[filter] || 'created'; - - // Memoize select function to avoid creating new reference on each render - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const selectPosts = useCallback( - (data: { pages: any[][] }) => data.pages.flat(), - [] - ); - - // Get query options and preserve their built-in enabled guards - const accountOptions = getAccountPostsInfiniteQueryOptions(username, filter, limit); - // The SDK's ranked-posts query, not a local bridge call: it is the path that - // applies DMCA post filtering and drops a tag that is itself listed. A - // bespoke get_ranked_posts call here served takedown-listed content. - const communityOptions = getPostsRankedInfiniteQueryOptions( - communitySort, - communityId, - limit, - '', - !!communityId && isCommunityMode, - ); - - const blogQuery = useInfiniteQuery({ - ...accountOptions, - select: selectPosts, - enabled: accountOptions.enabled && !isCommunityMode, - }); - - const communityQuery = useInfiniteQuery({ - ...communityOptions, - select: selectPosts, - }); + // 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, @@ -85,7 +36,7 @@ export function BlogPostsList({ filter = 'posts', limit = 20 }: Props) { isRefetchError, isSuccess, refetch, - } = isCommunityMode ? communityQuery : blogQuery; + } = 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 diff --git a/apps/self-hosted/src/features/blog/hooks/use-archive-feed.ts b/apps/self-hosted/src/features/blog/hooks/use-archive-feed.ts new file mode 100644 index 0000000000..feba941abd --- /dev/null +++ b/apps/self-hosted/src/features/blog/hooks/use-archive-feed.ts @@ -0,0 +1,67 @@ +'use client'; + +import { + getAccountPostsInfiniteQueryOptions, + getPostsRankedInfiniteQueryOptions, + type Entry, +} from '@ecency/sdk'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { useInstanceConfig } from './use-instance-config'; + +// Map blog filters to community sort options +const communityFilterMap: Record = { + posts: 'created', + blog: 'created', + trending: 'trending', + hot: 'hot', + new: 'created', + payout: 'payout', + muted: 'muted', +}; + +/** + * The archive feed as one infinite query, flattened. Extracted from + * BlogPostsList so a theme's own archive surface (the Reader rail) cannot + * drift from the seam default's fetching: same queries, same enabled guards, + * same paging. + * + * Community instances go through the SDK's ranked-posts query, not a local + * bridge call: it is the path that applies DMCA post filtering and drops a + * tag that is itself listed. A bespoke get_ranked_posts call here served + * takedown-listed content. + */ +export function useArchiveFeed(filter = 'posts', limit = 20) { + const { username, communityId, isCommunityMode } = useInstanceConfig(); + + const communitySort = communityFilterMap[filter] || 'created'; + + // Memoize select function to avoid creating new reference on each render + const selectPosts = useCallback( + (data: { pages: Entry[][] }) => data.pages.flat(), + [] + ); + + // Get query options and preserve their built-in enabled guards + const accountOptions = getAccountPostsInfiniteQueryOptions(username, filter, limit); + const communityOptions = getPostsRankedInfiniteQueryOptions( + communitySort, + communityId, + limit, + '', + !!communityId && isCommunityMode, + ); + + const blogQuery = useInfiniteQuery({ + ...accountOptions, + select: selectPosts, + enabled: accountOptions.enabled && !isCommunityMode, + }); + + const communityQuery = useInfiniteQuery({ + ...communityOptions, + select: selectPosts, + }); + + return isCommunityMode ? communityQuery : blogQuery; +} diff --git a/apps/self-hosted/src/features/floating-menu/config-fields.ts b/apps/self-hosted/src/features/floating-menu/config-fields.ts index abb1ae2832..c3d054d7a6 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.ts @@ -46,6 +46,7 @@ const STYLE_TEMPLATE_LABEL_KEYS = { 'modern-gradient': 'panel_configuration_general_style_template_modern_gradient_option', journal: 'panel_configuration_general_style_template_journal_option', + reader: 'panel_configuration_general_style_template_reader_option', } satisfies Record; export type ConfigFieldType = 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 7f381aa3d5..5c256ce7d5 100644 --- a/apps/self-hosted/src/features/shared/failure-states.test.ts +++ b/apps/self-hosted/src/features/shared/failure-states.test.ts @@ -45,6 +45,7 @@ const EMPTINESS_CLAIMS = new Set([ */ const GUARDED_CLAIMS: Record = { 'src/features/blog/components/blog-posts-list.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, 'src/features/blog/layout/blog-sidebar.tsx:community_not_found': 1, diff --git a/apps/self-hosted/src/routes/$author.$permlink.tsx b/apps/self-hosted/src/routes/$author.$permlink.tsx index 1a93744f7d..93401eb8ae 100644 --- a/apps/self-hosted/src/routes/$author.$permlink.tsx +++ b/apps/self-hosted/src/routes/$author.$permlink.tsx @@ -1,11 +1,23 @@ import { createFileRoute } from '@tanstack/react-router'; import { BlogPostPage } from '@/features/blog/components/blog-post-page'; +import { resolvePostsFilter } from '@/features/blog/utils/post-filters'; export const Route = createFileRoute('/$author/$permlink')({ component: BlogPostPage, - validateSearch: (search: Record) => { + // Optional keys in the annotation, so post links that carry no search at + // all keep typechecking; the inferred shape would demand every key. + validateSearch: ( + search: Record, + ): { raw?: true; filter?: string } => { return { raw: search.raw !== undefined ? true : undefined, + // Retained (clamped) so a theme whose archive stays visible beside the + // open post (Reader) keeps showing the feed the reader was browsing. + // Absent on canonical deep links, so ordinary post URLs are unchanged. + filter: + search.filter !== undefined + ? resolvePostsFilter(search.filter) + : undefined, }; }, }); diff --git a/apps/self-hosted/src/routes/$category.$author.$permlink.tsx b/apps/self-hosted/src/routes/$category.$author.$permlink.tsx index a577e97528..93e9cec4b8 100644 --- a/apps/self-hosted/src/routes/$category.$author.$permlink.tsx +++ b/apps/self-hosted/src/routes/$category.$author.$permlink.tsx @@ -1,11 +1,20 @@ import { createFileRoute } from '@tanstack/react-router'; import { BlogPostPage } from '@/features/blog/components/blog-post-page'; +import { resolvePostsFilter } from '@/features/blog/utils/post-filters'; export const Route = createFileRoute('/$category/$author/$permlink')({ component: BlogPostPage, - validateSearch: (search: Record) => { + validateSearch: ( + search: Record, + ): { raw?: true; filter?: string } => { return { raw: search.raw !== undefined ? true : undefined, + // Same retention as /$author/$permlink: the Reader rail reads it to + // stay on the feed the reader was browsing. + filter: + search.filter !== undefined + ? resolvePostsFilter(search.filter) + : undefined, }; }, }); diff --git a/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts b/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts index e1ff1c034b..cdf4b073a1 100644 --- a/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts +++ b/apps/self-hosted/src/styles/theme-appearance-tokens.test.ts @@ -174,10 +174,10 @@ const accentBlocks = ALL_BLOCKS.filter((block) => describe('accent blocks', () => { it('are every light and dark palette the templates declare', () => { - // Six templates in two modes, plus the two base blocks in variables.css. + // Seven templates in two modes, plus the two base blocks in variables.css. // A parser that stopped seeing them would make every check below vacuous. - expect(accentBlocks.length).toBe(14); - expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(7); + expect(accentBlocks.length).toBe(16); + expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(8); }); it('declare the chip text next to the accent that fills the chip', () => { @@ -326,7 +326,7 @@ describe('card treatment', () => { ); it('is stated by every template rather than inherited by accident', () => { - expect(templates.length).toBe(6); + expect(templates.length).toBe(7); const missing: string[] = []; for (const block of templates) { for (const token of CARD_TOKENS) { diff --git a/apps/self-hosted/src/styles/themes/index.css b/apps/self-hosted/src/styles/themes/index.css index 3b9b9f7158..4d8ada5037 100644 --- a/apps/self-hosted/src/styles/themes/index.css +++ b/apps/self-hosted/src/styles/themes/index.css @@ -10,3 +10,4 @@ @import "./developer.css"; @import "./modern-gradient.css"; @import "./journal.css"; +@import "./reader.css"; diff --git a/apps/self-hosted/src/styles/themes/reader.css b/apps/self-hosted/src/styles/themes/reader.css new file mode 100644 index 0000000000..e90b0fddd2 --- /dev/null +++ b/apps/self-hosted/src/styles/themes/reader.css @@ -0,0 +1,119 @@ +/* Reader Theme - Your archive beside the open post + * + * The second layout-level design: a split frame with the post archive as a + * persistent rail beside whatever is open, the way a feed reader lays out. + * Sans UI around a serif reading surface, cool porcelain neutrals, a deep + * teal accent. The structural half lives in src/themes/reader/ (Shell and + * ArchiveList manifest overrides); these tokens carry the full --theme-* + * contract so the accent correction sweep and every token utility keep + * working. + */ + +[data-style-template="reader"] { + /* Typography: sans chrome, serif articles */ + --theme-font-body: "Source Serif 4", "Georgia", "Times New Roman", serif; + --theme-font-heading: + -apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial", + sans-serif; + --theme-font-ui: + -apple-system, "BlinkMacSystemFont", "Segoe UI", "Helvetica Neue", "Arial", + sans-serif; + + /* Typography Sizes */ + --theme-text-base: 18px; + --theme-leading-normal: 1.6; + --theme-tracking-normal: 0; + --theme-tracking-tight: -0.01em; + + /* Colors - Light: porcelain surfaces, near-black ink, deep teal accent */ + --theme-bg-primary: #ffffff; + --theme-bg-secondary: #f6f6f4; + --theme-bg-tertiary: rgba(28, 30, 33, 0.05); + --theme-bg-card: #ffffff; + + --theme-text-primary: #1c1e21; + --theme-text-secondary: rgba(28, 30, 33, 0.75); + /* 0.62 composites to ~4.8:1 over white: rail dates and marginalia stay + * quiet but AA-readable at small sizes. */ + --theme-text-muted: rgba(28, 30, 33, 0.62); + + --theme-accent: #17677a; + --theme-accent-hover: #125363; + /* Ink on the accent fill: white on #17677a is 6.4:1. */ + --theme-accent-contrast: #ffffff; + + --theme-border: rgba(28, 30, 33, 0.12); + --theme-border-strong: rgba(28, 30, 33, 0.2); + + /* Effects: an app frame, so gentle rounding and soft elevation */ + --theme-radius-sm: 4px; + --theme-radius: 6px; + --theme-radius-lg: 10px; + --theme-radius-full: 9999px; + + --theme-shadow-sm: 0 1px 2px rgba(28, 30, 33, 0.06); + --theme-shadow: 0 2px 8px rgba(28, 30, 33, 0.08); + --theme-shadow-lg: 0 8px 24px rgba(28, 30, 33, 0.12); + + /* Link underline style, derived from the accent; see variables.css */ + --theme-link-decoration: underline; + --theme-link-decoration-color: color-mix( + in srgb, + var(--theme-accent-text, var(--theme-accent)) 40%, + transparent + ); + --theme-link-decoration-hover: var(--theme-accent-text, var(--theme-accent)); + + /* Tag style: quiet chips in the UI face */ + --theme-tag-text: var(--theme-accent-text-light, rgba(28, 30, 33, 0.65)); + --theme-tag-radius: 4px; + + /* Card treatment: flat surfaces separated by hairlines, no glass */ + --theme-card-bg: var(--theme-bg-card); + --theme-card-border: 1px solid var(--theme-border); + --theme-card-backdrop: none; + + /* Layout: the sidebar width IS the rail width in the Reader shell; the + * content width bounds the open article inside the reading pane. */ + --theme-content-width: 720px; + --theme-sidebar-width: 340px; + --theme-layout-gap: 2rem; + --theme-layout-container-padding: 1.25rem; + --theme-layout-section-gap: 2rem; + --theme-card-padding: 1rem; + --theme-grid-gap: 1.5rem; + --theme-grid-columns-tablet: 1; + --theme-grid-columns-desktop: 1; + --theme-post-card-image-height: 200px; + --theme-post-card-image-radius: 6px; +} + +/* Reader Dark Mode: graphite, never blue-black */ +[data-style-template="reader"][data-theme="dark"] { + --theme-bg-primary: #15171a; + --theme-bg-secondary: #1b1e22; + --theme-bg-tertiary: rgba(236, 239, 244, 0.07); + --theme-bg-card: #15171a; + + --theme-text-primary: rgba(236, 239, 244, 0.92); + --theme-text-secondary: rgba(236, 239, 244, 0.75); + --theme-text-muted: rgba(236, 239, 244, 0.62); + + --theme-accent: #58a6c4; + --theme-accent-hover: #74b9d3; + /* Ink on the accent fill: the same choice the correction module makes for + * this fill, so static CSS and runtime preview agree. 5.7:1. */ + --theme-accent-contrast: #111827; + + --theme-border: rgba(236, 239, 244, 0.12); + --theme-border-strong: rgba(236, 239, 244, 0.2); + + --theme-link-decoration-color: color-mix( + in srgb, + var(--theme-accent-text, var(--theme-accent)) 40%, + transparent + ); + --theme-link-decoration-hover: var(--theme-accent-text, var(--theme-accent)); + + --theme-tag-text: var(--theme-accent-text-dark, rgba(236, 239, 244, 0.65)); +} diff --git a/apps/self-hosted/src/themes/reader/reader-home.tsx b/apps/self-hosted/src/themes/reader/reader-home.tsx new file mode 100644 index 0000000000..f3d1887c0f --- /dev/null +++ b/apps/self-hosted/src/themes/reader/reader-home.tsx @@ -0,0 +1,49 @@ +import { InstanceConfigManager, t } from '@/core'; +import { + useCommunityData, + useInstanceConfig, +} from '@/features/blog/hooks/use-instance-config'; + +interface Props { + filter?: string; + limit?: number; +} + +/** + * What the Reader theme renders where the feed route mounts the ArchiveList + * seam: the rail already IS the archive, so the reading pane greets instead + * of repeating it. Only ever seen at the desktop split; on small screens the + * shell gives the feed route to the rail and hides this pane entirely. The + * props are the seam's contract and deliberately unused. + */ +export function ReaderHome(_props: Props) { + const { username, isCommunityMode } = useInstanceConfig(); + const { data: community } = useCommunityData(); + + const blogTitle = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.title, + ); + const blogDescription = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.description, + ); + + const displayTitle = + isCommunityMode && community?.title ? community.title : blogTitle || username; + + return ( +
+
+

{displayTitle}

+ {blogDescription && ( +

{blogDescription}

+ )} +

+ {t('reader_home_hint')} +

+

+ {t('reader_home_keys')} +

+
+
+ ); +} diff --git a/apps/self-hosted/src/themes/reader/reader-rail.tsx b/apps/self-hosted/src/themes/reader/reader-rail.tsx new file mode 100644 index 0000000000..38b06a6406 --- /dev/null +++ b/apps/self-hosted/src/themes/reader/reader-rail.tsx @@ -0,0 +1,244 @@ +'use client'; + +import type { Entry } from '@ecency/sdk'; +import { Link, useLocation, useNavigate, useParams } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { useEffect, useRef } from 'react'; +import { formatDate, t } from '@/core'; +import { DetectBottom } from '@/features/blog/components/detect-bottom'; +import { useArchiveFeed } from '@/features/blog/hooks/use-archive-feed'; +import { usePostsFilterState } from '@/features/blog/hooks/use-posts-filter-state'; +import { chooseFeedRetry } from '@/features/blog/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'; + +/** Keystrokes typed into a field are never navigation. */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); +} + +/** + * The Reader archive rail: every entry in the feed as a compact row, the open + * one highlighted, paged by the same shared hook the seam default uses. j and + * k move to the next and previous entry without leaving the page (the arrow + * keys are left alone: they scroll the article). Deep links need nothing + * special, since the rail only marks whatever author and permlink the route + * already carries. + * + * The filter comes from the shared filter state, and every post link carries + * a non-default filter along (the post routes retain it): the rail must keep + * showing the feed the reader was browsing after a post opens, or "the + * archive never leaves the page" quietly turns into "opening a post resets + * the archive to the default feed". + */ +export function ReaderRail() { + const params = useParams({ strict: false }) as { + author?: string; + permlink?: string; + }; + // The route's author param keeps its '@'; entries do not. + const activeAuthor = (params.author ?? '').replace(/^@/, ''); + const activePermlink = params.permlink; + const navigate = useNavigate(); + + const { availableFilters, currentFilter } = usePostsFilterState(); + const defaultFilter = availableFilters[0] || 'posts'; + // Canonical post URLs stay clean: only a non-default feed travels along. + const carriedFilter = + currentFilter === defaultFilter ? undefined : currentFilter; + + const { + data = [], + fetchNextPage, + isFetching, + hasNextPage, + isEnabled, + isError, + isFetchNextPageError, + isRefetchError, + isSuccess, + refetch, + } = useArchiveFeed(currentFilter); + + const entries = data as Entry[]; + const outcome = resolveQueryOutcome({ + isEnabled, + isError, + isSuccess, + hasContent: entries.length > 0, + }); + + // The row a link opens is the EFFECTIVE entry (a reblog navigates to its + // original), so active detection and the keyboard lookup must compare that + // same identity, author included: a community feed can hold the same + // permlink from two authors, and a cross-post's wrapper permlink never + // matches the route. + const isOpen = (entry: Entry) => { + const effective = entry.original_entry || entry; + return ( + effective.permlink === activePermlink && + effective.author === activeAuthor + ); + }; + + // j/k are only navigation where the archive is part of the page: the feed + // and open posts. The rail stays mounted (CSS-hidden) on search, publish + // and edit, and a stray keypress there must never navigate away from a + // composer or a search. Post routes stay active even where the rail is + // hidden on small screens: next and previous post is exactly what those + // keys mean while reading. + const location = useLocation(); + const pathname = location.pathname.replace(/\/+$/, '') || '/'; + const keyboardActive = + pathname === '/' || + pathname === '/blog' || + (!!activePermlink && !pathname.startsWith('/edit')); + + // Refs, not deps: the key handler reads the CURRENT list, selection, route + // and filter without re-subscribing on every feed page or route change. + const entriesRef = useRef(entries); + entriesRef.current = entries; + const isOpenRef = useRef(isOpen); + isOpenRef.current = isOpen; + const carriedFilterRef = useRef(carriedFilter); + carriedFilterRef.current = carriedFilter; + const keyboardActiveRef = useRef(keyboardActive); + keyboardActiveRef.current = keyboardActive; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!keyboardActiveRef.current) return; + if (event.defaultPrevented) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isTypingTarget(event.target)) return; + const forward = event.key === 'j'; + const back = event.key === 'k'; + if (!forward && !back) return; + + const list = entriesRef.current; + if (!list.length) return; + const current = list.findIndex((entry) => isOpenRef.current(entry)); + // Nothing open yet: either key starts at the top. + const next = + current === -1 + ? 0 + : Math.min(Math.max(current + (forward ? 1 : -1), 0), list.length - 1); + if (next === current) return; + + const target = list[next].original_entry || list[next]; + event.preventDefault(); + navigate({ + to: '/$author/$permlink', + params: { author: `@${target.author}`, permlink: target.permlink }, + search: { raw: undefined, filter: carriedFilterRef.current }, + }); + }; + + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [navigate]); + + // Keyboard moves must keep the selection in sight; 'nearest' leaves mouse + // scrolling alone when the entry is already visible. + const activeItemRef = useRef(null); + useEffect(() => { + activeItemRef.current?.scrollIntoView({ block: 'nearest' }); + }, [activeAuthor, activePermlink]); + + if (outcome === 'failed') { + return ( +
+ refetch()} /> +
+ ); + } + + return ( +
+ {nothingToShow(outcome) && !isFetching && ( +
+ {t('noPosts')} +
+ )} + + {entries.map((entry) => { + const entryData = entry.original_entry || entry; + const active = isOpen(entry); + return ( + +

+ + {entryData.community && entryData.community_title && ( + ยท {entryData.community_title} + )} +

+

+ {entryData.title} +

+ + ); + })} + + {/* Unmounted while the last page is failing, same reasoning as the seam + default: leaving it mounted would refire the failed fetch in a loop. */} + {hasNextPage && outcome !== 'stale' && ( + fetchNextPage()} /> + )} + + {isFetching && ( +
+ {t('loadingMore')} +
+ )} + + {outcome === 'pending' && !isFetching && ( +
+ {t('loading')} +
+ )} + + {outcome === 'stale' && !isFetching && ( + + chooseFeedRetry({ isFetchNextPageError, isRefetchError }) === + 'next-page' + ? fetchNextPage() + : refetch() + } + /> + )} +
+ ); +} diff --git a/apps/self-hosted/src/themes/reader/reader-shell.tsx b/apps/self-hosted/src/themes/reader/reader-shell.tsx new file mode 100644 index 0000000000..d7f52da385 --- /dev/null +++ b/apps/self-hosted/src/themes/reader/reader-shell.tsx @@ -0,0 +1,125 @@ +import type { PropsWithChildren } from 'react'; +import { Link, useLocation } from '@tanstack/react-router'; +import clsx from 'clsx'; +import { InstanceConfigManager } from '@/core'; +import { CreatePostButton, UserMenu } from '@/features/auth'; +import { SearchInput } from '@/features/blog/components/search-input'; +import { + useCommunityData, + useInstanceConfig, +} from '@/features/blog/hooks/use-instance-config'; +import { usePostsFilterState } from '@/features/blog/hooks/use-posts-filter-state'; +import { ReaderRail } from './reader-rail'; + +/** + * The Reader page frame: the archive as a persistent rail beside whatever is + * open, the way a feed reader lays out. Both panes scroll independently, so + * moving between posts never loses the reader's place in the archive. On + * small screens the split collapses to one pane at a time: the feed route IS + * the rail, and every other route (post, search, publish) shows the content + * pane. The sidebar seam is not rendered at all; its config options are + * declared unsupported through the manifest, so the editor hides them under + * this theme instead of leaving them silently inert. + */ +export function ReaderShell(props: PropsWithChildren) { + const { username, isCommunityMode } = useInstanceConfig(); + const { data: community } = useCommunityData(); + const location = useLocation(); + + const blogTitle = InstanceConfigManager.useConfig( + ({ configuration }) => configuration.instanceConfiguration.meta.title, + ); + const proxyBase = InstanceConfigManager.useConfig( + ({ configuration }) => + configuration.general.imageProxy || 'https://i.ecency.com', + ); + + const displayTitle = + isCommunityMode && community?.title ? community.title : blogTitle || username; + const avatarAccount = isCommunityMode ? community?.name : username; + const avatarUrl = avatarAccount + ? `${proxyBase}/u/${avatarAccount}/avatar/small` + : null; + + // Shared with BlogNavigation, so the shell cannot drift from it. + const { availableFilters, currentFilter, filterLabel } = usePostsFilterState(); + + // The one route where the rail is the whole story on small screens. + const pathname = location.pathname.replace(/\/+$/, '') || '/'; + const isFeedRoute = pathname === '/' || pathname === '/blog'; + + return ( +
+
+ {/* flex-wrap: at narrow viewports the search and menu drop to their + own line instead of overflowing the strip. */} +
+ + {avatarUrl && ( + + )} + + {displayTitle} + + + + + + + +
+
+ +
+ +
+
+ {props.children} +
+
+
+ + {/* The floating composer entry point the default navigation mounts; a + theme shell must never cost owners and community members the way in. */} + +
+ ); +} diff --git a/apps/self-hosted/src/themes/registry.test.ts b/apps/self-hosted/src/themes/registry.test.ts index c1e89def4f..dab5a9cb92 100644 --- a/apps/self-hosted/src/themes/registry.test.ts +++ b/apps/self-hosted/src/themes/registry.test.ts @@ -37,6 +37,18 @@ describe('theme manifest registry', () => { expect(journal.unsupportedOptions).toEqual(['sidebar', 'listType']); }); + it('reader owns its shell and archive pane, declares what it does not consume', () => { + const reader = getThemeManifest('reader'); + expect(reader.components?.Shell).toBeTypeOf('function'); + // The rail owns the archive, so the feed route's ArchiveList seam becomes + // the reading-pane greeting rather than a second copy of the feed. + expect(reader.components?.ArchiveList).toBeTypeOf('function'); + // Cards stay the shared default: search results render them in the pane. + expect(reader.components?.PostCard).toBeUndefined(); + expect(reader.components?.Navigation).toBeUndefined(); + expect(reader.unsupportedOptions).toEqual(['sidebar', 'listType']); + }); + it('unknown and absent ids resolve to the default template', () => { expect(getThemeManifest(undefined).id).toBe('medium'); expect(getThemeManifest('no-such-theme').id).toBe('medium'); @@ -55,7 +67,8 @@ const { DEFAULT_THEME_COMPONENTS, resolveThemeComponents } = await import( describe('component resolution', () => { it('every CSS-only template resolves to exactly the shared defaults', () => { - for (const id of STYLE_TEMPLATES.filter((t) => t !== 'journal')) { + const layoutThemes = new Set(['journal', 'reader']); + 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 // the very same component functions render, so nothing remounts. @@ -80,6 +93,16 @@ describe('component resolution', () => { expect(resolved.ArchiveList).toBe(DEFAULT_THEME_COMPONENTS.ArchiveList); }); + it('reader resolves its own shell and archive pane, defaults for the rest', () => { + const reader = getThemeManifest('reader'); + const resolved = resolveThemeComponents('reader'); + expect(resolved.Shell).toBe(reader.components?.Shell); + expect(resolved.ArchiveList).toBe(reader.components?.ArchiveList); + expect(resolved.Navigation).toBe(DEFAULT_THEME_COMPONENTS.Navigation); + expect(resolved.Sidebar).toBe(DEFAULT_THEME_COMPONENTS.Sidebar); + expect(resolved.PostCard).toBe(DEFAULT_THEME_COMPONENTS.PostCard); + }); + it('option support reads the manifest declaration', async () => { const { isThemeOptionSupported } = await import('./registry'); expect(isThemeOptionSupported('journal', 'sidebar')).toBe(false); diff --git a/apps/self-hosted/src/themes/registry.ts b/apps/self-hosted/src/themes/registry.ts index c25aafea23..91c4f5fd32 100644 --- a/apps/self-hosted/src/themes/registry.ts +++ b/apps/self-hosted/src/themes/registry.ts @@ -5,6 +5,8 @@ import { } from '../../hosting/api/src/style-templates'; import { JournalPostCard } from './journal/journal-post-card'; import { JournalShell } from './journal/journal-shell'; +import { ReaderHome } from './reader/reader-home'; +import { ReaderShell } from './reader/reader-shell'; import type { ThemeManifest, ThemeOptionKey } from './manifest'; /** @@ -31,6 +33,16 @@ const MANIFESTS: Record = { components: { Shell: JournalShell, PostCard: JournalPostCard }, unsupportedOptions: ['sidebar', 'listType'], }, + // The second layout-level design: a split frame with the archive as a + // persistent rail beside the open post. The home pane replaces the + // ArchiveList seam (the rail owns the archive), while cards stay the shared + // default so search results keep their look inside the reading pane. + reader: { + id: 'reader', + tier: 'free', + components: { Shell: ReaderShell, ArchiveList: ReaderHome }, + unsupportedOptions: ['sidebar', 'listType'], + }, }; function isStyleTemplate(value: unknown): value is StyleTemplate {