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 @@ -81,6 +81,17 @@ export const STYLE_TEMPLATE_DISPLAY = {
},
headingStyle: 'sans',
},
journal: {
name: 'Journal',
tagline: 'Ink on paper: one quiet column for long-form writing',
colors: {
background: '#faf8f4',
surface: '#f2efe8',
accent: '#9c4a1e',
text: '#221d17',
},
headingStyle: 'serif',
},
} 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 @@ -23,6 +23,7 @@ export const STYLE_TEMPLATES = Object.freeze([
'magazine',
'developer',
'modern-gradient',
'journal',
] as const);

export type StyleTemplate = (typeof STYLE_TEMPLATES)[number];
Expand Down
3 changes: 3 additions & 0 deletions apps/self-hosted/src/core/i18n-strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export type TranslationKey =
| 'panel_configuration_general_style_template_magazine_option'
| '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_language_label'
| 'panel_configuration_general_language_description'
| 'panel_configuration_general_language_en_option'
Expand Down Expand Up @@ -590,6 +591,8 @@ export const translations: { en: Translations } & Record<
panel_configuration_general_style_template_magazine_option: 'Magazine (Editorial)',
panel_configuration_general_style_template_developer_option: 'Developer (Tech)',
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_language_label: 'Language',
panel_configuration_general_language_description: 'Default language',
panel_configuration_general_language_en_option: 'English',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { t } from '@/core';
import { useInstanceConfig } from '../hooks/use-instance-config';
import { BlogPostItem } from './blog-post-item';
import { useThemeComponents } from '@/themes/use-theme-components';

interface Props {
query: string;
Expand Down Expand Up @@ -35,6 +35,9 @@ function searchResultToEntry(result: SearchResult): Entry {
}

export function SearchResults({ query }: Props) {
// The entry card resolves through the theme registry, so search results
// wear the active theme's entry look instead of the default card.
const { PostCard } = useThemeComponents();
const { username, communityId, isCommunityMode } = useInstanceConfig();

// Build search query scoped to the blog/community
Expand Down Expand Up @@ -92,7 +95,7 @@ export function SearchResults({ query }: Props) {
</div>
<div className="blog-posts-list">
{results.map((result, index) => (
<BlogPostItem
<PostCard
key={`${result.author}/${result.permlink}`}
entry={searchResultToEntry(result)}
index={index}
Expand Down
43 changes: 43 additions & 0 deletions apps/self-hosted/src/features/blog/hooks/use-posts-filter-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useLocation } from '@tanstack/react-router';
import { useMemo } from 'react';
import { t } from '@/core';
import { getConfiguredPostsFilters } from '../utils/post-filters';

/**
* The archive filter state every shell needs: the configured filters, which
* one is active (from the route search, defaulting to the first configured)
* and a label resolver. Extracted from BlogNavigation so a theme shell cannot
* drift from the navigation's behavior by re-implementing it.
*/
export function usePostsFilterState() {
const location = useLocation();
const availableFilters = getConfiguredPostsFilters();

const currentFilter = useMemo(() => {
const defaultFilter = availableFilters[0] || 'posts';
if (typeof location.search === 'string') {
return (
new URLSearchParams(location.search).get('filter') || defaultFilter
);
}
if (
location.search &&
typeof location.search === 'object' &&
'filter' in location.search
) {
return (location.search.filter as string) || defaultFilter;
}
return defaultFilter;
}, [location.search, availableFilters]);

// An i18n key when one exists, a capitalized filter name otherwise.
const filterLabel = (filter: string): string => {
const key = `blog.navigation.${filter}`;
const translated = t(key as Parameters<typeof t>[0]);
return translated === key
? filter.charAt(0).toUpperCase() + filter.slice(1)
: translated;
};

return { availableFilters, currentFilter, filterLabel };
}
44 changes: 7 additions & 37 deletions apps/self-hosted/src/features/blog/layout/blog-navigation.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,25 @@
'use client';

import { Link, useLocation } from '@tanstack/react-router';
import { Link } from '@tanstack/react-router';
import clsx from 'clsx';
import { useMemo } from 'react';
import { UilRss } from '@tooni/iconscout-unicons-react';
import { InstanceConfigManager, t } from '@/core';
import { getRssFeedUrl } from '@/utils/rss-feed-url';
import { UserMenu, CreatePostButton } from '@/features/auth';
import { useInstanceConfig, useCommunityData } from '../hooks/use-instance-config';
import { getConfiguredPostsFilters } from '../utils/post-filters';
import { usePostsFilterState } from '../hooks/use-posts-filter-state';
import { SearchInput } from '../components/search-input';

export function BlogNavigation() {
const location = useLocation();
const { isCommunityMode } = useInstanceConfig();
const { data: community } = useCommunityData();

// getConfiguredPostsFilters validates the shape. Reading the raw value here
// would re-open the crash it exists to prevent: a scalar is truthy, so
// availableFilters.map below would throw and take the whole layout with it.
const availableFilters = getConfiguredPostsFilters();

const currentFilter = useMemo(() => {
// Default to the first configured filter
const defaultFilter = availableFilters[0] || 'posts';

if (typeof location.search === 'string') {
const searchParams = new URLSearchParams(location.search);
return searchParams.get('filter') || defaultFilter;
}
if (
location.search &&
typeof location.search === 'object' &&
'filter' in location.search
) {
return (location.search.filter as string) || defaultFilter;
}
return defaultFilter;
}, [location.search, availableFilters]);
// Shared with theme shells (use-posts-filter-state) so no shell can drift
// from this navigation's filter behavior. The hook validates the configured
// shape, which keeps the scalar-postsFilters crash guard.
const { availableFilters, currentFilter, filterLabel: getFilterLabel } =
usePostsFilterState();

const blogTitle = InstanceConfigManager.useConfig(
({ configuration }) => configuration.instanceConfiguration.meta.title,
Expand All @@ -63,18 +45,6 @@ export function BlogNavigation() {
return null;
}, [blogLogo, isCommunityMode, community?.name, proxyBase]);

// Get localized filter label
const getFilterLabel = (filter: string): string => {
// Try i18n key first (e.g., blog.navigation.blog, blog.navigation.trending)
const i18nKey = `blog.navigation.${filter}`;
const translated = t(i18nKey as Parameters<typeof t>[0]);
// If translation returns the key itself, use capitalized filter name
if (translated === i18nKey) {
return filter.charAt(0).toUpperCase() + filter.slice(1);
}
return translated;
};

return (
<div className="max-w-3xl mx-auto mb-6 sm:mb-8">
<div className="flex items-center justify-between mb-4 sm:mb-6">
Expand Down
41 changes: 41 additions & 0 deletions apps/self-hosted/src/features/floating-menu/config-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -863,3 +863,44 @@ describe('isFieldVisible (the visibleWhen capability)', () => {
expect(isFieldVisible(broken, doc)).toBe(true);
});
});

describe('theme-gated layout options', () => {
const fields = buildConfigFields((key) => key);
// Typed walk down the section tree; a missing level fails the suite loudly
// instead of hiding behind a cast.
function sectionFields(field: ConfigField | undefined, name: string) {
if (!field?.fields) throw new Error(`section ${name} has no fields`);
return field.fields;
}
const layout = sectionFields(
sectionFields(
sectionFields(fields.configuration, 'configuration').instanceConfiguration,
'instanceConfiguration',
).layout,
'layout',
);

function docWithTemplate(styleTemplate?: string) {
return {
configuration: { general: styleTemplate ? { styleTemplate } : {} },
} as unknown as Record<string, import('./types').ConfigValue>;
}

it('hides the sidebar section and list type under a theme that declares them unsupported', () => {
expect(isFieldVisible(layout.sidebar, docWithTemplate('journal'))).toBe(false);
expect(isFieldVisible(layout.listType, docWithTemplate('journal'))).toBe(false);
});

it('shows them for every CSS-only template and for an unset template', () => {
for (const template of ['medium', 'minimal', 'magazine', 'developer', 'modern-gradient']) {
expect(isFieldVisible(layout.sidebar, docWithTemplate(template))).toBe(true);
}
expect(isFieldVisible(layout.listType, docWithTemplate())).toBe(true);
});

it('follows the UNSAVED draft, so switching templates in the panel reacts immediately', () => {
// The predicate reads the edited document, not the applied config.
expect(isFieldVisible(layout.sidebar, docWithTemplate('journal'))).toBe(false);
expect(isFieldVisible(layout.sidebar, docWithTemplate('medium'))).toBe(true);
});
});
24 changes: 24 additions & 0 deletions apps/self-hosted/src/features/floating-menu/config-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ import {
STYLE_TEMPLATES,
type StyleTemplate,
} from '../../../hosting/api/src/style-templates';
import { isThemeOptionSupported } from '@/themes/registry';

/**
* The configured style template out of the EDITED document, for visibleWhen
* predicates: visibility must follow the unsaved draft, so switching the
* template in the panel immediately shows or hides the options that theme
* consumes.
*/
function editedStyleTemplate(document: Record<string, ConfigValue>): unknown {
const configuration = document?.configuration as
| { general?: { styleTemplate?: unknown } }
| undefined;
return configuration?.general?.styleTemplate;
}

/**
* One label key per roster entry, `satisfies` so adding a template to the
Expand All @@ -31,6 +45,7 @@ const STYLE_TEMPLATE_LABEL_KEYS = {
developer: 'panel_configuration_general_style_template_developer_option',
'modern-gradient':
'panel_configuration_general_style_template_modern_gradient_option',
journal: 'panel_configuration_general_style_template_journal_option',
} satisfies Record<StyleTemplate, TranslationKey>;

export type ConfigFieldType =
Expand Down Expand Up @@ -240,6 +255,11 @@ export function buildConfigFields(
listType: {
label: t('panel_configuration_instance_configuration_layout_list_type_label'),
type: 'select',
// Declared unsupported by themes whose entry component is not
// list/grid switchable (Journal renders one column of plain
// entries). Hidden, not inert: the stored value is untouched.
visibleWhen: (document) =>
isThemeOptionSupported(editedStyleTemplate(document), 'listType'),
description: t('panel_configuration_instance_configuration_layout_list_type_description'),
options: [
{ value: 'list', label: t('panel_configuration_instance_configuration_layout_list_type_list_option') },
Expand All @@ -260,6 +280,10 @@ export function buildConfigFields(
sidebar: {
label: t('panel_configuration_instance_configuration_layout_sidebar_label'),
type: 'section',
// Themes whose shell renders no sidebar declare it unsupported;
// the whole section hides rather than sitting there doing nothing.
visibleWhen: (document) =>
isThemeOptionSupported(editedStyleTemplate(document), 'sidebar'),
fields: {
placement: {
label: t('panel_configuration_instance_configuration_layout_sidebar_placement_label'),
Expand Down
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', () => {
// Five templates in two modes, plus the two base blocks in variables.css.
// Six 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(12);
expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(6);
expect(accentBlocks.length).toBe(14);
expect(new Set(accentBlocks.map((block) => block.file)).size).toBe(7);
});

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(5);
expect(templates.length).toBe(6);
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 @@ -9,3 +9,4 @@
@import "./magazine.css";
@import "./developer.css";
@import "./modern-gradient.css";
@import "./journal.css";
Loading
Loading