Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/self-hosted/hosting/api/src/style-template-display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<StyleTemplate, StyleTemplateDisplay>;

export function templateCatalog() {
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/hosting/api/src/style-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const STYLE_TEMPLATES = Object.freeze([
'developer',
'modern-gradient',
'journal',
'reader',
] as const);

export type StyleTemplate = (typeof STYLE_TEMPLATES)[number];
Expand Down
7 changes: 7 additions & 0 deletions apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down
59 changes: 5 additions & 54 deletions apps/self-hosted/src/features/blog/components/blog-posts-list.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,57 +18,13 @@ interface Props {
limit?: number;
}

// Map blog filters to community sort options
const communityFilterMap: Record<string, string> = {
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,
Expand All @@ -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 <ErrorMessage />` above the map. query-core keeps
// `data` through an error, so that discarded every page already rendered and
Expand Down
67 changes: 67 additions & 0 deletions apps/self-hosted/src/features/blog/hooks/use-archive-feed.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<StyleTemplate, TranslationKey>;

export type ConfigFieldType =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const EMPTINESS_CLAIMS = new Set([
*/
const GUARDED_CLAIMS: Record<string, number> = {
'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,
Expand Down
14 changes: 13 additions & 1 deletion apps/self-hosted/src/routes/$author.$permlink.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
// 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<string, unknown>,
): { 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,
};
},
});
11 changes: 10 additions & 1 deletion apps/self-hosted/src/routes/$category.$author.$permlink.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
validateSearch: (
search: Record<string, unknown>,
): { 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,
};
},
});
8 changes: 4 additions & 4 deletions apps/self-hosted/src/styles/theme-appearance-tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions apps/self-hosted/src/styles/themes/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
@import "./developer.css";
@import "./modern-gradient.css";
@import "./journal.css";
@import "./reader.css";
Loading
Loading