feat: better previews and consistent previews

This commit is contained in:
2026-02-17 06:47:57 +01:00
parent 4ce1654f47
commit b2db7c6df0
15 changed files with 508 additions and 1241 deletions

View File

@@ -63,6 +63,25 @@ const PREVIEW_ASSETS = {
},
} 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 {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_MAX_POSTS_PER_PAGE;
@@ -415,6 +434,12 @@ export class PreviewServer {
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);
if (mediaAsset) {
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> {
const match = pathname.match(/^\/media\/(.+)$/);
if (!match || !dataDir) return null;

View File

@@ -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 {
// ============ Git Handlers ============
@@ -256,6 +272,19 @@ export function registerIpcHandlers(): void {
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) => {
const engine = getPostEngine();
return engine.getAllPosts(options);

View File

@@ -6,11 +6,14 @@ import { registerIpcHandlers, registerChatHandlers, initializeChatHandlers, clea
import { media } from './database/schema';
import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine';
import { PreviewServer } from './engine/PreviewServer';
let mainWindow: BrowserWindow | null = null;
let previewServer: PreviewServer | null = null;
let activePreviewPostId: string | null = null;
const PREVIEW_SERVER_PORT = 4123;
const BLOG_PREVIEW_POST_MENU_ID = 'blog.previewPost';
// Check if dev server is likely running (only in development)
const isDev = process.env.NODE_ENV === 'development';
@@ -109,6 +112,42 @@ async function openPreviewInBrowser(): Promise<void> {
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> {
if (!previewServer) {
previewServer = new PreviewServer();
@@ -265,9 +304,15 @@ function createApplicationMenu(): Menu {
{ type: 'separator' },
{
label: 'Preview Post',
id: BLOG_PREVIEW_POST_MENU_ID,
enabled: false,
accelerator: 'CmdOrCtrl+Shift+V',
click: () => {
mainWindow?.webContents.send('menu:previewPost');
click: async () => {
try {
await openActivePostPreviewInBrowser();
} catch (error) {
console.error('Failed to preview active post in browser:', error);
}
},
},
{ type: 'separator' },
@@ -444,6 +489,11 @@ async function initialize(): Promise<void> {
// Register IPC handlers
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
initializeChatHandlers(() => mainWindow);

View File

@@ -52,6 +52,7 @@ export const electronAPI: ElectronAPI = {
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', 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),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
@@ -140,6 +141,7 @@ export const electronAPI: ElectronAPI = {
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath),
setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
},
// Meta (tags, categories, and project metadata)

View File

@@ -430,6 +430,7 @@ export interface ElectronAPI {
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getPreviewUrl: (id: string) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
@@ -508,6 +509,7 @@ export interface ElectronAPI {
selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>;
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; mainLanguage?: string } | null>;
setPreviewPostTarget: (postId: string | null) => Promise<void>;
};
meta: {
getTags: () => Promise<string[]>;