feat: better previews and consistent previews
This commit is contained in:
@@ -63,6 +63,25 @@ const PREVIEW_ASSETS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const PREVIEW_IMAGE_ASSETS = {
|
||||||
|
'prev.png': {
|
||||||
|
modulePath: 'lightbox2/dist/images/prev.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
},
|
||||||
|
'next.png': {
|
||||||
|
modulePath: 'lightbox2/dist/images/next.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
},
|
||||||
|
'close.png': {
|
||||||
|
modulePath: 'lightbox2/dist/images/close.png',
|
||||||
|
contentType: 'image/png',
|
||||||
|
},
|
||||||
|
'loading.gif': {
|
||||||
|
modulePath: 'lightbox2/dist/images/loading.gif',
|
||||||
|
contentType: 'image/gif',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
function clampMaxPostsPerPage(value: unknown): number {
|
function clampMaxPostsPerPage(value: unknown): number {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
return DEFAULT_MAX_POSTS_PER_PAGE;
|
return DEFAULT_MAX_POSTS_PER_PAGE;
|
||||||
@@ -415,6 +434,12 @@ export class PreviewServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageAsset = await this.resolveImageAsset(pathname);
|
||||||
|
if (imageAsset) {
|
||||||
|
this.respondAsset(res, imageAsset.contentType, imageAsset.body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir);
|
const mediaAsset = await this.resolveMediaAsset(pathname, context.dataDir);
|
||||||
if (mediaAsset) {
|
if (mediaAsset) {
|
||||||
this.respondAsset(res, mediaAsset.contentType, mediaAsset.body);
|
this.respondAsset(res, mediaAsset.contentType, mediaAsset.body);
|
||||||
@@ -705,6 +730,27 @@ export class PreviewServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveImageAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||||
|
const match = pathname.match(/^\/images\/([^/]+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const assetName = match[1] as keyof typeof PREVIEW_IMAGE_ASSETS;
|
||||||
|
const assetDefinition = PREVIEW_IMAGE_ASSETS[assetName];
|
||||||
|
if (!assetDefinition) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const absolutePath = require.resolve(assetDefinition.modulePath);
|
||||||
|
const body = await readFile(absolutePath);
|
||||||
|
return {
|
||||||
|
contentType: assetDefinition.contentType,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PreviewServer] Failed to read image asset: ${assetDefinition.modulePath}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> {
|
private async resolveMediaAsset(pathname: string, dataDir?: string): Promise<{ contentType: string; body: Buffer } | null> {
|
||||||
const match = pathname.match(/^\/media\/(.+)$/);
|
const match = pathname.match(/^\/media\/(.+)$/);
|
||||||
if (!match || !dataDir) return null;
|
if (!match || !dataDir) return null;
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ function safeHandle(channel: string, handler: (...args: any[]) => Promise<any>):
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCanonicalPreviewPath(createdAt: Date, slug: string): string {
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
return `/${year}/${month}/${day}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||||
|
if (post.createdAt instanceof Date) {
|
||||||
|
return post.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(post.createdAt);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
// ============ Git Handlers ============
|
// ============ Git Handlers ============
|
||||||
|
|
||||||
@@ -256,6 +272,19 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getPost(id);
|
return engine.getPost(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
safeHandle('posts:getPreviewUrl', async (_, id: string) => {
|
||||||
|
const engine = getPostEngine();
|
||||||
|
const post = await engine.getPost(id);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = resolvePostCreatedAt(post);
|
||||||
|
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||||
|
return `http://127.0.0.1:4123${canonicalPath}`;
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('posts:getAll', async (_, options?: PaginationOptions) => {
|
safeHandle('posts:getAll', async (_, options?: PaginationOptions) => {
|
||||||
const engine = getPostEngine();
|
const engine = getPostEngine();
|
||||||
return engine.getAllPosts(options);
|
return engine.getAllPosts(options);
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea
|
|||||||
import { media } from './database/schema';
|
import { media } from './database/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { getMediaEngine } from './engine/MediaEngine';
|
import { getMediaEngine } from './engine/MediaEngine';
|
||||||
|
import { getPostEngine } from './engine/PostEngine';
|
||||||
import { PreviewServer } from './engine/PreviewServer';
|
import { PreviewServer } from './engine/PreviewServer';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let previewServer: PreviewServer | null = null;
|
let previewServer: PreviewServer | null = null;
|
||||||
|
let activePreviewPostId: string | null = null;
|
||||||
const PREVIEW_SERVER_PORT = 4123;
|
const PREVIEW_SERVER_PORT = 4123;
|
||||||
|
const BLOG_PREVIEW_POST_MENU_ID = 'blog.previewPost';
|
||||||
|
|
||||||
// Check if dev server is likely running (only in development)
|
// Check if dev server is likely running (only in development)
|
||||||
const isDev = process.env.NODE_ENV === 'development';
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
@@ -109,6 +112,42 @@ async function openPreviewInBrowser(): Promise<void> {
|
|||||||
await shell.openExternal(`${previewServer.getBaseUrl()}/`);
|
await shell.openExternal(`${previewServer.getBaseUrl()}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildCanonicalPostPath(createdAt: Date, slug: string): string {
|
||||||
|
const year = createdAt.getFullYear();
|
||||||
|
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(createdAt.getDate()).padStart(2, '0');
|
||||||
|
return `/${year}/${month}/${day}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewPostMenuEnabled(enabled: boolean): void {
|
||||||
|
const appMenu = Menu.getApplicationMenu();
|
||||||
|
const previewPostMenuItem = appMenu?.getMenuItemById(BLOG_PREVIEW_POST_MENU_ID);
|
||||||
|
if (previewPostMenuItem) {
|
||||||
|
previewPostMenuItem.enabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openActivePostPreviewInBrowser(): Promise<void> {
|
||||||
|
if (!activePreviewPostId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postEngine = getPostEngine();
|
||||||
|
const post = await postEngine.getPost(activePreviewPostId);
|
||||||
|
if (!post) {
|
||||||
|
setPreviewPostMenuEnabled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewServer) {
|
||||||
|
previewServer = new PreviewServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
await previewServer.start(PREVIEW_SERVER_PORT);
|
||||||
|
const canonicalPath = buildCanonicalPostPath(post.createdAt, post.slug);
|
||||||
|
await shell.openExternal(`${previewServer.getBaseUrl()}${canonicalPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function startPreviewServerOnAppStart(): Promise<void> {
|
async function startPreviewServerOnAppStart(): Promise<void> {
|
||||||
if (!previewServer) {
|
if (!previewServer) {
|
||||||
previewServer = new PreviewServer();
|
previewServer = new PreviewServer();
|
||||||
@@ -265,9 +304,15 @@ function createApplicationMenu(): Menu {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: 'Preview Post',
|
label: 'Preview Post',
|
||||||
|
id: BLOG_PREVIEW_POST_MENU_ID,
|
||||||
|
enabled: false,
|
||||||
accelerator: 'CmdOrCtrl+Shift+V',
|
accelerator: 'CmdOrCtrl+Shift+V',
|
||||||
click: () => {
|
click: async () => {
|
||||||
mainWindow?.webContents.send('menu:previewPost');
|
try {
|
||||||
|
await openActivePostPreviewInBrowser();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to preview active post in browser:', error);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
@@ -444,6 +489,11 @@ async function initialize(): Promise<void> {
|
|||||||
|
|
||||||
// Register IPC handlers
|
// Register IPC handlers
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
|
|
||||||
|
ipcMain.handle('app:setPreviewPostTarget', async (_, postId: string | null) => {
|
||||||
|
activePreviewPostId = typeof postId === 'string' && postId.length > 0 ? postId : null;
|
||||||
|
setPreviewPostMenuEnabled(Boolean(activePreviewPostId));
|
||||||
|
});
|
||||||
|
|
||||||
// Initialize and register chat handlers
|
// Initialize and register chat handlers
|
||||||
initializeChatHandlers(() => mainWindow);
|
initializeChatHandlers(() => mainWindow);
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
|
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
|
||||||
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
|
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
|
||||||
get: (id: string) => ipcRenderer.invoke('posts:get', id),
|
get: (id: string) => ipcRenderer.invoke('posts:get', id),
|
||||||
|
getPreviewUrl: (id: string) => ipcRenderer.invoke('posts:getPreviewUrl', id),
|
||||||
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
|
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
|
||||||
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
|
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
|
||||||
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
||||||
@@ -140,6 +141,7 @@ export const electronAPI: ElectronAPI = {
|
|||||||
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
|
||||||
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
|
||||||
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
|
||||||
|
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Meta (tags, categories, and project metadata)
|
// Meta (tags, categories, and project metadata)
|
||||||
|
|||||||
@@ -430,6 +430,7 @@ export interface ElectronAPI {
|
|||||||
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
|
||||||
delete: (id: string) => Promise<boolean>;
|
delete: (id: string) => Promise<boolean>;
|
||||||
get: (id: string) => Promise<PostData | null>;
|
get: (id: string) => Promise<PostData | null>;
|
||||||
|
getPreviewUrl: (id: string) => Promise<string | null>;
|
||||||
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
|
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
|
||||||
getByStatus: (status: string) => Promise<PostData[]>;
|
getByStatus: (status: string) => Promise<PostData[]>;
|
||||||
publish: (id: string) => Promise<PostData | null>;
|
publish: (id: string) => Promise<PostData | null>;
|
||||||
@@ -508,6 +509,7 @@ export interface ElectronAPI {
|
|||||||
selectFolder: (title?: string) => Promise<string | null>;
|
selectFolder: (title?: string) => Promise<string | null>;
|
||||||
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
||||||
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
|
||||||
|
setPreviewPostTarget: (postId: string | null) => Promise<void>;
|
||||||
};
|
};
|
||||||
meta: {
|
meta: {
|
||||||
getTags: () => Promise<string[]>;
|
getTags: () => Promise<string[]>;
|
||||||
|
|||||||
@@ -193,10 +193,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
margin-bottom: 8px;
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-mode-toggle {
|
.editor-mode-toggle {
|
||||||
@@ -233,7 +254,6 @@
|
|||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.15s;
|
transition: background-color 0.15s;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery-button:hover {
|
.gallery-button:hover {
|
||||||
@@ -261,107 +281,26 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 16px;
|
overflow: hidden;
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hydration loading overlay */
|
.editor-preview-frame {
|
||||||
.preview-hydrating-overlay {
|
width: 100%;
|
||||||
position: absolute;
|
height: 100%;
|
||||||
top: 0;
|
border: none;
|
||||||
left: 0;
|
background: var(--vscode-editor-background);
|
||||||
right: 0;
|
}
|
||||||
bottom: 0;
|
|
||||||
background: rgba(30, 30, 30, 0.85);
|
.editor-preview-loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 10;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-hydrating-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--vscode-foreground);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-hydrating-spinner {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 3px solid var(--vscode-panel-border);
|
|
||||||
border-top-color: var(--vscode-focusBorder);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview .preview-content {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview h1,
|
|
||||||
.editor-preview h2,
|
|
||||||
.editor-preview h3 {
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
color: var(--vscode-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview h1 { font-size: 2em; }
|
|
||||||
.editor-preview h2 { font-size: 1.5em; }
|
|
||||||
.editor-preview h3 { font-size: 1.25em; }
|
|
||||||
|
|
||||||
.editor-preview code {
|
|
||||||
background-color: var(--vscode-textCodeBlock-background);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: var(--vscode-editor-font-family);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview pre {
|
|
||||||
background-color: var(--vscode-textCodeBlock-background);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview pre code {
|
|
||||||
background: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview blockquote {
|
|
||||||
border-left: 3px solid var(--vscode-textBlockQuote-border);
|
|
||||||
padding-left: 16px;
|
|
||||||
margin-left: 0;
|
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-preview a {
|
|
||||||
color: var(--vscode-textLink-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview a:hover {
|
|
||||||
color: var(--vscode-textLink-activeForeground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-preview img {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-field-row {
|
.editor-field-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import { ImportAnalysisView } from '../ImportAnalysisView';
|
|||||||
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
||||||
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
||||||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||||||
import { parseMacros, getMacro } from '../../macros/registry';
|
|
||||||
import { InsertModal } from '../InsertModal';
|
import { InsertModal } from '../InsertModal';
|
||||||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||||||
import { marked } from 'marked';
|
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
/** Get display name for media: prefer title over originalName */
|
/** Get display name for media: prefer title over originalName */
|
||||||
@@ -116,380 +114,6 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render a macro synchronously for preview
|
|
||||||
const renderMacroSync = (name: string, params: Record<string, string>, postId?: string): string => {
|
|
||||||
const macro = getMacro(name);
|
|
||||||
if (!macro) {
|
|
||||||
return `<span class="macro-error">Unknown macro: ${name}</span>`;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = macro.render(params, { postId, isPreview: true });
|
|
||||||
// If it returns a promise, show loading state (shouldn't happen for gallery)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return `<div class="macro-loading">Loading ${name}...</div>`;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
return `<span class="macro-error">Error rendering ${name}</span>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Simple markdown to HTML converter for preview
|
|
||||||
export const markdownToHtml = (markdown: string, postId?: string): string => {
|
|
||||||
// First, render macros
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
let result = markdown;
|
|
||||||
|
|
||||||
// Replace macros from end to start to preserve positions
|
|
||||||
for (let i = macros.length - 1; i >= 0; i--) {
|
|
||||||
const macro = macros[i];
|
|
||||||
const rendered = renderMacroSync(macro.name, macro.params, postId);
|
|
||||||
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
return marked.parse(result, {
|
|
||||||
gfm: true,
|
|
||||||
breaks: false,
|
|
||||||
async: false,
|
|
||||||
}) as string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate gallery elements in the preview with actual linked media
|
|
||||||
*/
|
|
||||||
const hydrateGalleries = async (
|
|
||||||
container: HTMLElement,
|
|
||||||
postId: string,
|
|
||||||
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
|
|
||||||
) => {
|
|
||||||
const galleries = container.querySelectorAll('.macro-gallery[data-post-id]');
|
|
||||||
|
|
||||||
for (const gallery of galleries) {
|
|
||||||
const galleryPostId = gallery.getAttribute('data-post-id');
|
|
||||||
if (!galleryPostId || galleryPostId !== postId) continue;
|
|
||||||
|
|
||||||
const galleryContainer = gallery.querySelector('.gallery-container');
|
|
||||||
if (!galleryContainer) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Load linked media for this post
|
|
||||||
const linkedData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
|
|
||||||
|
|
||||||
if (!linkedData || linkedData.length === 0) {
|
|
||||||
galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to images only (media is nested in the link object)
|
|
||||||
const images = linkedData.filter(link => link.media?.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
if (images.length === 0) {
|
|
||||||
galleryContainer.innerHTML = '<div class="gallery-empty">No images linked to this post</div>';
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build gallery grid (column count is handled via CSS class on parent)
|
|
||||||
galleryContainer.innerHTML = images.map((link, index) => `
|
|
||||||
<div class="gallery-item" data-index="${index}">
|
|
||||||
<img
|
|
||||||
src="bds-media://${link.media.id}"
|
|
||||||
alt="${link.media.alt || link.media.originalName}"
|
|
||||||
title="${link.media.title || link.media.originalName}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Set up lightbox click handlers
|
|
||||||
const items = galleryContainer.querySelectorAll('.gallery-item');
|
|
||||||
const imageData = images.map(link => ({
|
|
||||||
src: `bds-media://${link.media.id}`,
|
|
||||||
alt: link.media.alt || link.media.originalName,
|
|
||||||
}));
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
item.addEventListener('click', () => onImageClick(index, imageData));
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to hydrate gallery:', error);
|
|
||||||
galleryContainer.innerHTML = '<div class="gallery-error">Failed to load gallery</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const FULL_MONTH_NAMES = [
|
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Track photo_archive hydration state to prevent duplicate runs
|
|
||||||
const photoArchiveHydratingCache = new WeakSet<HTMLElement>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrate photo_archive elements in the preview with actual media from the given year/month.
|
|
||||||
*/
|
|
||||||
const hydratePhotoArchive = async (
|
|
||||||
container: HTMLElement,
|
|
||||||
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
|
|
||||||
) => {
|
|
||||||
// Match both year-based and recent-based archives
|
|
||||||
const archives = container.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]');
|
|
||||||
|
|
||||||
if (archives.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're already hydrating (prevent duplicate runs)
|
|
||||||
if (photoArchiveHydratingCache.has(container)) {
|
|
||||||
console.log('[photo_archive] Skipping duplicate hydration for container');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
photoArchiveHydratingCache.add(container);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await doHydratePhotoArchive(container, onImageClick, archives);
|
|
||||||
} finally {
|
|
||||||
// Clear the hydrating flag after a delay to allow for content changes
|
|
||||||
setTimeout(() => photoArchiveHydratingCache.delete(container), 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal implementation of photo_archive hydration
|
|
||||||
*/
|
|
||||||
const doHydratePhotoArchive = async (
|
|
||||||
_container: HTMLElement,
|
|
||||||
onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
|
|
||||||
archives: NodeListOf<Element>
|
|
||||||
) => {
|
|
||||||
// Collect media for archive rendering based on current macros.
|
|
||||||
type ImageData = { id: string; originalName: string; alt?: string; mimeType: string; createdAt?: Date };
|
|
||||||
const archiveData: Array<{
|
|
||||||
element: Element;
|
|
||||||
mode: 'single-month' | 'full-year' | 'recent';
|
|
||||||
year?: number;
|
|
||||||
month?: number;
|
|
||||||
images?: ImageData[];
|
|
||||||
// Map key is "YYYY-MM" for recent mode, or month number (1-12) for year mode
|
|
||||||
monthlyImages?: Map<string | number, ImageData[]>;
|
|
||||||
showYearInLabel?: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
console.log(`[photo_archive] Processing ${archives.length} archive macro(s)`);
|
|
||||||
|
|
||||||
for (const archive of archives) {
|
|
||||||
const recentStr = archive.getAttribute('data-recent');
|
|
||||||
const yearStr = archive.getAttribute('data-year');
|
|
||||||
const monthStr = archive.getAttribute('data-month');
|
|
||||||
|
|
||||||
if (recentStr) {
|
|
||||||
// Recent mode: get last N months with images
|
|
||||||
const recentCount = parseInt(recentStr, 10) || 10;
|
|
||||||
console.log(`[photo_archive] Recent mode: fetching last ${recentCount} months with images`);
|
|
||||||
|
|
||||||
// Fetch all images (no filter)
|
|
||||||
const allMedia = await window.electronAPI?.media.filter({});
|
|
||||||
const allImages = (allMedia || []).filter(m => m.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
// Group by year-month and sort by most recent
|
|
||||||
const monthlyMap = new Map<string, ImageData[]>();
|
|
||||||
for (const img of allImages) {
|
|
||||||
if (!img.createdAt) continue;
|
|
||||||
const date = new Date(img.createdAt);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth() + 1; // 1-based
|
|
||||||
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
|
|
||||||
|
|
||||||
if (!monthlyMap.has(key)) {
|
|
||||||
monthlyMap.set(key, []);
|
|
||||||
}
|
|
||||||
monthlyMap.get(key)!.push(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by key descending (newest first) and take top N
|
|
||||||
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
|
|
||||||
const recentMonthlyImages = new Map<string, ImageData[]>();
|
|
||||||
|
|
||||||
for (const key of sortedKeys) {
|
|
||||||
const images = monthlyMap.get(key)!;
|
|
||||||
recentMonthlyImages.set(key, images);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
|
|
||||||
archiveData.push({
|
|
||||||
element: archive,
|
|
||||||
mode: 'recent',
|
|
||||||
monthlyImages: recentMonthlyImages,
|
|
||||||
showYearInLabel: true
|
|
||||||
});
|
|
||||||
console.log(`[photo_archive] Recent: ${totalImages} images across ${recentMonthlyImages.size} months`);
|
|
||||||
|
|
||||||
} else if (yearStr) {
|
|
||||||
const year = parseInt(yearStr, 10);
|
|
||||||
const month = monthStr ? parseInt(monthStr, 10) : undefined;
|
|
||||||
|
|
||||||
if (!year) continue;
|
|
||||||
|
|
||||||
if (month !== undefined) {
|
|
||||||
// Single month view
|
|
||||||
const mediaItems = await window.electronAPI?.media.filter({
|
|
||||||
year,
|
|
||||||
month: month - 1, // API uses 0-based month
|
|
||||||
});
|
|
||||||
const images = (mediaItems || []).filter(m => m.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
archiveData.push({ element: archive, mode: 'single-month', year, month, images });
|
|
||||||
console.log(`[photo_archive] Year ${year} month ${month}: ${images.length} images`);
|
|
||||||
} else {
|
|
||||||
// Full year view - collect all months, tracking which month each image belongs to
|
|
||||||
const monthlyImages = new Map<number, ImageData[]>();
|
|
||||||
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
const mediaItems = await window.electronAPI?.media.filter({
|
|
||||||
year,
|
|
||||||
month: m,
|
|
||||||
});
|
|
||||||
const images = (mediaItems || []).filter(item => item.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
if (images.length > 0) {
|
|
||||||
monthlyImages.set(m + 1, images); // Store with 1-based month key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
|
|
||||||
archiveData.push({ element: archive, mode: 'full-year', year, month: undefined, monthlyImages });
|
|
||||||
console.log(`[photo_archive] Year ${year}: ${totalImages} images across ${monthlyImages.size} months`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render galleries
|
|
||||||
for (const { element, mode, year, month, images, monthlyImages, showYearInLabel } of archiveData) {
|
|
||||||
const archiveContainer = element.querySelector('.photo-archive-container');
|
|
||||||
if (!archiveContainer) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Render the gallery
|
|
||||||
let html = '';
|
|
||||||
|
|
||||||
if (mode === 'single-month' && month !== undefined && images && year) {
|
|
||||||
// Single month view
|
|
||||||
if (images.length === 0) {
|
|
||||||
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${FULL_MONTH_NAMES[month - 1]} ${year}</div>`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
html = buildMonthGallery(month, year, images, onImageClick, false);
|
|
||||||
} else if (mode === 'recent' && monthlyImages) {
|
|
||||||
// Recent mode - keys are "YYYY-MM" strings
|
|
||||||
if (monthlyImages.size === 0) {
|
|
||||||
archiveContainer.innerHTML = `<div class="photo-archive-empty">No recent photos found</div>`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by key descending (newest first) - keys are "YYYY-MM" strings
|
|
||||||
const sortedEntries = Array.from(monthlyImages.entries())
|
|
||||||
.sort((a, b) => (b[0] as string).localeCompare(a[0] as string));
|
|
||||||
|
|
||||||
html = sortedEntries.map(([key, imgs]) => {
|
|
||||||
// Parse "YYYY-MM" to get year and month
|
|
||||||
const [yearStr, monthStr] = (key as string).split('-');
|
|
||||||
const entryYear = parseInt(yearStr, 10);
|
|
||||||
const entryMonth = parseInt(monthStr, 10);
|
|
||||||
return `<div class="photo-archive-month-wrapper">${buildMonthGallery(entryMonth, entryYear, imgs, onImageClick, true)}</div>`;
|
|
||||||
}).join('');
|
|
||||||
} else if (mode === 'full-year' && monthlyImages && year) {
|
|
||||||
// Full year view - keys are month numbers
|
|
||||||
if (monthlyImages.size === 0) {
|
|
||||||
archiveContainer.innerHTML = `<div class="photo-archive-empty">No photos found for ${year}</div>`;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort months ascending (January first)
|
|
||||||
const sortedMonths = Array.from(monthlyImages.entries())
|
|
||||||
.sort((a, b) => (a[0] as number) - (b[0] as number));
|
|
||||||
html = sortedMonths.map(([m, imgs]) =>
|
|
||||||
`<div class="photo-archive-month-wrapper">${buildMonthGallery(m as number, year, imgs, onImageClick, showYearInLabel || false)}</div>`
|
|
||||||
).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveContainer.innerHTML = html;
|
|
||||||
|
|
||||||
// Set up click handlers for all images
|
|
||||||
setupPhotoArchiveClickHandlers(archiveContainer, onImageClick);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to hydrate photo archive:', error);
|
|
||||||
archiveContainer.innerHTML = '<div class="photo-archive-error">Failed to load photo archive</div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[photo_archive] Hydration complete.');
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build HTML for a single month's gallery with rotated month label
|
|
||||||
* @param month - 1-based month number (1 = January)
|
|
||||||
* @param year - The year
|
|
||||||
* @param images - Array of image data
|
|
||||||
* @param _onImageClick - Click handler (unused in template, set up separately)
|
|
||||||
* @param showYear - Whether to include the year in the label (e.g., "January 2024")
|
|
||||||
*/
|
|
||||||
function buildMonthGallery(
|
|
||||||
month: number,
|
|
||||||
year: number,
|
|
||||||
images: { id: string; originalName: string; alt?: string }[],
|
|
||||||
_onImageClick: (index: number, images: { src: string; alt: string }[]) => void,
|
|
||||||
showYear: boolean = false
|
|
||||||
): string {
|
|
||||||
const monthName = FULL_MONTH_NAMES[month - 1];
|
|
||||||
const labelText = showYear ? `${monthName} ${year}` : monthName;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="photo-archive-month" data-month="${month}" data-year="${year}">
|
|
||||||
<div class="photo-archive-month-label">
|
|
||||||
<span>${labelText}</span>
|
|
||||||
</div>
|
|
||||||
<div class="photo-archive-gallery gallery-lightbox">
|
|
||||||
${images.map((img, index) => `
|
|
||||||
<div class="photo-archive-item" data-index="${index}" data-media-id="${img.id}">
|
|
||||||
<img
|
|
||||||
src="bds-media://${img.id}"
|
|
||||||
alt="${img.alt || img.originalName}"
|
|
||||||
title="${img.title || img.originalName}"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
`).join('')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up click handlers for photo archive gallery items
|
|
||||||
*/
|
|
||||||
function setupPhotoArchiveClickHandlers(
|
|
||||||
container: Element,
|
|
||||||
onImageClick: (index: number, images: { src: string; alt: string }[]) => void
|
|
||||||
) {
|
|
||||||
// Find all month galleries
|
|
||||||
const monthGalleries = container.querySelectorAll('.photo-archive-month');
|
|
||||||
|
|
||||||
monthGalleries.forEach(monthGallery => {
|
|
||||||
const items = monthGallery.querySelectorAll('.photo-archive-item');
|
|
||||||
const imageData = Array.from(items).map(item => {
|
|
||||||
const img = item.querySelector('img');
|
|
||||||
return {
|
|
||||||
src: img?.getAttribute('src') || '',
|
|
||||||
alt: img?.getAttribute('alt') || '',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
|
||||||
item.addEventListener('click', () => onImageClick(index, imageData));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
}
|
}
|
||||||
@@ -543,17 +167,13 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
|
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
const [hasPublishedVersion, setHasPublishedVersion] = useState(false);
|
||||||
const hydrationOverlayRef = useRef<HTMLDivElement>(null);
|
|
||||||
const isHydratingRef = useRef(false);
|
|
||||||
const previewContentRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
const [editorMode, setEditorMode] = useState<EditorMode>(preferredEditorMode);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
|
|
||||||
const [showPostSearch, setShowPostSearch] = useState(false);
|
const [showPostSearch, setShowPostSearch] = useState(false);
|
||||||
const [showMediaSearch, setShowMediaSearch] = useState(false);
|
const [showMediaSearch, setShowMediaSearch] = useState(false);
|
||||||
const editorRef = useRef<unknown>(null);
|
const editorRef = useRef<unknown>(null);
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const isDirty = checkIsDirty(postId);
|
const isDirty = checkIsDirty(postId);
|
||||||
|
|
||||||
@@ -607,67 +227,30 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
|
|
||||||
// Extract images from resolved content for lightbox
|
// Extract images from resolved content for lightbox
|
||||||
const images = useMarkdownImages(resolvedContent);
|
const images = useMarkdownImages(resolvedContent);
|
||||||
|
|
||||||
// Combine regular images with gallery images for lightbox
|
|
||||||
const allImages = useMemo(() => {
|
|
||||||
// If gallery images are set, use those; otherwise use extracted images
|
|
||||||
return galleryImages.length > 0 ? galleryImages : images;
|
|
||||||
}, [images, galleryImages]);
|
|
||||||
|
|
||||||
// Hydrate galleries and photo archives when in preview mode
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorMode !== 'preview' || !previewRef.current || !previewContentRef.current) return;
|
if (editorMode !== 'preview') return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setPreviewUrl(null);
|
||||||
// Helper to show/hide the overlay without triggering React re-render
|
|
||||||
const showOverlay = (show: boolean) => {
|
window.electronAPI?.posts.getPreviewUrl(postId)
|
||||||
if (hydrationOverlayRef.current) {
|
.then((url) => {
|
||||||
hydrationOverlayRef.current.style.display = show ? 'flex' : 'none';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set content immediately if not hydrating
|
|
||||||
// During hydration, we skip updating to preserve the hydrated DOM
|
|
||||||
if (!isHydratingRef.current) {
|
|
||||||
previewContentRef.current.innerHTML = markdownToHtml(resolvedContent, postId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to ensure DOM is updated
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
if (cancelled || !previewRef.current) return;
|
|
||||||
|
|
||||||
const lightboxHandler = (index: number, imgs: { src: string; alt: string }[]) => {
|
|
||||||
setGalleryImages(imgs);
|
|
||||||
setLightboxIndex(index);
|
|
||||||
setLightboxOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if there are photo_archive macros that need hydration
|
|
||||||
const hasPhotoArchives = previewRef.current.querySelectorAll('.macro-photo-archive[data-year], .macro-photo-archive[data-recent]').length > 0;
|
|
||||||
|
|
||||||
if (hasPhotoArchives) {
|
|
||||||
isHydratingRef.current = true;
|
|
||||||
showOverlay(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await hydrateGalleries(previewRef.current, postId, lightboxHandler);
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
await hydratePhotoArchive(previewRef.current, lightboxHandler);
|
setPreviewUrl(url);
|
||||||
}
|
}
|
||||||
} finally {
|
})
|
||||||
// Always reset hydration state when complete - the ref is global to the component
|
.catch((error) => {
|
||||||
isHydratingRef.current = false;
|
console.error('Failed to load post preview URL:', error);
|
||||||
showOverlay(false);
|
if (!cancelled) {
|
||||||
}
|
setPreviewUrl(null);
|
||||||
}, 100);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
};
|
||||||
}, [editorMode, postId, resolvedContent]);
|
}, [editorMode, postId]);
|
||||||
|
|
||||||
// Track latest values for auto-save on unmount/switch
|
// Track latest values for auto-save on unmount/switch
|
||||||
const pendingChangesRef = useRef<{
|
const pendingChangesRef = useRef<{
|
||||||
@@ -1264,57 +847,63 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
|
|
||||||
<div className="editor-body">
|
<div className="editor-body">
|
||||||
<div className="editor-toolbar">
|
<div className="editor-toolbar">
|
||||||
<label>Content</label>
|
<div className="editor-toolbar-left">
|
||||||
<div className="editor-mode-toggle">
|
<label>Content</label>
|
||||||
<button
|
|
||||||
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
|
||||||
onClick={() => handleEditorModeChange('wysiwyg')}
|
|
||||||
title="Visual editor"
|
|
||||||
>
|
|
||||||
Visual
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={editorMode === 'markdown' ? 'active' : ''}
|
|
||||||
onClick={() => handleEditorModeChange('markdown')}
|
|
||||||
title="Markdown source"
|
|
||||||
>
|
|
||||||
Markdown
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={editorMode === 'preview' ? 'active' : ''}
|
|
||||||
onClick={() => handleEditorModeChange('preview')}
|
|
||||||
title="Read-only preview"
|
|
||||||
>
|
|
||||||
Preview
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{images.length > 0 && (
|
<div className="editor-toolbar-center">
|
||||||
<button
|
<div className="editor-mode-toggle">
|
||||||
className="gallery-button"
|
<button
|
||||||
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
className={editorMode === 'wysiwyg' ? 'active' : ''}
|
||||||
title={`View ${images.length} image(s)`}
|
onClick={() => handleEditorModeChange('wysiwyg')}
|
||||||
>
|
title="Visual editor"
|
||||||
📷 {images.length}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{editorMode === 'markdown' && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
className="insert-post-link-button"
|
|
||||||
onClick={() => setShowPostSearch(true)}
|
|
||||||
title="Link to post (Ctrl+K)"
|
|
||||||
>
|
>
|
||||||
📝
|
Visual
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="insert-media-button"
|
className={editorMode === 'markdown' ? 'active' : ''}
|
||||||
onClick={() => setShowMediaSearch(true)}
|
onClick={() => handleEditorModeChange('markdown')}
|
||||||
title="Insert image from media library"
|
title="Markdown source"
|
||||||
>
|
>
|
||||||
🖼️
|
Markdown
|
||||||
</button>
|
</button>
|
||||||
</>
|
<button
|
||||||
)}
|
className={editorMode === 'preview' ? 'active' : ''}
|
||||||
|
onClick={() => handleEditorModeChange('preview')}
|
||||||
|
title="Read-only preview"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="editor-toolbar-right">
|
||||||
|
{images.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="gallery-button"
|
||||||
|
onClick={() => { setLightboxIndex(0); setLightboxOpen(true); }}
|
||||||
|
title={`View ${images.length} image(s)`}
|
||||||
|
>
|
||||||
|
📷 {images.length}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{editorMode === 'markdown' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="insert-post-link-button"
|
||||||
|
onClick={() => setShowPostSearch(true)}
|
||||||
|
title="Link to post (Ctrl+K)"
|
||||||
|
>
|
||||||
|
📝
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="insert-media-button"
|
||||||
|
onClick={() => setShowMediaSearch(true)}
|
||||||
|
title="Insert image from media library"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editorMode === 'wysiwyg' && (
|
{editorMode === 'wysiwyg' && (
|
||||||
@@ -1353,27 +942,26 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{editorMode === 'preview' && (
|
{editorMode === 'preview' && (
|
||||||
<div className="editor-preview markdown-body" ref={previewRef}>
|
<div className="editor-preview">
|
||||||
<div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}>
|
{previewUrl ? (
|
||||||
<div className="preview-hydrating-content">
|
<iframe
|
||||||
<div className="preview-hydrating-spinner" />
|
className="editor-preview-frame"
|
||||||
<span>Loading photo archive...</span>
|
src={previewUrl}
|
||||||
</div>
|
title="Post preview"
|
||||||
</div>
|
/>
|
||||||
<div
|
) : (
|
||||||
className="preview-content"
|
<div className="editor-preview-loading">Loading preview...</div>
|
||||||
ref={previewContentRef}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Lightbox for viewing images in content */}
|
{/* Lightbox for viewing images in content */}
|
||||||
<Lightbox
|
<Lightbox
|
||||||
images={allImages}
|
images={images}
|
||||||
initialIndex={lightboxIndex}
|
initialIndex={lightboxIndex}
|
||||||
isOpen={lightboxOpen}
|
isOpen={lightboxOpen}
|
||||||
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
|
onClose={() => { setLightboxOpen(false); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2197,6 +1785,13 @@ export const Editor: React.FC = () => {
|
|||||||
const showMetadataDiff = activeTab?.type === 'metadata-diff';
|
const showMetadataDiff = activeTab?.type === 'metadata-diff';
|
||||||
const showGitDiff = activeTab?.type === 'git-diff';
|
const showGitDiff = activeTab?.type === 'git-diff';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activePostId = activeTab?.type === 'post' ? activeTab.id : null;
|
||||||
|
window.electronAPI?.app.setPreviewPostTarget(activePostId).catch((error) => {
|
||||||
|
console.error('Failed to sync preview post target:', error);
|
||||||
|
});
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeView === 'posts' && selectedPostId && !isLoading) {
|
if (activeView === 'posts' && selectedPostId && !isLoading) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'self' http://127.0.0.1:4123; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: file: blob: bds-media: bds-thumb:; worker-src 'self' blob:; font-src 'self' data:;" />
|
||||||
<title>Blogging Desktop Server</title>
|
<title>Blogging Desktop Server</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -174,6 +174,14 @@ describe('PreviewServer', () => {
|
|||||||
const lightboxJsResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.js`);
|
const lightboxJsResponse = await fetch(`${server.getBaseUrl()}/assets/lightbox.min.js`);
|
||||||
expect(lightboxJsResponse.status).toBe(200);
|
expect(lightboxJsResponse.status).toBe(200);
|
||||||
expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript');
|
expect(lightboxJsResponse.headers.get('content-type')).toContain('application/javascript');
|
||||||
|
|
||||||
|
const lightboxPrevImageResponse = await fetch(`${server.getBaseUrl()}/images/prev.png`);
|
||||||
|
expect(lightboxPrevImageResponse.status).toBe(200);
|
||||||
|
expect(lightboxPrevImageResponse.headers.get('content-type')).toContain('image/png');
|
||||||
|
|
||||||
|
const lightboxLoadingImageResponse = await fetch(`${server.getBaseUrl()}/images/loading.gif`);
|
||||||
|
expect(lightboxLoadingImageResponse.status).toBe(200);
|
||||||
|
expect(lightboxLoadingImageResponse.headers.get('content-type')).toContain('image/gif');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('limits list routes to 50 posts', async () => {
|
it('limits list routes to 50 posts', async () => {
|
||||||
|
|||||||
@@ -114,4 +114,167 @@ describe('main bootstrap preview behavior', () => {
|
|||||||
expect(mockPreviewStart).toHaveBeenCalledWith(4123);
|
expect(mockPreviewStart).toHaveBeenCalledWith(4123);
|
||||||
expect(mockApp.whenReady).toHaveBeenCalled();
|
expect(mockApp.whenReady).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables Blog Preview Post only for active post tab and opens canonical URL', async () => {
|
||||||
|
const mockApp = {
|
||||||
|
name: 'bDS',
|
||||||
|
whenReady: vi.fn(() => Promise.resolve()),
|
||||||
|
on: vi.fn(),
|
||||||
|
quit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBrowserWindowGetAllWindows = vi.fn(() => [{ id: 1 }]);
|
||||||
|
|
||||||
|
class MockBrowserWindow {
|
||||||
|
static getAllWindows = mockBrowserWindowGetAllWindows;
|
||||||
|
|
||||||
|
loadURL = vi.fn();
|
||||||
|
loadFile = vi.fn();
|
||||||
|
on = vi.fn();
|
||||||
|
isDestroyed = vi.fn(() => false);
|
||||||
|
webContents = {
|
||||||
|
on: vi.fn(),
|
||||||
|
send: vi.fn(),
|
||||||
|
openDevTools: vi.fn(),
|
||||||
|
toggleDevTools: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const findMenuItemById = (template: any[], id: string): any | null => {
|
||||||
|
for (const item of template) {
|
||||||
|
if (item && item.id === id) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item?.submenu && Array.isArray(item.submenu)) {
|
||||||
|
const found = findMenuItemById(item.submenu, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let capturedTemplate: any[] = [];
|
||||||
|
const menuObject = {
|
||||||
|
getMenuItemById: (id: string) => findMenuItemById(capturedTemplate, id),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ipcMainHandle = vi.fn();
|
||||||
|
const shellOpenExternal = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
vi.doMock('electron', () => ({
|
||||||
|
app: mockApp,
|
||||||
|
BrowserWindow: MockBrowserWindow,
|
||||||
|
Menu: {
|
||||||
|
buildFromTemplate: vi.fn((template: any[]) => {
|
||||||
|
capturedTemplate = template;
|
||||||
|
return menuObject;
|
||||||
|
}),
|
||||||
|
setApplicationMenu: vi.fn(),
|
||||||
|
getApplicationMenu: vi.fn(() => menuObject),
|
||||||
|
},
|
||||||
|
ipcMain: {
|
||||||
|
on: vi.fn(),
|
||||||
|
handle: ipcMainHandle,
|
||||||
|
removeHandler: vi.fn(),
|
||||||
|
},
|
||||||
|
protocol: {
|
||||||
|
registerSchemesAsPrivileged: vi.fn(),
|
||||||
|
handle: vi.fn(),
|
||||||
|
},
|
||||||
|
net: {
|
||||||
|
fetch: vi.fn(),
|
||||||
|
},
|
||||||
|
shell: {
|
||||||
|
openExternal: shellOpenExternal,
|
||||||
|
openPath: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockPreviewStart = vi.fn().mockResolvedValue(4123);
|
||||||
|
const mockPreviewStop = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockPreviewGetBaseUrl = vi.fn(() => 'http://127.0.0.1:4123');
|
||||||
|
|
||||||
|
class MockPreviewServer {
|
||||||
|
start = mockPreviewStart;
|
||||||
|
stop = mockPreviewStop;
|
||||||
|
getBaseUrl = mockPreviewGetBaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/engine/PreviewServer', () => ({
|
||||||
|
PreviewServer: MockPreviewServer,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const postCreatedAt = new Date('2026-02-17T10:00:00.000Z');
|
||||||
|
const getPost = vi.fn().mockResolvedValue({
|
||||||
|
id: 'post-42',
|
||||||
|
slug: 'current-post',
|
||||||
|
createdAt: postCreatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/engine/PostEngine', () => ({
|
||||||
|
getPostEngine: vi.fn(() => ({
|
||||||
|
getPost,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/database', () => ({
|
||||||
|
getDatabase: vi.fn(() => ({
|
||||||
|
initializeLocal: vi.fn().mockResolvedValue(undefined),
|
||||||
|
close: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getLocal: vi.fn(() => ({
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => ({
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/ipc', () => ({
|
||||||
|
registerIpcHandlers: vi.fn(),
|
||||||
|
registerChatHandlers: vi.fn(),
|
||||||
|
initializeChatHandlers: vi.fn(),
|
||||||
|
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/database/schema', () => ({
|
||||||
|
media: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('drizzle-orm', () => ({
|
||||||
|
eq: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.doMock('../../src/main/engine/MediaEngine', () => ({
|
||||||
|
getMediaEngine: vi.fn(() => ({
|
||||||
|
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await import('../../src/main/main');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
|
const previewMenuItem = findMenuItemById(capturedTemplate, 'blog.previewPost');
|
||||||
|
expect(previewMenuItem).toBeTruthy();
|
||||||
|
expect(previewMenuItem.enabled).toBe(false);
|
||||||
|
|
||||||
|
const setPreviewTargetCall = ipcMainHandle.mock.calls.find((call: unknown[]) => call[0] === 'app:setPreviewPostTarget');
|
||||||
|
expect(setPreviewTargetCall).toBeTruthy();
|
||||||
|
const setPreviewTargetHandler = setPreviewTargetCall?.[1] as ((event: unknown, postId: string | null) => Promise<void>);
|
||||||
|
|
||||||
|
await setPreviewTargetHandler({}, 'post-42');
|
||||||
|
expect(previewMenuItem.enabled).toBe(true);
|
||||||
|
|
||||||
|
await previewMenuItem.click();
|
||||||
|
|
||||||
|
expect(getPost).toHaveBeenCalledWith('post-42');
|
||||||
|
expect(shellOpenExternal).toHaveBeenCalledWith('http://127.0.0.1:4123/2026/02/17/current-post');
|
||||||
|
|
||||||
|
await setPreviewTargetHandler({}, null);
|
||||||
|
expect(previewMenuItem.enabled).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -701,6 +701,29 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('posts:getPreviewUrl', () => {
|
||||||
|
it('should return canonical preview URL for an existing post', async () => {
|
||||||
|
mockPostEngine.getPost.mockResolvedValue(createMockPost({
|
||||||
|
id: 'post-1',
|
||||||
|
slug: 'my-post',
|
||||||
|
createdAt: new Date('2026-02-16T12:00:00.000Z'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await invokeHandler('posts:getPreviewUrl', 'post-1');
|
||||||
|
|
||||||
|
expect(mockPostEngine.getPost).toHaveBeenCalledWith('post-1');
|
||||||
|
expect(result).toBe('http://127.0.0.1:4123/2026/02/16/my-post');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when post does not exist', async () => {
|
||||||
|
mockPostEngine.getPost.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await invokeHandler('posts:getPreviewUrl', 'missing-post');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('posts:getAll', () => {
|
describe('posts:getAll', () => {
|
||||||
it('should return paginated posts from PostEngine', async () => {
|
it('should return paginated posts from PostEngine', async () => {
|
||||||
const mockPosts = [
|
const mockPosts = [
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { markdownToHtml } from '../../../src/renderer/components/Editor/Editor';
|
|
||||||
|
|
||||||
describe('Editor markdown preview rendering', () => {
|
|
||||||
it('renders continuous blockquote lines as a single blockquote paragraph (CommonMark softbreak behavior)', () => {
|
|
||||||
const markdown = [
|
|
||||||
'> Georg Bauer',
|
|
||||||
'> Am Krug 40',
|
|
||||||
'> 48151 Münster',
|
|
||||||
'> eMail: gb at rfc1437.de',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
const html = markdownToHtml(markdown, 'post-1');
|
|
||||||
|
|
||||||
expect((html.match(/<blockquote>/g) ?? []).length).toBe(1);
|
|
||||||
expect((html.match(/<p>/g) ?? []).length).toBe(1);
|
|
||||||
expect(html).not.toContain('<br');
|
|
||||||
expect(html).toContain('Georg Bauer');
|
|
||||||
expect(html).toContain('eMail: gb at rfc1437.de');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
import { render, act } from '@testing-library/react';
|
import { render, act, fireEvent } from '@testing-library/react';
|
||||||
|
|
||||||
let markdownUpdatedHandler: ((ctx: unknown, markdown: string, prevMarkdown: string) => void) | null = null;
|
let markdownUpdatedHandler: ((ctx: unknown, markdown: string, prevMarkdown: string) => void) | null = null;
|
||||||
|
|
||||||
@@ -161,6 +161,7 @@ describe('Editor visual mode persistence', () => {
|
|||||||
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
|
(window as any).electronAPI.posts.get = vi.fn().mockResolvedValue(createPost());
|
||||||
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
|
(window as any).electronAPI.posts.hasPublishedVersion = vi.fn().mockReturnValue(neverSettles);
|
||||||
(window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null);
|
(window as any).electronAPI.posts.update = vi.fn().mockResolvedValue(null);
|
||||||
|
(window as any).electronAPI.posts.getPreviewUrl = vi.fn().mockResolvedValue('http://127.0.0.1:4123/2026/02/16/test-post');
|
||||||
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
(window as any).electronAPI.meta.getCategories = vi.fn().mockReturnValue(neverSettles);
|
||||||
|
|
||||||
useAppStore.setState({
|
useAppStore.setState({
|
||||||
@@ -200,4 +201,46 @@ describe('Editor visual mode persistence', () => {
|
|||||||
unmount?.();
|
unmount?.();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses canonical preview server URL in preview mode iframe', async () => {
|
||||||
|
const { getByTitle, container } = render(<PostEditor postId="post-1" />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(getByTitle('Read-only preview'));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.posts.getPreviewUrl).toHaveBeenCalledWith('post-1');
|
||||||
|
|
||||||
|
const frame = container.querySelector('.editor-preview-frame') as HTMLIFrameElement | null;
|
||||||
|
expect(frame).not.toBeNull();
|
||||||
|
expect(frame?.getAttribute('src')).toBe('http://127.0.0.1:4123/2026/02/16/test-post');
|
||||||
|
|
||||||
|
expect(container.querySelector('.preview-content')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mode toggle in centered toolbar section', async () => {
|
||||||
|
const { container } = render(<PostEditor postId="post-1" />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const centerSection = container.querySelector('.editor-toolbar-center');
|
||||||
|
expect(centerSection).not.toBeNull();
|
||||||
|
|
||||||
|
const modeToggle = centerSection?.querySelector('.editor-mode-toggle');
|
||||||
|
expect(modeToggle).not.toBeNull();
|
||||||
|
|
||||||
|
const modeButtons = modeToggle?.querySelectorAll('button');
|
||||||
|
expect(modeButtons?.length).toBe(3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,628 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for photo_archive hydration logic
|
|
||||||
*
|
|
||||||
* Tests the actual hydration path used by Editor.tsx to verify
|
|
||||||
* that year/month parameters correctly filter images.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
import { JSDOM } from 'jsdom';
|
|
||||||
import photoArchiveMacro from '../../../src/renderer/macros/definitions/photo_archive';
|
|
||||||
import { parseMacros, getMacro, registerMacro, clearMacros } from '../../../src/renderer/macros/registry';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replicate the exact markdownToHtml and renderMacroSync from Editor.tsx
|
|
||||||
*/
|
|
||||||
function renderMacroSync(name: string, params: Record<string, string>, postId?: string): string {
|
|
||||||
const macro = getMacro(name);
|
|
||||||
if (!macro) {
|
|
||||||
return `<span class="macro-error">Unknown macro: ${name}</span>`;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = macro.render(params, { postId, isPreview: true });
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
return `<div class="macro-loading">Loading ${name}...</div>`;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
return `<span class="macro-error">Error rendering ${name}</span>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function markdownToHtml(markdown: string, postId?: string): string {
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
let result = markdown;
|
|
||||||
|
|
||||||
// Replace macros from end to start to preserve positions
|
|
||||||
for (let i = macros.length - 1; i >= 0; i--) {
|
|
||||||
const macro = macros[i];
|
|
||||||
const rendered = renderMacroSync(macro.name, macro.params, postId);
|
|
||||||
result = result.slice(0, macro.start) + rendered + result.slice(macro.end);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock media data: 6 months in 2020, 6 months in 2019, 1 image per month
|
|
||||||
function createMockMediaDatabase() {
|
|
||||||
const media: Array<{
|
|
||||||
id: string;
|
|
||||||
originalName: string;
|
|
||||||
mimeType: string;
|
|
||||||
createdAt: Date;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
// 2020: January through June (6 months)
|
|
||||||
for (let month = 0; month < 6; month++) {
|
|
||||||
media.push({
|
|
||||||
id: `img-2020-${month + 1}`,
|
|
||||||
originalName: `photo-2020-${month + 1}.jpg`,
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
createdAt: new Date(Date.UTC(2020, month, 15)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2019: July through December (6 months)
|
|
||||||
for (let month = 6; month < 12; month++) {
|
|
||||||
media.push({
|
|
||||||
id: `img-2019-${month + 1}`,
|
|
||||||
originalName: `photo-2019-${month + 1}.jpg`,
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
createdAt: new Date(Date.UTC(2019, month, 15)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return media;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate the media.filter API behavior from MediaEngine
|
|
||||||
function createMockMediaFilter(allMedia: ReturnType<typeof createMockMediaDatabase>) {
|
|
||||||
return async (filter: { year?: number; month?: number }) => {
|
|
||||||
let result = [...allMedia];
|
|
||||||
|
|
||||||
if (filter.year !== undefined) {
|
|
||||||
const startOfYear = new Date(Date.UTC(filter.year, 0, 1));
|
|
||||||
const endOfYear = new Date(Date.UTC(filter.year + 1, 0, 1));
|
|
||||||
result = result.filter(m => m.createdAt >= startOfYear && m.createdAt < endOfYear);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.month !== undefined && filter.year !== undefined) {
|
|
||||||
const startOfMonth = new Date(Date.UTC(filter.year, filter.month, 1));
|
|
||||||
const endOfMonth = new Date(Date.UTC(filter.year, filter.month + 1, 1));
|
|
||||||
result = result.filter(m => m.createdAt >= startOfMonth && m.createdAt < endOfMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the core hydration logic to test it
|
|
||||||
type ImageData = { id: string; originalName: string; mimeType: string; createdAt?: Date };
|
|
||||||
|
|
||||||
interface ArchiveResult {
|
|
||||||
mode: 'single-month' | 'full-year' | 'recent';
|
|
||||||
year?: number;
|
|
||||||
month?: number;
|
|
||||||
images?: ImageData[];
|
|
||||||
monthlyImages?: Map<string | number, ImageData[]>;
|
|
||||||
totalImages: number;
|
|
||||||
monthCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates the hydration logic from Editor.tsx doHydratePhotoArchive
|
|
||||||
*/
|
|
||||||
async function hydratePhotoArchive(
|
|
||||||
dataAttrs: { recent?: string; year?: string; month?: string },
|
|
||||||
mediaFilter: (filter: { year?: number; month?: number }) => Promise<ImageData[]>
|
|
||||||
): Promise<ArchiveResult> {
|
|
||||||
const { recent: recentStr, year: yearStr, month: monthStr } = dataAttrs;
|
|
||||||
|
|
||||||
if (recentStr) {
|
|
||||||
// Recent mode: get last N months with images
|
|
||||||
const recentCount = parseInt(recentStr, 10) || 10;
|
|
||||||
|
|
||||||
// Fetch all images (no filter)
|
|
||||||
const allMedia = await mediaFilter({});
|
|
||||||
const allImages = allMedia.filter(m => m.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
// Group by year-month and sort by most recent
|
|
||||||
const monthlyMap = new Map<string, ImageData[]>();
|
|
||||||
for (const img of allImages) {
|
|
||||||
if (!img.createdAt) continue;
|
|
||||||
const date = new Date(img.createdAt);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = date.getMonth() + 1; // 1-based
|
|
||||||
const key = `${year}-${String(month).padStart(2, '0')}`; // e.g. "2024-06"
|
|
||||||
|
|
||||||
if (!monthlyMap.has(key)) {
|
|
||||||
monthlyMap.set(key, []);
|
|
||||||
}
|
|
||||||
monthlyMap.get(key)!.push(img);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by key descending (newest first) and take top N
|
|
||||||
const sortedKeys = Array.from(monthlyMap.keys()).sort().reverse().slice(0, recentCount);
|
|
||||||
const recentMonthlyImages = new Map<string, ImageData[]>();
|
|
||||||
|
|
||||||
for (const key of sortedKeys) {
|
|
||||||
recentMonthlyImages.set(key, monthlyMap.get(key)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalImages = Array.from(recentMonthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
|
|
||||||
return {
|
|
||||||
mode: 'recent',
|
|
||||||
monthlyImages: recentMonthlyImages,
|
|
||||||
totalImages,
|
|
||||||
monthCount: recentMonthlyImages.size,
|
|
||||||
};
|
|
||||||
|
|
||||||
} else if (yearStr) {
|
|
||||||
const year = parseInt(yearStr, 10);
|
|
||||||
const month = monthStr ? parseInt(monthStr, 10) : undefined;
|
|
||||||
|
|
||||||
if (month !== undefined) {
|
|
||||||
// Single month view
|
|
||||||
const mediaItems = await mediaFilter({
|
|
||||||
year,
|
|
||||||
month: month - 1, // API uses 0-based month
|
|
||||||
});
|
|
||||||
const images = mediaItems.filter(m => m.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'single-month',
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
images,
|
|
||||||
totalImages: images.length,
|
|
||||||
monthCount: images.length > 0 ? 1 : 0,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Full year view - collect all months
|
|
||||||
const monthlyImages = new Map<number, ImageData[]>();
|
|
||||||
|
|
||||||
for (let m = 0; m < 12; m++) {
|
|
||||||
const mediaItems = await mediaFilter({
|
|
||||||
year,
|
|
||||||
month: m,
|
|
||||||
});
|
|
||||||
const images = mediaItems.filter(item => item.mimeType?.startsWith('image/'));
|
|
||||||
|
|
||||||
if (images.length > 0) {
|
|
||||||
monthlyImages.set(m + 1, images); // Store with 1-based month key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalImages = Array.from(monthlyImages.values()).reduce((sum, imgs) => sum + imgs.length, 0);
|
|
||||||
return {
|
|
||||||
mode: 'full-year',
|
|
||||||
year,
|
|
||||||
monthlyImages,
|
|
||||||
totalImages,
|
|
||||||
monthCount: monthlyImages.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('No valid data attributes provided');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('photo_archive hydration', () => {
|
|
||||||
let mockMedia: ReturnType<typeof createMockMediaDatabase>;
|
|
||||||
let mockMediaFilter: ReturnType<typeof createMockMediaFilter>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockMedia = createMockMediaDatabase();
|
|
||||||
mockMediaFilter = createMockMediaFilter(mockMedia);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('mock database setup', () => {
|
|
||||||
it('should have 12 images total', () => {
|
|
||||||
expect(mockMedia).toHaveLength(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have 6 images in 2020 (Jan-Jun)', async () => {
|
|
||||||
const images2020 = await mockMediaFilter({ year: 2020 });
|
|
||||||
expect(images2020).toHaveLength(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have 6 images in 2019 (Jul-Dec)', async () => {
|
|
||||||
const images2019 = await mockMediaFilter({ year: 2019 });
|
|
||||||
expect(images2019).toHaveLength(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have 1 image in Feb 2020', async () => {
|
|
||||||
const imagesFeb2020 = await mockMediaFilter({ year: 2020, month: 1 }); // 0-based month
|
|
||||||
expect(imagesFeb2020).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('recent mode (no parameters)', () => {
|
|
||||||
it('should return 10 months of images when data-recent="10"', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ recent: '10' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('recent');
|
|
||||||
// We have 12 months total, but recent=10 should give us 10 months
|
|
||||||
expect(result.monthCount).toBe(10);
|
|
||||||
expect(result.totalImages).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return months sorted newest first', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ recent: '10' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
const monthKeys = Array.from(result.monthlyImages!.keys()) as string[];
|
|
||||||
// First should be 2020-06, last should be 2019-09 (skipping Jul, Aug of 2019)
|
|
||||||
expect(monthKeys[0]).toBe('2020-06');
|
|
||||||
expect(monthKeys[monthKeys.length - 1]).toBe('2019-09');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not require post-media linking side effects during preview hydration', async () => {
|
|
||||||
const linkMany = vi.fn();
|
|
||||||
const unlinkMany = vi.fn();
|
|
||||||
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ recent: '10' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('recent');
|
|
||||||
expect(linkMany).not.toHaveBeenCalled();
|
|
||||||
expect(unlinkMany).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('year mode (year parameter only)', () => {
|
|
||||||
it('should return only 2019 images when year="2019"', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ year: '2019' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.year).toBe(2019);
|
|
||||||
expect(result.totalImages).toBe(6);
|
|
||||||
expect(result.monthCount).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return only 2020 images when year="2020"', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ year: '2020' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.year).toBe(2020);
|
|
||||||
expect(result.totalImages).toBe(6);
|
|
||||||
expect(result.monthCount).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should NOT use recent mode when year is provided', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ year: '2019' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should be full-year, NOT recent
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.mode).not.toBe('recent');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('year+month mode', () => {
|
|
||||||
it('should return 1 image for Feb 2020', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ year: '2020', month: '2' },
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('single-month');
|
|
||||||
expect(result.year).toBe(2020);
|
|
||||||
expect(result.month).toBe(2);
|
|
||||||
expect(result.totalImages).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return 0 images for a month with no images', async () => {
|
|
||||||
const result = await hydratePhotoArchive(
|
|
||||||
{ year: '2020', month: '12' }, // December 2020 has no images
|
|
||||||
mockMediaFilter
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.mode).toBe('single-month');
|
|
||||||
expect(result.totalImages).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('full flow: macro render → DOM → hydration', () => {
|
|
||||||
/**
|
|
||||||
* Helper to extract data attributes from rendered macro HTML
|
|
||||||
*/
|
|
||||||
function extractDataAttrsFromMacroHtml(html: string): { recent?: string; year?: string; month?: string } {
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const el = dom.window.document.querySelector('.macro-photo-archive');
|
|
||||||
if (!el) throw new Error('No .macro-photo-archive element found in HTML');
|
|
||||||
|
|
||||||
return {
|
|
||||||
recent: el.getAttribute('data-recent') || undefined,
|
|
||||||
year: el.getAttribute('data-year') || undefined,
|
|
||||||
month: el.getAttribute('data-month') || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should render data-recent when no params and hydrate to recent mode', async () => {
|
|
||||||
// Render macro with no parameters
|
|
||||||
const html = photoArchiveMacro.render({}, { postId: 'test-post', isPreview: true });
|
|
||||||
|
|
||||||
// Extract data attributes
|
|
||||||
const dataAttrs = extractDataAttrsFromMacroHtml(html);
|
|
||||||
|
|
||||||
// Should have data-recent, NOT data-year
|
|
||||||
expect(dataAttrs.recent).toBe('10');
|
|
||||||
expect(dataAttrs.year).toBeUndefined();
|
|
||||||
|
|
||||||
// Hydrate using these attributes
|
|
||||||
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
|
|
||||||
expect(result.mode).toBe('recent');
|
|
||||||
expect(result.monthCount).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render data-year when year param given and hydrate to full-year mode', async () => {
|
|
||||||
// Render macro with year="2019"
|
|
||||||
const html = photoArchiveMacro.render({ year: '2019' }, { postId: 'test-post', isPreview: true });
|
|
||||||
|
|
||||||
// Extract data attributes
|
|
||||||
const dataAttrs = extractDataAttrsFromMacroHtml(html);
|
|
||||||
|
|
||||||
// Should have data-year, NOT data-recent
|
|
||||||
expect(dataAttrs.year).toBe('2019');
|
|
||||||
expect(dataAttrs.recent).toBeUndefined();
|
|
||||||
|
|
||||||
// Hydrate using these attributes
|
|
||||||
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.year).toBe(2019);
|
|
||||||
expect(result.totalImages).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render data-year and data-month when both params given', async () => {
|
|
||||||
// Render macro with year="2020" month="2"
|
|
||||||
const html = photoArchiveMacro.render({ year: '2020', month: '2' }, { postId: 'test-post', isPreview: true });
|
|
||||||
|
|
||||||
// Extract data attributes
|
|
||||||
const dataAttrs = extractDataAttrsFromMacroHtml(html);
|
|
||||||
|
|
||||||
// Should have data-year and data-month, NOT data-recent
|
|
||||||
expect(dataAttrs.year).toBe('2020');
|
|
||||||
expect(dataAttrs.month).toBe('2');
|
|
||||||
expect(dataAttrs.recent).toBeUndefined();
|
|
||||||
|
|
||||||
// Hydrate using these attributes
|
|
||||||
const result = await hydratePhotoArchive(dataAttrs, mockMediaFilter);
|
|
||||||
expect(result.mode).toBe('single-month');
|
|
||||||
expect(result.year).toBe(2020);
|
|
||||||
expect(result.month).toBe(2);
|
|
||||||
expect(result.totalImages).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('BUG: year="2020" should NOT load recent images', async () => {
|
|
||||||
// This test verifies the bug the user reported
|
|
||||||
const htmlWithYear = photoArchiveMacro.render({ year: '2020' }, { postId: 'test-post', isPreview: true });
|
|
||||||
const htmlWithoutYear = photoArchiveMacro.render({}, { postId: 'test-post', isPreview: true });
|
|
||||||
|
|
||||||
const attrsWithYear = extractDataAttrsFromMacroHtml(htmlWithYear);
|
|
||||||
const attrsWithoutYear = extractDataAttrsFromMacroHtml(htmlWithoutYear);
|
|
||||||
|
|
||||||
// The attributes MUST be different
|
|
||||||
expect(attrsWithYear).not.toEqual(attrsWithoutYear);
|
|
||||||
|
|
||||||
// With year: should have data-year, NOT data-recent
|
|
||||||
expect(attrsWithYear.year).toBe('2020');
|
|
||||||
expect(attrsWithYear.recent).toBeUndefined();
|
|
||||||
|
|
||||||
// Without year: should have data-recent, NOT data-year
|
|
||||||
expect(attrsWithoutYear.recent).toBe('10');
|
|
||||||
expect(attrsWithoutYear.year).toBeUndefined();
|
|
||||||
|
|
||||||
// Hydrate both and verify different results
|
|
||||||
const resultWithYear = await hydratePhotoArchive(attrsWithYear, mockMediaFilter);
|
|
||||||
const resultWithoutYear = await hydratePhotoArchive(attrsWithoutYear, mockMediaFilter);
|
|
||||||
|
|
||||||
// With year=2020: should have 6 images (Jan-Jun 2020)
|
|
||||||
expect(resultWithYear.mode).toBe('full-year');
|
|
||||||
expect(resultWithYear.totalImages).toBe(6);
|
|
||||||
|
|
||||||
// Without year: should have 10 images (recent 10 months)
|
|
||||||
expect(resultWithoutYear.mode).toBe('recent');
|
|
||||||
expect(resultWithoutYear.totalImages).toBe(10);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('full flow from markdown: parseMacros → render → hydrate', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearMacros();
|
|
||||||
registerMacro(photoArchiveMacro);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to extract data attributes from rendered macro HTML
|
|
||||||
*/
|
|
||||||
function extractDataAttrsFromMacroHtml(html: string): { recent?: string; year?: string; month?: string } {
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const el = dom.window.document.querySelector('.macro-photo-archive');
|
|
||||||
if (!el) throw new Error('No .macro-photo-archive element found in HTML');
|
|
||||||
|
|
||||||
return {
|
|
||||||
recent: el.getAttribute('data-recent') || undefined,
|
|
||||||
year: el.getAttribute('data-year') || undefined,
|
|
||||||
month: el.getAttribute('data-month') || undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulates what Editor.tsx does: parse markdown → render macros → extract attrs → hydrate
|
|
||||||
*/
|
|
||||||
async function fullFlowFromMarkdown(markdown: string): Promise<ArchiveResult> {
|
|
||||||
// Step 1: Parse macros from markdown (like parseMacros in registry.ts)
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
expect(macros.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
const macro = macros[0];
|
|
||||||
expect(macro.name).toBe('photo_archive');
|
|
||||||
|
|
||||||
// Step 2: Get macro definition and render (like renderMacroSync in Editor.tsx)
|
|
||||||
const definition = getMacro(macro.name);
|
|
||||||
expect(definition).toBeDefined();
|
|
||||||
|
|
||||||
const html = definition!.render(macro.params, { postId: 'test-post', isPreview: true });
|
|
||||||
|
|
||||||
// Step 3: Parse HTML and extract data attributes (like querySelector in hydratePhotoArchive)
|
|
||||||
const dataAttrs = extractDataAttrsFromMacroHtml(html);
|
|
||||||
|
|
||||||
// Step 4: Hydrate using the extracted attributes
|
|
||||||
return hydratePhotoArchive(dataAttrs, mockMediaFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('[[photo_archive]] should load recent 10 months', async () => {
|
|
||||||
const result = await fullFlowFromMarkdown('Some text [[photo_archive]] more text');
|
|
||||||
|
|
||||||
expect(result.mode).toBe('recent');
|
|
||||||
expect(result.monthCount).toBe(10);
|
|
||||||
expect(result.totalImages).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[[photo_archive year="2020"]] should load only 2020 images', async () => {
|
|
||||||
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2020"]] more text');
|
|
||||||
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.year).toBe(2020);
|
|
||||||
expect(result.totalImages).toBe(6);
|
|
||||||
expect(result.monthCount).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[[photo_archive year="2019"]] should load only 2019 images', async () => {
|
|
||||||
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2019"]] more text');
|
|
||||||
|
|
||||||
expect(result.mode).toBe('full-year');
|
|
||||||
expect(result.year).toBe(2019);
|
|
||||||
expect(result.totalImages).toBe(6);
|
|
||||||
expect(result.monthCount).toBe(6);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('[[photo_archive year="2020" month="2"]] should load only Feb 2020', async () => {
|
|
||||||
const result = await fullFlowFromMarkdown('Some text [[photo_archive year="2020" month="2"]] more text');
|
|
||||||
|
|
||||||
expect(result.mode).toBe('single-month');
|
|
||||||
expect(result.year).toBe(2020);
|
|
||||||
expect(result.month).toBe(2);
|
|
||||||
expect(result.totalImages).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('BUG REPRO: params should be correctly parsed from markdown', () => {
|
|
||||||
// Step 1: Parse macros with year parameter
|
|
||||||
const macrosWithYear = parseMacros('[[photo_archive year="2020"]]');
|
|
||||||
expect(macrosWithYear).toHaveLength(1);
|
|
||||||
expect(macrosWithYear[0].params).toEqual({ year: '2020' });
|
|
||||||
|
|
||||||
// Step 2: Parse macros without parameters
|
|
||||||
const macrosWithoutParams = parseMacros('[[photo_archive]]');
|
|
||||||
expect(macrosWithoutParams).toHaveLength(1);
|
|
||||||
expect(macrosWithoutParams[0].params).toEqual({});
|
|
||||||
|
|
||||||
// Step 3: Verify the params are DIFFERENT
|
|
||||||
expect(macrosWithYear[0].params).not.toEqual(macrosWithoutParams[0].params);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Editor.tsx markdownToHtml exact flow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
clearMacros();
|
|
||||||
registerMacro(photoArchiveMacro);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('USER BUG: [[photo_archive year=2016]] (unquoted) should NOT produce data-recent', () => {
|
|
||||||
// This is the EXACT user scenario - unquoted parameter value
|
|
||||||
const markdown = '[[photo_archive year=2016]]';
|
|
||||||
|
|
||||||
// Step 1: Parse macros
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
console.log('Parsed macros (unquoted):', JSON.stringify(macros, null, 2));
|
|
||||||
expect(macros).toHaveLength(1);
|
|
||||||
// BUG: This fails because PARAM_REGEX only matches quoted values
|
|
||||||
expect(macros[0].params.year).toBe('2016');
|
|
||||||
|
|
||||||
// Step 2: Render via markdownToHtml
|
|
||||||
const html = markdownToHtml(markdown, 'test-post');
|
|
||||||
console.log('Rendered HTML (unquoted):', html);
|
|
||||||
|
|
||||||
// Step 3: Check the HTML does NOT have data-recent
|
|
||||||
expect(html).not.toContain('data-recent=');
|
|
||||||
expect(html).toContain('data-year="2016"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('USER BUG: [[photo_archive year="2016"]] (quoted) should NOT produce data-recent', () => {
|
|
||||||
// This is the EXACT user scenario
|
|
||||||
const markdown = '[[photo_archive year="2016"]]';
|
|
||||||
|
|
||||||
// Step 1: Parse macros
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
console.log('Parsed macros:', JSON.stringify(macros, null, 2));
|
|
||||||
expect(macros).toHaveLength(1);
|
|
||||||
expect(macros[0].params.year).toBe('2016');
|
|
||||||
|
|
||||||
// Step 2: Render via markdownToHtml
|
|
||||||
const html = markdownToHtml(markdown, 'test-post');
|
|
||||||
console.log('Rendered HTML:', html);
|
|
||||||
|
|
||||||
// Step 3: Check the HTML does NOT have data-recent
|
|
||||||
expect(html).not.toContain('data-recent=');
|
|
||||||
expect(html).toContain('data-year="2016"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('markdownToHtml with [[photo_archive]] produces data-recent', () => {
|
|
||||||
const html = markdownToHtml('Test [[photo_archive]] end', 'post-123');
|
|
||||||
|
|
||||||
expect(html).toContain('data-recent="10"');
|
|
||||||
expect(html).not.toContain('data-year=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('markdownToHtml with [[photo_archive year="2020"]] produces data-year', () => {
|
|
||||||
const html = markdownToHtml('Test [[photo_archive year="2020"]] end', 'post-123');
|
|
||||||
|
|
||||||
expect(html).toContain('data-year="2020"');
|
|
||||||
expect(html).not.toContain('data-recent=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('markdownToHtml with [[photo_archive year="2020" month="2"]] produces both', () => {
|
|
||||||
const html = markdownToHtml('Test [[photo_archive year="2020" month="2"]] end', 'post-123');
|
|
||||||
|
|
||||||
expect(html).toContain('data-year="2020"');
|
|
||||||
expect(html).toContain('data-month="2"');
|
|
||||||
expect(html).not.toContain('data-recent=');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CRITICAL BUG TEST: verify params flow correctly through markdownToHtml', () => {
|
|
||||||
// This tests the exact code path in Editor.tsx
|
|
||||||
const markdown = 'Content with [[photo_archive year="2019"]] macro';
|
|
||||||
|
|
||||||
// 1. parseMacros should extract params correctly
|
|
||||||
const macros = parseMacros(markdown);
|
|
||||||
expect(macros[0].params.year).toBe('2019');
|
|
||||||
|
|
||||||
// 2. markdownToHtml should produce correct HTML
|
|
||||||
const html = markdownToHtml(markdown, 'test-post');
|
|
||||||
|
|
||||||
// 3. HTML should have data-year, NOT data-recent
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const el = dom.window.document.querySelector('.macro-photo-archive');
|
|
||||||
|
|
||||||
expect(el).not.toBeNull();
|
|
||||||
expect(el!.getAttribute('data-year')).toBe('2019');
|
|
||||||
expect(el!.getAttribute('data-recent')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
16
tests/renderer/cspPreviewFrame.test.ts
Normal file
16
tests/renderer/cspPreviewFrame.test.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
describe('renderer CSP for preview iframe', () => {
|
||||||
|
it('allows framing local preview server origin', () => {
|
||||||
|
const htmlPath = path.resolve(process.cwd(), 'src/renderer/index.html');
|
||||||
|
const html = readFileSync(htmlPath, 'utf8');
|
||||||
|
|
||||||
|
expect(html).toMatch(/Content-Security-Policy/i);
|
||||||
|
expect(html).toMatch(/frame-src\s+'self'\s+http:\/\/127\.0\.0\.1:4123/);
|
||||||
|
expect(html).not.toMatch(/unsafe-eval/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user