feat: better previews and consistent previews
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
Reference in New Issue
Block a user