feat: style editor for blog
This commit is contained in:
@@ -5,6 +5,7 @@ import { app } from 'electron';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { posts, projects } from '../database/schema';
|
import { posts, projects } from '../database/schema';
|
||||||
|
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
|
||||||
import {
|
import {
|
||||||
normalizeTaxonomyTerm,
|
normalizeTaxonomyTerm,
|
||||||
normalizeNonEmptyTaxonomyTerm,
|
normalizeNonEmptyTaxonomyTerm,
|
||||||
@@ -22,6 +23,7 @@ export interface ProjectMetadata {
|
|||||||
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
|
||||||
defaultAuthor?: string; // Default author for new posts and media
|
defaultAuthor?: string; // Default author for new posts and media
|
||||||
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50)
|
||||||
|
picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
const DEFAULT_MAX_POSTS_PER_PAGE = 50;
|
||||||
@@ -61,10 +63,12 @@ function sanitizePublicUrl(value: unknown): string | undefined {
|
|||||||
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||||
|
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
maxPostsPerPage,
|
maxPostsPerPage,
|
||||||
|
picoTheme,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +183,9 @@ export class MetaEngine extends EventEmitter {
|
|||||||
if (updates.maxPostsPerPage !== undefined) {
|
if (updates.maxPostsPerPage !== undefined) {
|
||||||
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage);
|
normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage);
|
||||||
}
|
}
|
||||||
|
if (updates.picoTheme !== undefined) {
|
||||||
|
normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme);
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.projectMetadata) {
|
if (!this.projectMetadata) {
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
@@ -189,6 +196,7 @@ export class MetaEngine extends EventEmitter {
|
|||||||
mainLanguage: normalizedUpdates.mainLanguage,
|
mainLanguage: normalizedUpdates.mainLanguage,
|
||||||
defaultAuthor: normalizedUpdates.defaultAuthor,
|
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||||
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||||
|
picoTheme: normalizedUpdates.picoTheme,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.projectMetadata = normalizeProjectMetadata({
|
this.projectMetadata = normalizeProjectMetadata({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { marked } from 'marked';
|
|||||||
import { Liquid } from 'liquidjs';
|
import { Liquid } from 'liquidjs';
|
||||||
import type { MediaData } from './MediaEngine';
|
import type { MediaData } from './MediaEngine';
|
||||||
import type { PostData } from './PostEngine';
|
import type { PostData } from './PostEngine';
|
||||||
|
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
||||||
|
|
||||||
export interface HtmlRewriteContext {
|
export interface HtmlRewriteContext {
|
||||||
canonicalPostPathBySlug: Map<string, string>;
|
canonicalPostPathBySlug: Map<string, string>;
|
||||||
@@ -41,6 +42,8 @@ export type DateArchiveContext = {
|
|||||||
export interface PostListTemplateContext {
|
export interface PostListTemplateContext {
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
is_date_archive: boolean;
|
is_date_archive: boolean;
|
||||||
show_archive_range_heading: boolean;
|
show_archive_range_heading: boolean;
|
||||||
archive_context: {
|
archive_context: {
|
||||||
@@ -67,6 +70,8 @@ export interface PostListTemplateContext {
|
|||||||
export interface SinglePostTemplateContext {
|
export interface SinglePostTemplateContext {
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
post: TemplatePostEntry;
|
post: TemplatePostEntry;
|
||||||
canonical_post_path_by_slug: Record<string, string>;
|
canonical_post_path_by_slug: Record<string, string>;
|
||||||
canonical_media_path_by_source_path: Record<string, string>;
|
canonical_media_path_by_source_path: Record<string, string>;
|
||||||
@@ -75,6 +80,8 @@ export interface SinglePostTemplateContext {
|
|||||||
export interface NotFoundTemplateContext {
|
export interface NotFoundTemplateContext {
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoutePagination {
|
export interface RoutePagination {
|
||||||
@@ -96,11 +103,20 @@ export interface PostEngineContract {
|
|||||||
getPost: (id: string) => Promise<PostData | null>;
|
getPost: (id: string) => Promise<PostData | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PREVIEW_ASSETS = {
|
export const PREVIEW_ASSETS: Record<string, { modulePath: string; contentType: string }> = {
|
||||||
'pico.min.css': {
|
'pico.min.css': {
|
||||||
modulePath: '@picocss/pico/css/pico.min.css',
|
modulePath: '@picocss/pico/css/pico.min.css',
|
||||||
contentType: 'text/css; charset=utf-8',
|
contentType: 'text/css; charset=utf-8',
|
||||||
},
|
},
|
||||||
|
...Object.fromEntries(
|
||||||
|
PICO_THEME_NAMES.map((theme) => [
|
||||||
|
`pico.${theme}.min.css`,
|
||||||
|
{
|
||||||
|
modulePath: `@picocss/pico/css/pico.${theme}.min.css`,
|
||||||
|
contentType: 'text/css; charset=utf-8',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
'lightbox.min.css': {
|
'lightbox.min.css': {
|
||||||
modulePath: 'lightbox2/dist/css/lightbox.min.css',
|
modulePath: 'lightbox2/dist/css/lightbox.min.css',
|
||||||
contentType: 'text/css; charset=utf-8',
|
contentType: 'text/css; charset=utf-8',
|
||||||
@@ -109,7 +125,7 @@ export const PREVIEW_ASSETS = {
|
|||||||
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
|
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
|
||||||
contentType: 'application/javascript; charset=utf-8',
|
contentType: 'application/javascript; charset=utf-8',
|
||||||
},
|
},
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
export const PREVIEW_IMAGE_ASSETS = {
|
export const PREVIEW_IMAGE_ASSETS = {
|
||||||
'prev.png': {
|
'prev.png': {
|
||||||
@@ -616,6 +632,8 @@ export class PageRenderer {
|
|||||||
basePathname: string;
|
basePathname: string;
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
pagination?: PaginationContext;
|
pagination?: PaginationContext;
|
||||||
},
|
},
|
||||||
): PostListTemplateContext {
|
): PostListTemplateContext {
|
||||||
@@ -710,6 +728,8 @@ export class PageRenderer {
|
|||||||
return {
|
return {
|
||||||
page_title: options.page_title,
|
page_title: options.page_title,
|
||||||
language: options.language,
|
language: options.language,
|
||||||
|
pico_stylesheet_href: options.pico_stylesheet_href,
|
||||||
|
html_theme_attribute: options.html_theme_attribute,
|
||||||
is_date_archive: options.routeKind === 'date',
|
is_date_archive: options.routeKind === 'date',
|
||||||
show_archive_range_heading: hasRangeHeading,
|
show_archive_range_heading: hasRangeHeading,
|
||||||
archive_context: options.routeKind === 'date'
|
archive_context: options.routeKind === 'date'
|
||||||
@@ -763,6 +783,8 @@ export class PageRenderer {
|
|||||||
basePathname: string;
|
basePathname: string;
|
||||||
page_title: string;
|
page_title: string;
|
||||||
language: string;
|
language: string;
|
||||||
|
pico_stylesheet_href?: string;
|
||||||
|
html_theme_attribute?: string;
|
||||||
pagination?: PaginationContext;
|
pagination?: PaginationContext;
|
||||||
},
|
},
|
||||||
postEngine?: PostEngineContract,
|
postEngine?: PostEngineContract,
|
||||||
@@ -786,7 +808,7 @@ export class PageRenderer {
|
|||||||
async renderSinglePost(
|
async renderSinglePost(
|
||||||
post: PostData,
|
post: PostData,
|
||||||
rewriteContext: HtmlRewriteContext,
|
rewriteContext: HtmlRewriteContext,
|
||||||
pageContext: { page_title: string; language: string },
|
pageContext: { page_title: string; language: string; pico_stylesheet_href?: string; html_theme_attribute?: string },
|
||||||
postEngine?: PostEngineContract,
|
postEngine?: PostEngineContract,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const renderablePost = postEngine
|
const renderablePost = postEngine
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
type MediaEngineContract,
|
type MediaEngineContract,
|
||||||
type PostMediaEngineContract,
|
type PostMediaEngineContract,
|
||||||
} from './PageRenderer';
|
} from './PageRenderer';
|
||||||
|
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||||
|
|
||||||
interface ActiveProjectContext {
|
interface ActiveProjectContext {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -172,11 +173,25 @@ export class PreviewServer {
|
|||||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||||
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
||||||
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||||
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
|
||||||
|
|
||||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
|
||||||
|
const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode'));
|
||||||
|
const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
|
||||||
|
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
||||||
|
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
||||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
||||||
|
|
||||||
|
if (pathname === '/__style-preview') {
|
||||||
|
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
||||||
|
pageTitle,
|
||||||
|
language,
|
||||||
|
picoStylesheetHref,
|
||||||
|
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
|
||||||
|
});
|
||||||
|
this.respond(res, 200, stylePreviewHtml);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const asset = await this.resolveAsset(pathname);
|
const asset = await this.resolveAsset(pathname);
|
||||||
if (asset) {
|
if (asset) {
|
||||||
this.respondAsset(res, asset.contentType, asset.body);
|
this.respondAsset(res, asset.contentType, asset.body);
|
||||||
@@ -198,11 +213,15 @@ export class PreviewServer {
|
|||||||
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, {
|
const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, {
|
||||||
pageTitle,
|
pageTitle,
|
||||||
language,
|
language,
|
||||||
|
picoStylesheetHref,
|
||||||
|
htmlThemeAttribute: undefined,
|
||||||
});
|
});
|
||||||
if (!result) {
|
if (!result) {
|
||||||
const notFoundHtml = await this.pageRenderer.renderNotFound({
|
const notFoundHtml = await this.pageRenderer.renderNotFound({
|
||||||
page_title: '404 Not Found',
|
page_title: '404 Not Found',
|
||||||
language,
|
language,
|
||||||
|
pico_stylesheet_href: picoStylesheetHref,
|
||||||
|
html_theme_attribute: undefined,
|
||||||
});
|
});
|
||||||
this.respond(res, 404, notFoundHtml);
|
this.respond(res, 404, notFoundHtml);
|
||||||
return;
|
return;
|
||||||
@@ -219,7 +238,7 @@ export class PreviewServer {
|
|||||||
pathname: string,
|
pathname: string,
|
||||||
maxPostsPerPage: number,
|
maxPostsPerPage: number,
|
||||||
rewriteContext: HtmlRewriteContext,
|
rewriteContext: HtmlRewriteContext,
|
||||||
pageContext: { pageTitle: string; language: string },
|
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const routePagination = parseRoutePagination(pathname);
|
const routePagination = parseRoutePagination(pathname);
|
||||||
if (!routePagination) {
|
if (!routePagination) {
|
||||||
@@ -244,6 +263,8 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +276,8 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +292,8 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +305,8 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +320,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +337,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +354,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +371,8 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +390,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,6 +409,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +426,8 @@ export class PreviewServer {
|
|||||||
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts },
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,12 +440,45 @@ export class PreviewServer {
|
|||||||
return this.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
|
return this.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
|
||||||
page_title: pageContext.pageTitle,
|
page_title: pageContext.pageTitle,
|
||||||
language: pageContext.language,
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
}, this.postEngine);
|
}, this.postEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async renderStylePreview(
|
||||||
|
rewriteContext: HtmlRewriteContext,
|
||||||
|
pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, {
|
||||||
|
maxPostsPerPage: 10,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.posts.length === 0) {
|
||||||
|
return this.pageRenderer.renderNotFound({
|
||||||
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageRenderer.renderPostList(result.posts, rewriteContext, {
|
||||||
|
archiveGrouping: true,
|
||||||
|
routeKind: 'date',
|
||||||
|
archiveContext: { kind: 'root' },
|
||||||
|
basePathname: '/__style-preview',
|
||||||
|
pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts },
|
||||||
|
page_title: pageContext.pageTitle,
|
||||||
|
language: pageContext.language,
|
||||||
|
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||||
|
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||||
|
}, this.postEngine);
|
||||||
|
}
|
||||||
|
|
||||||
private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise<PostData | null> {
|
private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise<PostData | null> {
|
||||||
if (!slug) return null;
|
if (!slug) return null;
|
||||||
|
|
||||||
@@ -588,7 +662,7 @@ export class PreviewServer {
|
|||||||
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
const match = pathname.match(/^\/assets\/([^/]+)$/);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
|
|
||||||
const assetName = match[1] as keyof typeof PREVIEW_ASSETS;
|
const assetName = match[1];
|
||||||
const assetDefinition = PREVIEW_ASSETS[assetName];
|
const assetDefinition = PREVIEW_ASSETS[assetName];
|
||||||
if (!assetDefinition) return null;
|
if (!assetDefinition) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ language }}">
|
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||||
{% render 'partials/head', page_title: page_title %}
|
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<section class="not-found" data-template="not-found">
|
<section class="not-found" data-template="not-found">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{{ page_title }}</title>
|
<title>{{ page_title }}</title>
|
||||||
<link rel="stylesheet" href="/assets/pico.min.css" />
|
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
|
||||||
|
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
|
||||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||||
{% render 'partials/styles' %}
|
{% render 'partials/styles' %}
|
||||||
<script defer src="/assets/lightbox.min.js"></script>
|
<script defer src="/assets/lightbox.min.js"></script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light dark; }
|
:root { color-scheme: light dark; }
|
||||||
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; }
|
body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem 4rem; background: var(--pico-background-color, var(--background-color)); color: var(--pico-color, var(--color)); }
|
||||||
main { display: grid; gap: 1rem; }
|
main { display: grid; gap: 1rem; }
|
||||||
.post { border: 1px solid var(--muted-border-color); padding: 1rem; background: var(--card-background-color); }
|
.post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); }
|
||||||
.post iframe { width: 100%; min-height: 20rem; }
|
.post iframe { width: 100%; min-height: 20rem; }
|
||||||
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--muted-border-color); padding: .75rem; margin: 1rem 0; }
|
.macro-gallery, .macro-photo-archive { border: 1px dashed var(--pico-muted-border-color, var(--muted-border-color)); padding: .75rem; margin: 1rem 0; }
|
||||||
.gallery-container { display: grid; gap: .5rem; }
|
.gallery-container { display: grid; gap: .5rem; }
|
||||||
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
|
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
|
||||||
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -14,23 +14,23 @@
|
|||||||
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||||
.gallery-item, .photo-archive-item { display: block; overflow: hidden; border-radius: .25rem; }
|
.gallery-item, .photo-archive-item { display: block; overflow: hidden; border-radius: .25rem; }
|
||||||
.gallery-item img, .photo-archive-item img { display: block; width: 100%; height: auto; aspect-ratio: 1 / 1; object-fit: cover; }
|
.gallery-item img, .photo-archive-item img { display: block; width: 100%; height: auto; aspect-ratio: 1 / 1; object-fit: cover; }
|
||||||
.gallery-caption { margin-top: .5rem; text-align: center; color: var(--muted-color); font-size: .92rem; }
|
.gallery-caption { margin-top: .5rem; text-align: center; color: var(--pico-muted-color, var(--muted-color)); font-size: .92rem; }
|
||||||
.gallery-empty, .photo-archive-empty { color: var(--muted-color); font-style: italic; }
|
.gallery-empty, .photo-archive-empty { color: var(--pico-muted-color, var(--muted-color)); font-style: italic; }
|
||||||
.photo-archive-container { display: grid; gap: 1rem; }
|
.photo-archive-container { display: grid; gap: 1rem; }
|
||||||
.photo-archive-month { display: grid; grid-template-columns: 3.25rem 1fr; gap: .75rem; align-items: start; }
|
.photo-archive-month { display: grid; grid-template-columns: 3.25rem 1fr; gap: .75rem; align-items: start; }
|
||||||
.photo-archive-month-label { display: flex; justify-content: center; align-items: center; }
|
.photo-archive-month-label { display: flex; justify-content: center; align-items: center; }
|
||||||
.photo-archive-month-label span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .08em; text-transform: uppercase; color: var(--muted-color); }
|
.photo-archive-month-label span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .08em; text-transform: uppercase; color: var(--pico-muted-color, var(--muted-color)); }
|
||||||
.photo-archive-gallery { display: grid; gap: .5rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
.photo-archive-gallery { display: grid; gap: .5rem; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||||
.photo-archive-single-month .photo-archive-gallery { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
.photo-archive-single-month .photo-archive-gallery { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||||
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
|
.archive-day-group { display: grid; grid-template-columns: 5.25rem 1fr; gap: 1.25rem; align-items: stretch; }
|
||||||
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--muted-color); }
|
.archive-day-marker { display: flex; justify-content: center; align-items: center; color: var(--pico-muted-color, var(--muted-color)); }
|
||||||
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }
|
.archive-day-marker span { writing-mode: vertical-rl; transform: rotate(180deg); letter-spacing: .16em; font-size: 1.05rem; font-weight: 600; text-transform: uppercase; }
|
||||||
.archive-day-posts { display: grid; gap: 1rem; }
|
.archive-day-posts { display: grid; gap: 1rem; }
|
||||||
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--color); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--pico-color, var(--color)); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
|
||||||
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
|
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
|
||||||
.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }
|
||||||
.preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; }
|
.preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; }
|
||||||
.preview-pagination-link { color: var(--muted-color); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; }
|
.preview-pagination-link { color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; }
|
||||||
.preview-pagination-link:hover,
|
.preview-pagination-link:hover,
|
||||||
.preview-pagination-link:focus-visible { opacity: 1; text-decoration: underline; }
|
.preview-pagination-link:focus-visible { opacity: 1; text-decoration: underline; }
|
||||||
.preview-pagination .spacer { flex: 1; }
|
.preview-pagination .spacer { flex: 1; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ language }}">
|
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||||
{% render 'partials/head', page_title: page_title %}
|
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
{% if archive_context %}
|
{% if archive_context %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ language }}">
|
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||||
{% render 'partials/head', page_title: page_title %}
|
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<article class="single-post" data-template="single-post">
|
<article class="single-post" data-template="single-post">
|
||||||
|
|||||||
@@ -863,7 +863,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
});
|
});
|
||||||
|
|
||||||
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => {
|
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('../shared/picoThemes').PicoThemeName }) => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
await engine.updateProjectMetadata(updates);
|
await engine.updateProjectMetadata(updates);
|
||||||
return engine.getProjectMetadata();
|
return engine.getProjectMetadata();
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
|
||||||
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tag Management (advanced tag operations)
|
// Tag Management (advanced tag operations)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface ProjectMetadata {
|
|||||||
mainLanguage?: string;
|
mainLanguage?: string;
|
||||||
defaultAuthor?: string;
|
defaultAuthor?: string;
|
||||||
maxPostsPerPage?: number;
|
maxPostsPerPage?: number;
|
||||||
|
picoTheme?: import('./picoThemes').PicoThemeName;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectData {
|
export interface ProjectData {
|
||||||
@@ -528,7 +529,7 @@ export interface ElectronAPI {
|
|||||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
||||||
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
getProjectMetadata: () => Promise<ProjectMetadata | null>;
|
||||||
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
|
||||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number }) => Promise<ProjectMetadata | null>;
|
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName }) => Promise<ProjectMetadata | null>;
|
||||||
};
|
};
|
||||||
tags: {
|
tags: {
|
||||||
getAll: () => Promise<TagData[]>;
|
getAll: () => Promise<TagData[]>;
|
||||||
|
|||||||
50
src/main/shared/picoThemes.ts
Normal file
50
src/main/shared/picoThemes.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
export const PICO_THEME_NAMES = [
|
||||||
|
'amber',
|
||||||
|
'blue',
|
||||||
|
'cyan',
|
||||||
|
'fuchsia',
|
||||||
|
'green',
|
||||||
|
'grey',
|
||||||
|
'indigo',
|
||||||
|
'jade',
|
||||||
|
'lime',
|
||||||
|
'orange',
|
||||||
|
'pink',
|
||||||
|
'pumpkin',
|
||||||
|
'purple',
|
||||||
|
'red',
|
||||||
|
'sand',
|
||||||
|
'slate',
|
||||||
|
'violet',
|
||||||
|
'yellow',
|
||||||
|
'zinc',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type PicoThemeName = (typeof PICO_THEME_NAMES)[number];
|
||||||
|
export type PicoThemeMode = 'auto' | 'light' | 'dark';
|
||||||
|
|
||||||
|
export function isPicoThemeName(value: unknown): value is PicoThemeName {
|
||||||
|
return typeof value === 'string' && (PICO_THEME_NAMES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePicoTheme(value: unknown): PicoThemeName | undefined {
|
||||||
|
return isPicoThemeName(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizePicoThemeMode(value: unknown): PicoThemeMode | undefined {
|
||||||
|
if (value === 'auto' || value === 'light' || value === 'dark') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPicoStylesheetAssetName(theme: PicoThemeName | undefined): string {
|
||||||
|
if (!theme) {
|
||||||
|
return 'pico.min.css';
|
||||||
|
}
|
||||||
|
return `pico.${theme}.min.css`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPicoStylesheetHref(theme: PicoThemeName | undefined): string {
|
||||||
|
return `/assets/${getPicoStylesheetAssetName(theme)}`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
|
|||||||
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
|
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
|
||||||
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||||
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from './utils/picoTheme';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
@@ -22,6 +23,7 @@ const App: React.FC = () => {
|
|||||||
setActiveView,
|
setActiveView,
|
||||||
setSelectedPost,
|
setSelectedPost,
|
||||||
setActiveProject,
|
setActiveProject,
|
||||||
|
setPicoTheme,
|
||||||
openTab,
|
openTab,
|
||||||
restoreTabState,
|
restoreTabState,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
@@ -35,6 +37,11 @@ const App: React.FC = () => {
|
|||||||
const activeProject = await window.electronAPI?.projects.getActive();
|
const activeProject = await window.electronAPI?.projects.getActive();
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
setActiveProject(activeProject as import('./store').ProjectData);
|
setActiveProject(activeProject as import('./store').ProjectData);
|
||||||
|
|
||||||
|
const metadata = await window.electronAPI?.meta.getProjectMetadata();
|
||||||
|
setPicoTheme(metadata?.picoTheme);
|
||||||
|
const resolvedTheme = getRendererPicoTheme(metadata?.picoTheme);
|
||||||
|
await ensureRendererPicoThemeStylesheet(resolvedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load posts (now with correct project context, limited to 500)
|
// Load posts (now with correct project context, limited to 500)
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||||
import '@picocss/pico/css/pico.conditional.slate.min.css';
|
import { useAppStore } from '../../store';
|
||||||
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
||||||
import './DocumentationView.css';
|
import './DocumentationView.css';
|
||||||
|
|
||||||
export const DocumentationView: React.FC = () => {
|
export const DocumentationView: React.FC = () => {
|
||||||
|
const { picoTheme } = useAppStore();
|
||||||
|
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureRendererPicoThemeStylesheet(resolvedTheme).catch((error) => {
|
||||||
|
console.error('Failed to load documentation theme stylesheet:', error);
|
||||||
|
});
|
||||||
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="documentation-view">
|
<div className="documentation-view">
|
||||||
<div className="documentation-header">
|
<div className="documentation-header">
|
||||||
@@ -12,7 +22,7 @@ export const DocumentationView: React.FC = () => {
|
|||||||
<p>User guide for this installed bDS version.</p>
|
<p>User guide for this installed bDS version.</p>
|
||||||
</div>
|
</div>
|
||||||
<main className="documentation-scroll">
|
<main className="documentation-scroll">
|
||||||
<div className="documentation-content markdown-body pico" data-theme="auto">
|
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
||||||
<article className="documentation-article">
|
<article className="documentation-article">
|
||||||
<Markdown>{documentationContent}</Markdown>
|
<Markdown>{documentationContent}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { LinkedMediaPanel } from '../LinkedMediaPanel';
|
|||||||
import { ErrorModal } from '../ErrorModal';
|
import { ErrorModal } from '../ErrorModal';
|
||||||
import { ConfirmDeleteModal } from '../ConfirmDeleteModal';
|
import { ConfirmDeleteModal } from '../ConfirmDeleteModal';
|
||||||
import { SettingsView } from '../SettingsView';
|
import { SettingsView } from '../SettingsView';
|
||||||
|
import { StyleView } from '../StyleView/StyleView';
|
||||||
import { TagsView } from '../TagsView';
|
import { TagsView } from '../TagsView';
|
||||||
import { TagInput } from '../TagInput';
|
import { TagInput } from '../TagInput';
|
||||||
import { ChatPanel } from '../ChatPanel';
|
import { ChatPanel } from '../ChatPanel';
|
||||||
@@ -1697,6 +1698,7 @@ export const Editor: React.FC = () => {
|
|||||||
const showPost = activeTab?.type === 'post';
|
const showPost = activeTab?.type === 'post';
|
||||||
const showMedia = activeTab?.type === 'media';
|
const showMedia = activeTab?.type === 'media';
|
||||||
const showSettings = activeTab?.type === 'settings';
|
const showSettings = activeTab?.type === 'settings';
|
||||||
|
const showStyle = activeTab?.type === 'style';
|
||||||
const showTags = activeTab?.type === 'tags';
|
const showTags = activeTab?.type === 'tags';
|
||||||
const showChat = activeTab?.type === 'chat';
|
const showChat = activeTab?.type === 'chat';
|
||||||
const showImport = activeTab?.type === 'import';
|
const showImport = activeTab?.type === 'import';
|
||||||
@@ -1765,6 +1767,16 @@ export const Editor: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showStyle) {
|
||||||
|
return (
|
||||||
|
<div className="editor">
|
||||||
|
<StyleView />
|
||||||
|
{renderErrorModal()}
|
||||||
|
{renderConfirmDeleteModal()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Show tags if tags tab is active
|
// Show tags if tags tab is active
|
||||||
if (showTags) {
|
if (showTags) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1244,6 +1244,7 @@ const SettingsNav: React.FC = () => {
|
|||||||
|
|
||||||
// Check if settings panel is currently active
|
// Check if settings panel is currently active
|
||||||
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId);
|
const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId);
|
||||||
|
const isStyleTabActive = tabs.some(t => t.type === 'style' && t.id === activeTabId);
|
||||||
|
|
||||||
const handleNavClick = (category: SettingsCategory) => {
|
const handleNavClick = (category: SettingsCategory) => {
|
||||||
// If settings panel is not open or not active, open it first
|
// If settings panel is not open or not active, open it first
|
||||||
@@ -1257,6 +1258,10 @@ const SettingsNav: React.FC = () => {
|
|||||||
}, isSettingsTabActive ? 0 : 100);
|
}, isSettingsTabActive ? 0 : 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStyleClick = () => {
|
||||||
|
openTab({ type: 'style', id: 'style', isTransient: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-content settings-panel">
|
<div className="sidebar-content settings-panel">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
@@ -1308,6 +1313,13 @@ const SettingsNav: React.FC = () => {
|
|||||||
<span className="settings-nav-entry-icon">🗄️</span>
|
<span className="settings-nav-entry-icon">🗄️</span>
|
||||||
<span>Data</span>
|
<span>Data</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
|
||||||
|
onClick={handleStyleClick}
|
||||||
|
>
|
||||||
|
<span className="settings-nav-entry-icon">🎨</span>
|
||||||
|
<span>Style</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -89,3 +89,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-bar-item.theme-badge {
|
||||||
|
border: 1px solid var(--vscode-statusBar-border, transparent);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { ProjectSelector } from '../ProjectSelector';
|
import { ProjectSelector } from '../ProjectSelector';
|
||||||
|
import { getRendererPicoTheme } from '../../utils/picoTheme';
|
||||||
import './StatusBar.css';
|
import './StatusBar.css';
|
||||||
|
|
||||||
export const StatusBar: React.FC = () => {
|
export const StatusBar: React.FC = () => {
|
||||||
@@ -9,6 +10,7 @@ export const StatusBar: React.FC = () => {
|
|||||||
tasks,
|
tasks,
|
||||||
selectedPostId,
|
selectedPostId,
|
||||||
totalPosts,
|
totalPosts,
|
||||||
|
picoTheme,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [selectedPostStatus, setSelectedPostStatus] = useState<string | null>(null);
|
const [selectedPostStatus, setSelectedPostStatus] = useState<string | null>(null);
|
||||||
@@ -25,6 +27,7 @@ export const StatusBar: React.FC = () => {
|
|||||||
}, [selectedPostId]);
|
}, [selectedPostId]);
|
||||||
|
|
||||||
const runningTasks = tasks.filter(t => t.status === 'running');
|
const runningTasks = tasks.filter(t => t.status === 'running');
|
||||||
|
const activeTheme = getRendererPicoTheme(picoTheme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="status-bar">
|
<div className="status-bar">
|
||||||
@@ -61,6 +64,10 @@ export const StatusBar: React.FC = () => {
|
|||||||
<span>{media.length} media</span>
|
<span>{media.length} media</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="status-bar-item theme-badge">
|
||||||
|
<span>Theme: {activeTheme}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* App Name */}
|
{/* App Name */}
|
||||||
<div className="status-bar-item brand">
|
<div className="status-bar-item brand">
|
||||||
<span>bDS</span>
|
<span>bDS</span>
|
||||||
|
|||||||
106
src/renderer/components/StyleView/StyleView.css
Normal file
106
src/renderer/components/StyleView/StyleView.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
.style-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-view-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-view-header p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-picker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-option {
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-option.selected {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-swatch {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-tones {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-tone {
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-input-border) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-tone-dark {
|
||||||
|
border-color: color-mix(in srgb, #ffffff 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-name {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-apply-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-mode-control {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-mode-control select {
|
||||||
|
min-width: 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
138
src/renderer/components/StyleView/StyleView.tsx
Normal file
138
src/renderer/components/StyleView/StyleView.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useAppStore } from '../../store';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import { PICO_THEME_NAMES, PICO_THEME_PREVIEW_TOKENS, getRendererPicoTheme, ensureRendererPicoThemeStylesheet, type PicoThemeName } from '../../utils/picoTheme';
|
||||||
|
import './StyleView.css';
|
||||||
|
|
||||||
|
const PREVIEW_SERVER_BASE_URL = 'http://127.0.0.1:4123';
|
||||||
|
type PreviewMode = 'auto' | 'light' | 'dark';
|
||||||
|
|
||||||
|
function toDisplayName(theme: PicoThemeName): string {
|
||||||
|
return `${theme.charAt(0).toUpperCase()}${theme.slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StyleView: React.FC = () => {
|
||||||
|
const { activeProject, picoTheme, setPicoTheme } = useAppStore();
|
||||||
|
const [selectedTheme, setSelectedTheme] = useState<PicoThemeName>(getRendererPicoTheme(picoTheme));
|
||||||
|
const [previewMode, setPreviewMode] = useState<PreviewMode>('auto');
|
||||||
|
const [isApplying, setIsApplying] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedTheme(getRendererPicoTheme(picoTheme));
|
||||||
|
}, [picoTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureRendererPicoThemeStylesheet(selectedTheme).catch((error) => {
|
||||||
|
console.error('Failed to load selected renderer theme stylesheet:', error);
|
||||||
|
});
|
||||||
|
}, [selectedTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTheme = async () => {
|
||||||
|
try {
|
||||||
|
const metadata = await window.electronAPI?.meta.getProjectMetadata();
|
||||||
|
const nextTheme = getRendererPicoTheme(metadata?.picoTheme);
|
||||||
|
setPicoTheme(metadata?.picoTheme);
|
||||||
|
setSelectedTheme(nextTheme);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load project style settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTheme();
|
||||||
|
}, [activeProject?.id, setPicoTheme]);
|
||||||
|
|
||||||
|
const previewUrl = useMemo(() => {
|
||||||
|
return `${PREVIEW_SERVER_BASE_URL}/__style-preview?theme=${encodeURIComponent(selectedTheme)}&mode=${encodeURIComponent(previewMode)}`;
|
||||||
|
}, [selectedTheme, previewMode]);
|
||||||
|
|
||||||
|
const handleApplyTheme = async () => {
|
||||||
|
try {
|
||||||
|
setIsApplying(true);
|
||||||
|
await window.electronAPI?.meta.updateProjectMetadata({ picoTheme: selectedTheme });
|
||||||
|
setPicoTheme(selectedTheme);
|
||||||
|
showToast.success(`Applied theme: ${toDisplayName(selectedTheme)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to apply style theme:', error);
|
||||||
|
showToast.error('Failed to apply theme');
|
||||||
|
} finally {
|
||||||
|
setIsApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="style-view">
|
||||||
|
<div className="style-view-header">
|
||||||
|
<h2>Style</h2>
|
||||||
|
<p>Select a Pico CSS theme and preview the top posts before applying.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-theme-picker" role="group" aria-label="Pico Theme Picker">
|
||||||
|
{PICO_THEME_NAMES.map((theme) => {
|
||||||
|
const isSelected = selectedTheme === theme;
|
||||||
|
const preview = PICO_THEME_PREVIEW_TOKENS[theme];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={theme}
|
||||||
|
type="button"
|
||||||
|
className={`style-theme-option ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={() => setSelectedTheme(theme)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
aria-label={toDisplayName(theme)}
|
||||||
|
>
|
||||||
|
<span className={`style-theme-swatch style-theme-swatch-${theme}`}>
|
||||||
|
<span className="style-theme-tones" aria-hidden="true">
|
||||||
|
<span
|
||||||
|
className="style-theme-tone style-theme-tone-accent"
|
||||||
|
style={{ background: `linear-gradient(135deg, ${preview.lightPrimary}, ${preview.darkPrimary})` }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="style-theme-tone style-theme-tone-light"
|
||||||
|
style={{ backgroundColor: preview.lightBackground }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="style-theme-tone style-theme-tone-dark"
|
||||||
|
style={{ backgroundColor: preview.darkBackground }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="style-theme-name">{toDisplayName(theme)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-apply-row">
|
||||||
|
<label className="style-preview-mode-control">
|
||||||
|
<span>Preview mode</span>
|
||||||
|
<select
|
||||||
|
aria-label="Preview mode"
|
||||||
|
value={previewMode}
|
||||||
|
onChange={(event) => setPreviewMode(event.target.value as PreviewMode)}
|
||||||
|
>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={handleApplyTheme}
|
||||||
|
disabled={isApplying || picoTheme === selectedTheme}
|
||||||
|
>
|
||||||
|
Apply Theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="style-preview-container">
|
||||||
|
<iframe
|
||||||
|
title="Theme preview"
|
||||||
|
className="style-preview-frame"
|
||||||
|
src={previewUrl}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StyleView;
|
||||||
1
src/renderer/components/StyleView/index.ts
Normal file
1
src/renderer/components/StyleView/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { StyleView } from './StyleView';
|
||||||
@@ -41,6 +41,10 @@ const getTabTitle = (
|
|||||||
if (tab.type === 'settings') {
|
if (tab.type === 'settings') {
|
||||||
return 'Settings';
|
return 'Settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'style') {
|
||||||
|
return 'Style';
|
||||||
|
}
|
||||||
|
|
||||||
if (tab.type === 'tags') {
|
if (tab.type === 'tags') {
|
||||||
return 'Tags';
|
return 'Tags';
|
||||||
@@ -107,6 +111,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
|||||||
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
|
<path d="M9.1 4.4L8.6 2H7.4l-.5 2.4-.7.3-2-1.3-.9.8 1.3 2-.2.7-2.4.5v1.2l2.4.5.3.8-1.3 2 .8.8 2-1.3.8.3.4 2.3h1.2l.5-2.4.8-.3 2 1.3.8-.8-1.3-2 .3-.8 2.3-.4V7.4l-2.4-.5-.3-.8 1.3-2-.8-.8-2 1.3-.7-.2zM9.4 1l.5 2.4L12 2.1l2 2-1.4 2.1 2.4.4v3l-2.4.5L14 12l-2 2-2.1-1.4-.5 2.4h-3L5.9 12.5 4 14l-2-2 1.4-2.1L1 9.4v-3l2.4-.5L2 4l2-2 2.1 1.4.4-2.4h3zm.6 7c0 1.1-.9 2-2 2s-2-.9-2-2 .9-2 2-2 2 .9 2 2zM8 9c.6 0 1-.4 1-1s-.4-1-1-1-1 .4-1 1 .4 1 1 1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'style':
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 1a6 6 0 0 1 4.91 2.55c-.61.3-1.39.52-2.28.52-1.08 0-1.9-.22-2.62-.42-.71-.2-1.33-.37-2.06-.37-.97 0-1.84.25-2.55.6A6 6 0 0 1 8 2zm-5 6a5.97 5.97 0 0 1 .17-1.42c.59-.37 1.5-.8 2.77-.8.59 0 1.1.14 1.76.32.79.22 1.69.47 2.92.47 1.05 0 1.99-.24 2.75-.59A6 6 0 0 1 13.99 8H3zm10.82 1h-10.6a6 6 0 0 0 10.6 0z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'tags':
|
case 'tags':
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export { TaskPopup } from './TaskPopup';
|
|||||||
export { ResizablePanel } from './ResizablePanel';
|
export { ResizablePanel } from './ResizablePanel';
|
||||||
export { CredentialsPanel } from './CredentialsPanel';
|
export { CredentialsPanel } from './CredentialsPanel';
|
||||||
export { SettingsView } from './SettingsView';
|
export { SettingsView } from './SettingsView';
|
||||||
|
export { StyleView } from './StyleView';
|
||||||
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
|
||||||
export { TagInput } from './TagInput';
|
export { TagInput } from './TagInput';
|
||||||
export { PostLinks } from './PostLinks';
|
export { PostLinks } from './PostLinks';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type {
|
|||||||
const STORAGE_KEY = 'bds-app-state';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation';
|
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
@@ -65,6 +65,7 @@ interface AppState {
|
|||||||
selectedPostId: string | null;
|
selectedPostId: string | null;
|
||||||
selectedMediaId: string | null;
|
selectedMediaId: string | null;
|
||||||
preferredEditorMode: EditorMode;
|
preferredEditorMode: EditorMode;
|
||||||
|
picoTheme: import('../../main/shared/picoThemes').PicoThemeName | undefined;
|
||||||
gitDiffPreferences: GitDiffPreferences;
|
gitDiffPreferences: GitDiffPreferences;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
@@ -113,6 +114,7 @@ interface AppState {
|
|||||||
setSelectedPost: (id: string | null) => void;
|
setSelectedPost: (id: string | null) => void;
|
||||||
setSelectedMedia: (id: string | null) => void;
|
setSelectedMedia: (id: string | null) => void;
|
||||||
setPreferredEditorMode: (mode: EditorMode) => void;
|
setPreferredEditorMode: (mode: EditorMode) => void;
|
||||||
|
setPicoTheme: (theme: import('../../main/shared/picoThemes').PicoThemeName | undefined) => void;
|
||||||
setGitDiffPreferences: (preferences: GitDiffPreferences) => void;
|
setGitDiffPreferences: (preferences: GitDiffPreferences) => void;
|
||||||
|
|
||||||
setPosts: (posts: PostData[], hasMore?: boolean, total?: number) => void;
|
setPosts: (posts: PostData[], hasMore?: boolean, total?: number) => void;
|
||||||
@@ -166,6 +168,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
selectedPostId: null,
|
selectedPostId: null,
|
||||||
selectedMediaId: null,
|
selectedMediaId: null,
|
||||||
preferredEditorMode: 'wysiwyg',
|
preferredEditorMode: 'wysiwyg',
|
||||||
|
picoTheme: undefined,
|
||||||
gitDiffPreferences: {
|
gitDiffPreferences: {
|
||||||
wordWrap: true,
|
wordWrap: true,
|
||||||
viewStyle: 'inline',
|
viewStyle: 'inline',
|
||||||
@@ -289,6 +292,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
setSelectedPost: (id) => set({ selectedPostId: id }),
|
setSelectedPost: (id) => set({ selectedPostId: id }),
|
||||||
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
setSelectedMedia: (id) => set({ selectedMediaId: id }),
|
||||||
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
|
||||||
|
setPicoTheme: (theme) => set({ picoTheme: theme }),
|
||||||
setGitDiffPreferences: (preferences) => set({ gitDiffPreferences: preferences }),
|
setGitDiffPreferences: (preferences) => set({ gitDiffPreferences: preferences }),
|
||||||
|
|
||||||
// Post Actions
|
// Post Actions
|
||||||
@@ -376,6 +380,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
selectedPostId: state.selectedPostId,
|
selectedPostId: state.selectedPostId,
|
||||||
selectedMediaId: state.selectedMediaId,
|
selectedMediaId: state.selectedMediaId,
|
||||||
preferredEditorMode: state.preferredEditorMode,
|
preferredEditorMode: state.preferredEditorMode,
|
||||||
|
picoTheme: state.picoTheme,
|
||||||
gitDiffPreferences: state.gitDiffPreferences,
|
gitDiffPreferences: state.gitDiffPreferences,
|
||||||
// Tabs are persisted here for now (project-specific persistence handled separately)
|
// Tabs are persisted here for now (project-specific persistence handled separately)
|
||||||
tabs: state.tabs,
|
tabs: state.tabs,
|
||||||
@@ -393,6 +398,7 @@ export const useAppStore = create<AppState>()(
|
|||||||
activeTabId: persistedState.activeTabId || null,
|
activeTabId: persistedState.activeTabId || null,
|
||||||
panelActiveTab: persistedState.panelActiveTab || current.panelActiveTab,
|
panelActiveTab: persistedState.panelActiveTab || current.panelActiveTab,
|
||||||
dirtyPosts: new Set(persistedState.dirtyPosts || []),
|
dirtyPosts: new Set(persistedState.dirtyPosts || []),
|
||||||
|
picoTheme: persistedState.picoTheme || current.picoTheme,
|
||||||
gitDiffPreferences: persistedState.gitDiffPreferences || current.gitDiffPreferences,
|
gitDiffPreferences: persistedState.gitDiffPreferences || current.gitDiffPreferences,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
70
src/renderer/utils/picoTheme.ts
Normal file
70
src/renderer/utils/picoTheme.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { PICO_THEME_NAMES, sanitizePicoTheme, type PicoThemeName } from '../../main/shared/picoThemes';
|
||||||
|
|
||||||
|
export { PICO_THEME_NAMES, type PicoThemeName };
|
||||||
|
|
||||||
|
export interface PicoThemePreviewToken {
|
||||||
|
lightBackground: string;
|
||||||
|
darkBackground: string;
|
||||||
|
lightPrimary: string;
|
||||||
|
darkPrimary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PICO_THEME_PREVIEW_TOKENS: Record<PicoThemeName, PicoThemePreviewToken> = {
|
||||||
|
amber: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#876400', darkPrimary: '#c79400' },
|
||||||
|
blue: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#2060df', darkPrimary: '#8999f9' },
|
||||||
|
cyan: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#047878', darkPrimary: '#0ab1b1' },
|
||||||
|
fuchsia: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#c1208b', darkPrimary: '#f869bf' },
|
||||||
|
green: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#33790f', darkPrimary: '#4eb31b' },
|
||||||
|
grey: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#6a6a6a', darkPrimary: '#9e9e9e' },
|
||||||
|
indigo: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#655cd6', darkPrimary: '#a294e5' },
|
||||||
|
jade: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#007a50', darkPrimary: '#00b478' },
|
||||||
|
lime: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#577400', darkPrimary: '#82ab00' },
|
||||||
|
orange: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#bd3c13', darkPrimary: '#f56b3d' },
|
||||||
|
pink: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#c72259', darkPrimary: '#f7708e' },
|
||||||
|
pumpkin: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#9c5900', darkPrimary: '#e48500' },
|
||||||
|
purple: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#aa40bf', darkPrimary: '#d47de4' },
|
||||||
|
red: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#c52f21', darkPrimary: '#f17961' },
|
||||||
|
sand: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#6e6a60', darkPrimary: '#a39e8f' },
|
||||||
|
slate: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#5d6b89', darkPrimary: '#909ebe' },
|
||||||
|
violet: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#8352c5', darkPrimary: '#b290d9' },
|
||||||
|
yellow: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#756b00', darkPrimary: '#ad9f00' },
|
||||||
|
zinc: { lightBackground: '#fff', darkBackground: 'rgb(19, 22.5, 30.5)', lightPrimary: '#646b79', darkPrimary: '#969eaf' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeStylesheetLoaders: Record<PicoThemeName, () => Promise<unknown>> = {
|
||||||
|
amber: () => import('@picocss/pico/css/pico.conditional.amber.min.css'),
|
||||||
|
blue: () => import('@picocss/pico/css/pico.conditional.blue.min.css'),
|
||||||
|
cyan: () => import('@picocss/pico/css/pico.conditional.cyan.min.css'),
|
||||||
|
fuchsia: () => import('@picocss/pico/css/pico.conditional.fuchsia.min.css'),
|
||||||
|
green: () => import('@picocss/pico/css/pico.conditional.green.min.css'),
|
||||||
|
grey: () => import('@picocss/pico/css/pico.conditional.grey.min.css'),
|
||||||
|
indigo: () => import('@picocss/pico/css/pico.conditional.indigo.min.css'),
|
||||||
|
jade: () => import('@picocss/pico/css/pico.conditional.jade.min.css'),
|
||||||
|
lime: () => import('@picocss/pico/css/pico.conditional.lime.min.css'),
|
||||||
|
orange: () => import('@picocss/pico/css/pico.conditional.orange.min.css'),
|
||||||
|
pink: () => import('@picocss/pico/css/pico.conditional.pink.min.css'),
|
||||||
|
pumpkin: () => import('@picocss/pico/css/pico.conditional.pumpkin.min.css'),
|
||||||
|
purple: () => import('@picocss/pico/css/pico.conditional.purple.min.css'),
|
||||||
|
red: () => import('@picocss/pico/css/pico.conditional.red.min.css'),
|
||||||
|
sand: () => import('@picocss/pico/css/pico.conditional.sand.min.css'),
|
||||||
|
slate: () => import('@picocss/pico/css/pico.conditional.slate.min.css'),
|
||||||
|
violet: () => import('@picocss/pico/css/pico.conditional.violet.min.css'),
|
||||||
|
yellow: () => import('@picocss/pico/css/pico.conditional.yellow.min.css'),
|
||||||
|
zinc: () => import('@picocss/pico/css/pico.conditional.zinc.min.css'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadedThemes = new Set<PicoThemeName>();
|
||||||
|
|
||||||
|
export function getRendererPicoTheme(theme: unknown): PicoThemeName {
|
||||||
|
return sanitizePicoTheme(theme) ?? 'slate';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureRendererPicoThemeStylesheet(theme: PicoThemeName): Promise<void> {
|
||||||
|
if (loadedThemes.has(theme)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = themeStylesheetLoaders[theme];
|
||||||
|
await loader();
|
||||||
|
loadedThemes.add(theme);
|
||||||
|
}
|
||||||
@@ -580,6 +580,33 @@ describe('MetaEngine', () => {
|
|||||||
expect(metadata?.defaultAuthor).toBe('Loaded Author');
|
expect(metadata?.defaultAuthor).toBe('Loaded Author');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should persist picoTheme to filesystem', async () => {
|
||||||
|
await metaEngine.setProjectMetadata({
|
||||||
|
name: 'Styled Project',
|
||||||
|
picoTheme: 'slate',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
|
const content = mockFiles.get(projectPath);
|
||||||
|
const parsed = JSON.parse(content!);
|
||||||
|
expect(parsed.picoTheme).toBe('slate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load picoTheme from filesystem', async () => {
|
||||||
|
const metaDir = metaEngine.getMetaDir();
|
||||||
|
const projectPath = normalizePath(`${metaDir}/project.json`);
|
||||||
|
mockFiles.set(projectPath, JSON.stringify({
|
||||||
|
name: 'Loaded Project',
|
||||||
|
picoTheme: 'zinc',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await metaEngine.loadProjectMetadata();
|
||||||
|
|
||||||
|
const metadata = await metaEngine.getProjectMetadata();
|
||||||
|
expect((metadata as any)?.picoTheme).toBe('zinc');
|
||||||
|
});
|
||||||
|
|
||||||
it('should set and get maxPostsPerPage in project metadata', async () => {
|
it('should set and get maxPostsPerPage in project metadata', async () => {
|
||||||
await metaEngine.setProjectMetadata({
|
await metaEngine.setProjectMetadata({
|
||||||
name: 'My Blog',
|
name: 'My Blog',
|
||||||
|
|||||||
@@ -196,6 +196,60 @@ describe('PreviewServer', () => {
|
|||||||
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
|
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses selected pico theme stylesheet from project metadata', async () => {
|
||||||
|
server = new PreviewServer({
|
||||||
|
postEngine: makeEngine([makePost()]),
|
||||||
|
settingsEngine: {
|
||||||
|
setProjectContext: vi.fn(),
|
||||||
|
async getProjectMetadata() {
|
||||||
|
return {
|
||||||
|
maxPostsPerPage: 50,
|
||||||
|
picoTheme: 'slate',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start(0);
|
||||||
|
|
||||||
|
const rootResponse = await fetch(`${server.getBaseUrl()}/`);
|
||||||
|
expect(rootResponse.status).toBe(200);
|
||||||
|
const rootHtml = await rootResponse.text();
|
||||||
|
|
||||||
|
expect(rootHtml).toContain('href="/assets/pico.slate.min.css"');
|
||||||
|
expect(rootHtml).not.toContain('href="/assets/pico.min.css"');
|
||||||
|
|
||||||
|
const themedCss = await fetch(`${server.getBaseUrl()}/assets/pico.slate.min.css`);
|
||||||
|
expect(themedCss.status).toBe(200);
|
||||||
|
expect(themedCss.headers.get('content-type')).toContain('text/css');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports preview mode override for style preview route', async () => {
|
||||||
|
server = new PreviewServer({
|
||||||
|
postEngine: makeEngine([makePost()]),
|
||||||
|
settingsEngine: {
|
||||||
|
setProjectContext: vi.fn(),
|
||||||
|
async getProjectMetadata() {
|
||||||
|
return {
|
||||||
|
maxPostsPerPage: 50,
|
||||||
|
picoTheme: 'slate',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.start(0);
|
||||||
|
|
||||||
|
const response = await fetch(`${server.getBaseUrl()}/__style-preview?theme=slate&mode=dark`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
expect(html).toContain('<html lang="en" data-theme="dark">');
|
||||||
|
expect(html).toContain('href="/assets/pico.slate.min.css"');
|
||||||
|
});
|
||||||
|
|
||||||
it('limits list routes to 50 posts', async () => {
|
it('limits list routes to 50 posts', async () => {
|
||||||
const posts = Array.from({ length: 60 }).map((_, index) =>
|
const posts = Array.from({ length: 60 }).map((_, index) =>
|
||||||
makePost({
|
makePost({
|
||||||
@@ -279,7 +333,7 @@ describe('PreviewServer', () => {
|
|||||||
expect(separatorCount).toBe(1);
|
expect(separatorCount).toBe(1);
|
||||||
|
|
||||||
expect(html).toContain('.archive-day-separator { position: relative; height: 2px;');
|
expect(html).toContain('.archive-day-separator { position: relative; height: 2px;');
|
||||||
expect(html).toContain('color: var(--color);');
|
expect(html).toContain('color: var(--pico-color, var(--color));');
|
||||||
expect(html).toContain('border-top: 1px solid currentColor;');
|
expect(html).toContain('border-top: 1px solid currentColor;');
|
||||||
expect(html).toContain('opacity: .18;');
|
expect(html).toContain('opacity: .18;');
|
||||||
expect(html).toContain('.archive-day-separator::before');
|
expect(html).toContain('.archive-day-separator::before');
|
||||||
|
|||||||
@@ -156,4 +156,26 @@ describe('Pages shortcut UI', () => {
|
|||||||
expect(wrappers.length).toBeGreaterThanOrEqual(2);
|
expect(wrappers.length).toBeGreaterThanOrEqual(2);
|
||||||
expect((wrappers[0] as HTMLElement).style.display).toBe('flex');
|
expect((wrappers[0] as HTMLElement).style.display).toBe('flex');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('opens style tab from settings sidebar navigation', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
sidebarVisible: true,
|
||||||
|
tabs: [],
|
||||||
|
activeTabId: null,
|
||||||
|
});
|
||||||
|
useAppStore.getState().setActiveView('settings');
|
||||||
|
|
||||||
|
render(<Sidebar />);
|
||||||
|
|
||||||
|
const styleButton = await screen.findByRole('button', { name: /style/i });
|
||||||
|
styleButton.click();
|
||||||
|
|
||||||
|
const state = useAppStore.getState();
|
||||||
|
expect(state.tabs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: 'style', id: 'style' }),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(state.activeTabId).toBe('style');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
28
tests/renderer/components/StatusBar.test.tsx
Normal file
28
tests/renderer/components/StatusBar.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { StatusBar } from '../../../src/renderer/components/StatusBar/StatusBar';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
vi.mock('../../../src/renderer/components/ProjectSelector', () => ({
|
||||||
|
ProjectSelector: () => <div data-testid="project-selector">Project</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('StatusBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useAppStore.setState({
|
||||||
|
media: [],
|
||||||
|
tasks: [],
|
||||||
|
selectedPostId: null,
|
||||||
|
totalPosts: 0,
|
||||||
|
picoTheme: 'slate',
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the currently applied theme', () => {
|
||||||
|
render(<StatusBar />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Theme: slate')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
78
tests/renderer/components/StyleView.test.tsx
Normal file
78
tests/renderer/components/StyleView.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { StyleView } from '../../../src/renderer/components/StyleView/StyleView';
|
||||||
|
import { useAppStore } from '../../../src/renderer/store';
|
||||||
|
|
||||||
|
describe('StyleView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
useAppStore.setState({
|
||||||
|
activeProject: {
|
||||||
|
id: 'project-1',
|
||||||
|
name: 'Test Project',
|
||||||
|
slug: 'test-project',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
picoTheme: 'slate',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
(window as any).electronAPI = {
|
||||||
|
...(window as any).electronAPI,
|
||||||
|
meta: {
|
||||||
|
...(window as any).electronAPI?.meta,
|
||||||
|
getProjectMetadata: vi.fn().mockResolvedValue({ picoTheme: 'slate' }),
|
||||||
|
updateProjectMetadata: vi.fn().mockResolvedValue({ picoTheme: 'blue' }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets users select a theme and apply it', async () => {
|
||||||
|
render(<StyleView />);
|
||||||
|
|
||||||
|
const blueButton = await screen.findByRole('button', { name: /blue/i });
|
||||||
|
fireEvent.click(blueButton);
|
||||||
|
|
||||||
|
const applyButton = screen.getByRole('button', { name: /apply theme/i });
|
||||||
|
fireEvent.click(applyButton);
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.meta.updateProjectMetadata).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ picoTheme: 'blue' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the style preview iframe for top posts with selected theme', async () => {
|
||||||
|
render(<StyleView />);
|
||||||
|
|
||||||
|
const iframe = await screen.findByTitle('Theme preview');
|
||||||
|
expect(iframe.getAttribute('src')).toContain('/__style-preview');
|
||||||
|
expect(iframe.getAttribute('src')).toContain('theme=slate');
|
||||||
|
expect(iframe.getAttribute('src')).toContain('mode=auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets users force dark/light mode for preview iframe', async () => {
|
||||||
|
render(<StyleView />);
|
||||||
|
|
||||||
|
const modeSelect = await screen.findByLabelText(/preview mode/i);
|
||||||
|
fireEvent.change(modeSelect, { target: { value: 'dark' } });
|
||||||
|
|
||||||
|
const iframe = await screen.findByTitle('Theme preview');
|
||||||
|
expect(iframe.getAttribute('src')).toContain('mode=dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders each theme swatch with accent, light, and dark tones', async () => {
|
||||||
|
const { container } = render(<StyleView />);
|
||||||
|
|
||||||
|
await screen.findByRole('button', { name: /blue/i });
|
||||||
|
|
||||||
|
const blueSwatch = container.querySelector('.style-theme-swatch-blue');
|
||||||
|
expect(blueSwatch).not.toBeNull();
|
||||||
|
|
||||||
|
const tones = blueSwatch?.querySelectorAll('.style-theme-tone') ?? [];
|
||||||
|
expect(tones.length).toBe(3);
|
||||||
|
expect(blueSwatch?.textContent).toContain('Blue');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -86,4 +86,15 @@ describe('TabBar', () => {
|
|||||||
|
|
||||||
expect(container.querySelector('.tab-bar')).toBeNull();
|
expect(container.querySelector('.tab-bar')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders style tab label', async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
tabs: [{ type: 'style', id: 'style', isTransient: false }],
|
||||||
|
activeTabId: 'style',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<TabBar />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Style')).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ describe('documentation structure and presentation', () => {
|
|||||||
);
|
);
|
||||||
const source = readFileSync(viewPath, 'utf8');
|
const source = readFileSync(viewPath, 'utf8');
|
||||||
|
|
||||||
expect(source).toContain('@picocss/pico/css/pico.conditional.slate.min.css');
|
expect(source).toContain('ensureRendererPicoThemeStylesheet');
|
||||||
|
expect(source).toContain('getRendererPicoTheme');
|
||||||
expect(source).toContain('className="documentation-content markdown-body pico"');
|
expect(source).toContain('className="documentation-content markdown-body pico"');
|
||||||
expect(source).toContain('data-theme="auto"');
|
expect(source).toContain('data-theme="auto"');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -343,6 +343,29 @@ describe('Tab Management', () => {
|
|||||||
|
|
||||||
expect(getStore().tabs).toHaveLength(1);
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should open style as a dedicated tab', () => {
|
||||||
|
getStore().openTab({ type: 'style', id: 'style', isTransient: false });
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
|
expect(getStore().tabs[0].type).toBe('style');
|
||||||
|
expect(getStore().activeTabId).toBe('style');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore style tab from persisted state', () => {
|
||||||
|
const tabState = {
|
||||||
|
tabs: [
|
||||||
|
{ type: 'style' as const, id: 'style', isTransient: false },
|
||||||
|
],
|
||||||
|
activeTabId: 'style',
|
||||||
|
};
|
||||||
|
|
||||||
|
getStore().restoreTabState(tabState);
|
||||||
|
|
||||||
|
expect(getStore().tabs).toHaveLength(1);
|
||||||
|
expect(getStore().tabs[0].type).toBe('style');
|
||||||
|
expect(getStore().activeTabId).toBe('style');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Multiple Tab Types', () => {
|
describe('Multiple Tab Types', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user