2361 lines
82 KiB
TypeScript
2361 lines
82 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||
import MonacoEditor, { Monaco } from '@monaco-editor/react';
|
||
import { useAppStore, PostData, EditorMode, MediaData } from '../../store';
|
||
import { showToast } from '../Toast';
|
||
import { MilkdownEditor } from '../MilkdownEditor';
|
||
import { Lightbox, useMarkdownImages } from '../Lightbox';
|
||
import { PostLinks } from '../PostLinks';
|
||
import { LinkedMediaPanel } from '../LinkedMediaPanel';
|
||
import { ErrorModal } from '../ErrorModal';
|
||
import { ConfirmDeleteModal } from '../ConfirmDeleteModal';
|
||
import { SettingsView } from '../SettingsView';
|
||
import { TagsView } from '../TagsView';
|
||
import { TagInput } from '../TagInput';
|
||
import { ChatPanel } from '../ChatPanel';
|
||
import { ImportAnalysisView } from '../ImportAnalysisView';
|
||
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
||
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
||
import { AutoSaveManager, getContrastColor } from '../../utils';
|
||
import { parseMacros, getMacro } from '../../macros/registry';
|
||
import { InsertModal } from '../InsertModal';
|
||
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
|
||
import './Editor.css';
|
||
|
||
/** Get display name for media: prefer title over originalName */
|
||
function getMediaDisplayName(media: { title?: string; originalName: string }): string {
|
||
return media.title || media.originalName;
|
||
}
|
||
|
||
// Module-level AutoSaveManager for idle-time based auto-saving
|
||
const autoSaveManager = new AutoSaveManager({
|
||
idleTimeMs: 3000, // Save after 3 seconds of idle time
|
||
onSave: async (id, changes) => {
|
||
// Note: We don't check if post exists in store's posts array since that's limited to 500.
|
||
// If the post was deleted, the update will fail gracefully.
|
||
|
||
// Build update payload from changes
|
||
const update: Parameters<typeof window.electronAPI.posts.update>[1] = {};
|
||
if ('title' in changes) update.title = changes.title as string;
|
||
if ('content' in changes) update.content = changes.content as string;
|
||
if ('tags' in changes) {
|
||
const tagsStr = changes.tags as string;
|
||
update.tags = tagsStr.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||
}
|
||
if ('categories' in changes) {
|
||
update.categories = changes.categories as string[];
|
||
}
|
||
|
||
const updated = await window.electronAPI?.posts.update(id, update);
|
||
if (updated) {
|
||
useAppStore.getState().updatePost(id, updated as Partial<PostData>);
|
||
useAppStore.getState().markClean(id);
|
||
// Emit event so PostEditor can update its local state
|
||
window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id, updated } }));
|
||
}
|
||
},
|
||
onSaveComplete: (id) => {
|
||
console.log(`Auto-saved post ${id}`);
|
||
},
|
||
onSaveError: (id, error) => {
|
||
console.error(`Auto-save failed for ${id}:`, error);
|
||
},
|
||
});
|
||
|
||
/**
|
||
* Resolves media references in markdown content to bds-media:// URLs
|
||
* Matches images by:
|
||
* 1. Media ID in the path (e.g., /media/2025/01/{id}.jpg)
|
||
* 2. Original filename (e.g., image.jpg)
|
||
* 3. Filename pattern (e.g., {id}.jpg)
|
||
*/
|
||
const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
|
||
if (!content || mediaList.length === 0) return content;
|
||
|
||
// Build lookup maps for efficient matching
|
||
const byId = new Map<string, string>();
|
||
const byOriginalName = new Map<string, string>();
|
||
const byFilename = new Map<string, string>();
|
||
|
||
for (const m of mediaList) {
|
||
byId.set(m.id, m.id);
|
||
byOriginalName.set(m.originalName.toLowerCase(), m.id);
|
||
byFilename.set(m.filename.toLowerCase(), m.id);
|
||
}
|
||
|
||
// Replace image URLs in markdown
|
||
return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
|
||
// Skip if already using bds-media protocol or external URLs
|
||
if (src.startsWith('bds-media://') || src.startsWith('http://') || src.startsWith('https://')) {
|
||
return match;
|
||
}
|
||
|
||
// Extract the filename from the path
|
||
const filename = src.split('/').pop() || '';
|
||
const filenameWithoutExt = filename.replace(/\.[^.]+$/, '');
|
||
const filenameLower = filename.toLowerCase();
|
||
|
||
// Try to match by:
|
||
// 1. UUID in path (the file is named by ID)
|
||
if (byId.has(filenameWithoutExt)) {
|
||
return ``;
|
||
}
|
||
|
||
// 2. Filename lookup
|
||
if (byFilename.has(filenameLower)) {
|
||
return `})`;
|
||
}
|
||
|
||
// 3. Original name lookup
|
||
if (byOriginalName.has(filenameLower)) {
|
||
return `})`;
|
||
}
|
||
|
||
// No match found, return original
|
||
return match;
|
||
});
|
||
};
|
||
|
||
// 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
|
||
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 result
|
||
// Escape HTML (but not our rendered macros - they're already safe)
|
||
// We need to be careful here - macro output contains HTML
|
||
// For safety, we skip escaping since we control the macro output
|
||
// Headers
|
||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||
// Bold
|
||
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
||
// Italic
|
||
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
||
// Images
|
||
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img alt="$1" src="$2" style="max-width: 100%;" />')
|
||
// Links
|
||
.replace(/\[(.*?)\]\((.*?)\)/gim, '<a href="$2" target="_blank">$1</a>')
|
||
// Code blocks
|
||
.replace(/```([\s\S]*?)```/gim, '<pre><code>$1</code></pre>')
|
||
// Inline code
|
||
.replace(/`(.*?)`/gim, '<code>$1</code>')
|
||
// Blockquotes
|
||
.replace(/^\> (.*$)/gim, '<blockquote>$1</blockquote>')
|
||
// Horizontal rules
|
||
.replace(/^---$/gim, '<hr />')
|
||
// Line breaks
|
||
.replace(/\n/g, '<br />');
|
||
};
|
||
|
||
/**
|
||
* 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 {
|
||
postId: string;
|
||
}
|
||
|
||
export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
|
||
const {
|
||
updatePost,
|
||
markDirty,
|
||
markClean,
|
||
isDirty: checkIsDirty,
|
||
preferredEditorMode,
|
||
setPreferredEditorMode,
|
||
showErrorModal,
|
||
showConfirmDeleteModal,
|
||
media,
|
||
closeTab,
|
||
} = useAppStore();
|
||
|
||
// Fetch full post data from backend
|
||
const [post, setPost] = useState<PostData | null>(null);
|
||
const [isLoadingPost, setIsLoadingPost] = useState(true);
|
||
// Track whether form state has been initialized from post data
|
||
const [isInitialized, setIsInitialized] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setIsLoadingPost(true);
|
||
setIsInitialized(false);
|
||
window.electronAPI?.posts.get(postId).then((fetchedPost) => {
|
||
if (cancelled) return;
|
||
if (fetchedPost) {
|
||
setPost(fetchedPost as PostData);
|
||
// Also update the store so other components have the full data
|
||
useAppStore.getState().updatePost(postId, fetchedPost as Partial<PostData>);
|
||
} else {
|
||
// Post doesn't exist, close the tab
|
||
closeTab(postId);
|
||
}
|
||
setIsLoadingPost(false);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, [postId, closeTab]);
|
||
|
||
const [title, setTitle] = useState('');
|
||
const [content, setContent] = useState('');
|
||
const [author, setAuthor] = useState('');
|
||
const [tags, setTags] = useState<string[]>([]);
|
||
const [selectedCategories, setSelectedCategories] = useState<string[]>(['article']);
|
||
const [availableCategories, setAvailableCategories] = useState<string[]>(['article', 'picture', 'aside', 'page']);
|
||
const [categoriesDropdownOpen, setCategoriesDropdownOpen] = useState(false);
|
||
const categoriesDropdownRef = useRef<HTMLDivElement>(null);
|
||
const [isSaving, setIsSaving] = 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 [lightboxOpen, setLightboxOpen] = useState(false);
|
||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||
const [galleryImages, setGalleryImages] = useState<{ src: string; alt: string }[]>([]);
|
||
const [showPostSearch, setShowPostSearch] = useState(false);
|
||
const [showMediaSearch, setShowMediaSearch] = useState(false);
|
||
const editorRef = useRef<unknown>(null);
|
||
const previewRef = useRef<HTMLDivElement>(null);
|
||
|
||
const isDirty = checkIsDirty(postId);
|
||
|
||
// Listen for auto-save events to keep local post state in sync
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const { id, updated } = (e as CustomEvent).detail;
|
||
if (id === postId && updated) {
|
||
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||
}
|
||
};
|
||
window.addEventListener('bds:post-auto-saved', handler);
|
||
return () => window.removeEventListener('bds:post-auto-saved', handler);
|
||
}, [postId]);
|
||
|
||
// Check if post has a published version for discard functionality
|
||
useEffect(() => {
|
||
window.electronAPI?.posts.hasPublishedVersion(postId).then(setHasPublishedVersion);
|
||
}, [postId]);
|
||
|
||
// Load available categories from backend (project-scoped)
|
||
useEffect(() => {
|
||
const loadCategories = async () => {
|
||
try {
|
||
const categories = await window.electronAPI?.meta.getCategories();
|
||
if (categories && categories.length > 0) {
|
||
setAvailableCategories(categories);
|
||
}
|
||
} catch {
|
||
// Keep defaults
|
||
}
|
||
};
|
||
loadCategories();
|
||
}, []);
|
||
|
||
// Close categories dropdown when clicking outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (categoriesDropdownRef.current && !categoriesDropdownRef.current.contains(event.target as Node)) {
|
||
setCategoriesDropdownOpen(false);
|
||
}
|
||
};
|
||
if (categoriesDropdownOpen) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}
|
||
}, [categoriesDropdownOpen]);
|
||
|
||
// Resolve media URLs in content for display
|
||
const resolvedContent = useMemo(() => resolveMediaUrls(content, media), [content, media]);
|
||
|
||
// Extract images from resolved content for lightbox
|
||
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(() => {
|
||
if (editorMode !== 'preview' || !previewRef.current || !previewContentRef.current) return;
|
||
|
||
let cancelled = false;
|
||
|
||
// Helper to show/hide the overlay without triggering React re-render
|
||
const showOverlay = (show: boolean) => {
|
||
if (hydrationOverlayRef.current) {
|
||
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) {
|
||
await hydratePhotoArchive(previewRef.current, lightboxHandler);
|
||
}
|
||
} finally {
|
||
// Always reset hydration state when complete - the ref is global to the component
|
||
isHydratingRef.current = false;
|
||
showOverlay(false);
|
||
}
|
||
}, 100);
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
clearTimeout(timer);
|
||
};
|
||
}, [editorMode, postId, resolvedContent]);
|
||
|
||
// Track latest values for auto-save on unmount/switch
|
||
const pendingChangesRef = useRef<{
|
||
title: string;
|
||
content: string;
|
||
tags: string[];
|
||
categories: string[];
|
||
postId: string;
|
||
isDirty: boolean;
|
||
} | null>(null);
|
||
|
||
// Update ref when values change
|
||
useEffect(() => {
|
||
pendingChangesRef.current = {
|
||
title,
|
||
content,
|
||
tags,
|
||
categories: selectedCategories,
|
||
postId,
|
||
isDirty,
|
||
};
|
||
}, [title, content, tags, selectedCategories, postId, isDirty]);
|
||
|
||
// Auto-save when switching away from a post or unmounting
|
||
useEffect(() => {
|
||
return () => {
|
||
// Cancel any pending auto-save timer - we'll save immediately
|
||
autoSaveManager.cancel(postId);
|
||
|
||
const pending = pendingChangesRef.current;
|
||
// Auto-save if we have pending changes (the update will fail gracefully if post was deleted)
|
||
if (pending && pending.postId === postId && pending.isDirty) {
|
||
// Fire and forget auto-save
|
||
window.electronAPI?.posts.update(pending.postId, {
|
||
title: pending.title,
|
||
content: pending.content,
|
||
tags: pending.tags,
|
||
categories: pending.categories.length > 0 ? pending.categories : ['article'],
|
||
}).then((updated) => {
|
||
if (updated) {
|
||
useAppStore.getState().updatePost(pending.postId, updated as Partial<PostData>);
|
||
useAppStore.getState().markClean(pending.postId);
|
||
window.dispatchEvent(new CustomEvent('bds:post-auto-saved', { detail: { id: pending.postId, updated } }));
|
||
}
|
||
}).catch((error) => {
|
||
console.error('Auto-save failed:', error);
|
||
});
|
||
}
|
||
};
|
||
}, [postId]);
|
||
|
||
// Reset when post data is loaded or changes
|
||
useEffect(() => {
|
||
if (post) {
|
||
setTitle(post.title);
|
||
setContent(post.content);
|
||
setAuthor(post.author || '');
|
||
setTags(post.tags);
|
||
setSelectedCategories(post.categories.length > 0 ? post.categories : ['article']);
|
||
markClean(postId);
|
||
// Mark as initialized AFTER setting local state
|
||
setIsInitialized(true);
|
||
}
|
||
}, [post, postId, markClean]);
|
||
|
||
// Track changes and notify auto-save manager
|
||
// Only run after form has been initialized from post data
|
||
useEffect(() => {
|
||
if (!post || !isInitialized) return;
|
||
const tagsChanged = JSON.stringify(tags.slice().sort()) !== JSON.stringify(post.tags.slice().sort());
|
||
const categoriesChanged = JSON.stringify(selectedCategories.slice().sort()) !== JSON.stringify(post.categories.slice().sort());
|
||
const authorChanged = author !== (post.author || '');
|
||
const hasChanges =
|
||
title !== post.title ||
|
||
content !== post.content ||
|
||
authorChanged ||
|
||
tagsChanged ||
|
||
categoriesChanged;
|
||
|
||
if (hasChanges) {
|
||
markDirty(postId);
|
||
// Notify auto-save manager with accumulated changes
|
||
// Convert tags array to comma-separated string for auto-save compatibility
|
||
autoSaveManager.notifyChange(postId, {
|
||
title,
|
||
content,
|
||
author,
|
||
tags: tags.join(', '),
|
||
categories: selectedCategories,
|
||
});
|
||
} else {
|
||
markClean(postId);
|
||
}
|
||
}, [title, content, author, tags, selectedCategories, post, postId, isInitialized, markDirty, markClean]);
|
||
|
||
// Handle editor mode change and persist preference
|
||
const handleEditorModeChange = (mode: EditorMode) => {
|
||
setEditorMode(mode);
|
||
setPreferredEditorMode(mode);
|
||
};
|
||
|
||
const handleSave = useCallback(async () => {
|
||
if (!isDirty || isSaving) return;
|
||
|
||
// Cancel any pending auto-save since we're saving manually
|
||
autoSaveManager.cancel(postId);
|
||
|
||
setIsSaving(true);
|
||
try {
|
||
const updated = await window.electronAPI?.posts.update(postId, {
|
||
title,
|
||
content,
|
||
author: author || undefined,
|
||
tags,
|
||
categories: selectedCategories.length > 0 ? selectedCategories : ['article'],
|
||
});
|
||
|
||
if (updated) {
|
||
updatePost(postId, updated as Partial<PostData>);
|
||
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||
markClean(postId);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to save post:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Save Failed',
|
||
message: err.message || 'Failed to save post',
|
||
stack: err.stack,
|
||
});
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
}, [postId, title, content, author, tags, selectedCategories, isDirty, isSaving, updatePost, markClean, showErrorModal]);
|
||
|
||
const handlePublish = async () => {
|
||
await handleSave();
|
||
try {
|
||
const updated = await window.electronAPI?.posts.publish(postId);
|
||
if (updated) {
|
||
updatePost(postId, updated as Partial<PostData>);
|
||
setPost(prev => prev ? { ...prev, ...updated as Partial<PostData> } : prev);
|
||
showToast.success('Post published');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to publish post:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Publish Failed',
|
||
message: err.message || 'Failed to publish post',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDiscard = async () => {
|
||
// If this post has a published version, revert to it
|
||
// If never published, delete the post entirely
|
||
const confirmMessage = hasPublishedVersion
|
||
? 'Discard all changes since last publish? This cannot be undone.'
|
||
: 'Delete this draft? This cannot be undone.';
|
||
|
||
if (!confirm(confirmMessage)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (hasPublishedVersion) {
|
||
// Revert to published version
|
||
const reverted = await window.electronAPI?.posts.discard(postId);
|
||
if (reverted) {
|
||
setTitle(reverted.title);
|
||
setContent(reverted.content);
|
||
setAuthor(reverted.author || '');
|
||
setTags(reverted.tags);
|
||
setSelectedCategories(reverted.categories.length > 0 ? reverted.categories : ['article']);
|
||
// Update local post state so UI reflects the published status
|
||
setPost(reverted as PostData);
|
||
updatePost(postId, reverted as Partial<PostData>);
|
||
markClean(postId);
|
||
showToast.success('Reverted to last published version');
|
||
}
|
||
} else {
|
||
// Never published - delete the post entirely
|
||
await window.electronAPI?.posts.delete(postId);
|
||
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
|
||
pendingChangesRef.current = null;
|
||
useAppStore.getState().removePost(postId);
|
||
useAppStore.getState().closeTab(postId);
|
||
showToast.success('Draft deleted');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to discard/delete:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed',
|
||
message: err.message || 'Operation failed',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
// Fetch references to this post
|
||
const [linkedBy, linkedMedia] = await Promise.all([
|
||
window.electronAPI?.posts.getLinkedBy(postId),
|
||
window.electronAPI?.postMedia.getForPost(postId),
|
||
]);
|
||
|
||
// Build references array
|
||
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
|
||
|
||
// Add posts that link to this post
|
||
if (linkedBy && linkedBy.length > 0) {
|
||
linkedBy.forEach((p: { id: string; title: string }) => {
|
||
references.push({ id: p.id, title: p.title, type: 'link' });
|
||
});
|
||
}
|
||
|
||
// Add linked media
|
||
if (linkedMedia && linkedMedia.length > 0) {
|
||
linkedMedia.forEach((m: { mediaId: string }) => {
|
||
const mediaItem = media.find(item => item.id === m.mediaId);
|
||
if (mediaItem) {
|
||
references.push({
|
||
id: mediaItem.id,
|
||
title: getMediaDisplayName(mediaItem),
|
||
type: 'media',
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
// Show confirmation modal
|
||
showConfirmDeleteModal({
|
||
itemType: 'post',
|
||
itemTitle: title || 'Untitled',
|
||
references,
|
||
onConfirm: async () => {
|
||
try {
|
||
await window.electronAPI?.posts.delete(postId);
|
||
// Clear pending ref to prevent auto-save on unmount from resurrecting the post
|
||
pendingChangesRef.current = null;
|
||
useAppStore.getState().removePost(postId);
|
||
useAppStore.getState().closeTab(postId);
|
||
useAppStore.getState().setSelectedPost(null);
|
||
showToast.success('Post deleted');
|
||
} catch (error) {
|
||
console.error('Failed to delete post:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Delete Failed',
|
||
message: err.message || 'Failed to delete post',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to fetch post references:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Error',
|
||
message: err.message || 'Failed to fetch post references',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
// Handle Monaco editor mount
|
||
const handleEditorDidMount = (editor: unknown, monaco: Monaco) => {
|
||
editorRef.current = editor;
|
||
const ed = editor as any;
|
||
|
||
// Add keyboard shortcut and command for inserting post links
|
||
ed.addAction({
|
||
id: 'editor.action.insertPostLink',
|
||
label: 'Insert Link to Post',
|
||
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK],
|
||
run: () => {
|
||
setShowPostSearch(true);
|
||
}
|
||
});
|
||
};
|
||
|
||
// Handle link insertion from InsertModal (for posts or external URLs)
|
||
const handleInsertLink = useCallback((url: string, text?: string) => {
|
||
const editor = editorRef.current as any;
|
||
if (!editor) return;
|
||
|
||
const model = editor.getModel();
|
||
if (!model) return;
|
||
|
||
const selection = editor.getSelection();
|
||
const selectedText = selection ? model.getValueInRange(selection) : '';
|
||
|
||
const linkText = text || selectedText || url;
|
||
const linkMarkdown = `[${linkText}](${url})`;
|
||
|
||
editor.executeEdits('insert-link', [{
|
||
range: selection || editor.getSelection(),
|
||
text: linkMarkdown,
|
||
forceMoveMarkers: true
|
||
}]);
|
||
|
||
setShowPostSearch(false);
|
||
}, []);
|
||
|
||
// Handle image insertion from InsertModal (for media library)
|
||
const handleInsertImage = useCallback(async (url: string, alt: string, mediaId?: string) => {
|
||
const editor = editorRef.current as any;
|
||
if (!editor) return;
|
||
|
||
const selection = editor.getSelection();
|
||
const imageMarkdown = ``;
|
||
|
||
editor.executeEdits('insert-image', [{
|
||
range: selection || editor.getSelection(),
|
||
text: imageMarkdown,
|
||
forceMoveMarkers: true
|
||
}]);
|
||
|
||
// Link the media to this post if mediaId is provided (from media library)
|
||
if (mediaId) {
|
||
try {
|
||
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||
console.log(`[Editor] Linked media ${mediaId} to post ${postId}`);
|
||
} catch (error) {
|
||
console.error('Failed to link media to post:', error);
|
||
}
|
||
}
|
||
|
||
setShowMediaSearch(false);
|
||
}, [postId]);
|
||
|
||
// Configure Monaco before mount to add macro syntax highlighting
|
||
const handleEditorWillMount = (monaco: Monaco) => {
|
||
// Register a custom language that extends markdown with macro support
|
||
monaco.languages.register({ id: 'markdown-with-macros' });
|
||
|
||
// Define custom tokenization that highlights [[macro]] syntax
|
||
monaco.languages.setMonarchTokensProvider('markdown-with-macros', {
|
||
defaultToken: '',
|
||
tokenPostfix: '.md',
|
||
|
||
tokenizer: {
|
||
root: [
|
||
// Macro syntax: [[macroName param="value"]]
|
||
[/\[\[[a-zA-Z][\w-]*/, { token: 'keyword.macro', next: '@macroParams' }],
|
||
|
||
// Headers
|
||
[/^#{1,6}\s.*$/, 'keyword.header'],
|
||
|
||
// Block elements
|
||
[/^\s*>+/, 'string.quote'],
|
||
[/^\s*[-+*]\s/, 'keyword'],
|
||
[/^\s*\d+\.\s/, 'keyword'],
|
||
[/^\s*```\w*/, { token: 'string.code', next: '@codeblock' }],
|
||
|
||
// Inline elements
|
||
[/\*\*[^*]+\*\*/, 'strong'],
|
||
[/\*[^*]+\*/, 'emphasis'],
|
||
[/__[^_]+__/, 'strong'],
|
||
[/_[^_]+_/, 'emphasis'],
|
||
[/`[^`]+`/, 'variable'],
|
||
|
||
// Links and images
|
||
[/!?\[[^\]]*\]\([^)]*\)/, 'string.link'],
|
||
[/!?\[[^\]]*\]\[[^\]]*\]/, 'string.link'],
|
||
],
|
||
|
||
macroParams: [
|
||
[/\]\]/, { token: 'keyword.macro', next: '@root' }],
|
||
[/[a-zA-Z][\w-]*(?=\s*=)/, 'attribute.name'],
|
||
[/=/, 'delimiter'],
|
||
[/"[^"]*"/, 'string'],
|
||
[/\s+/, 'white'],
|
||
[/[^\]"=\s]+/, 'attribute.value'],
|
||
],
|
||
|
||
codeblock: [
|
||
[/^\s*```\s*$/, { token: 'string.code', next: '@root' }],
|
||
[/.*$/, 'variable.source'],
|
||
],
|
||
},
|
||
});
|
||
|
||
// Define theme colors for macros
|
||
monaco.editor.defineTheme('vs-dark-macros', {
|
||
base: 'vs-dark',
|
||
inherit: true,
|
||
rules: [
|
||
{ token: 'keyword.macro', foreground: 'C586C0', fontStyle: 'bold' },
|
||
{ token: 'attribute.name', foreground: '9CDCFE' },
|
||
{ token: 'attribute.value', foreground: 'CE9178' },
|
||
],
|
||
colors: {},
|
||
});
|
||
};
|
||
|
||
// Save on Ctrl+S
|
||
useEffect(() => {
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
e.preventDefault();
|
||
handleSave();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||
}, [handleSave]);
|
||
|
||
// Listen for menu events
|
||
useEffect(() => {
|
||
const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave);
|
||
const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish);
|
||
|
||
return () => {
|
||
unsubscribeSave?.();
|
||
unsubscribePublish?.();
|
||
};
|
||
}, [handleSave]);
|
||
|
||
// Show loading state while fetching post data
|
||
if (isLoadingPost || !post) {
|
||
return (
|
||
<div className="editor">
|
||
<div className="editor-empty">
|
||
<div className="welcome-content">
|
||
<p className="text-muted">Loading post...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="editor">
|
||
<div className="editor-header">
|
||
<div className="editor-tabs">
|
||
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
|
||
<span className="editor-tab-title">{title || 'Untitled'}</span>
|
||
{isDirty && <span className="editor-tab-dirty" title="Unsaved changes (auto-saves on switch)">●</span>}
|
||
</div>
|
||
</div>
|
||
<div className="editor-actions">
|
||
<span className={`status-badge status-${post.status}`}>
|
||
{post.status}
|
||
</span>
|
||
{isSaving && <span className="auto-save-indicator">Saving...</span>}
|
||
{post.status === 'draft' && (
|
||
<button
|
||
onClick={handlePublish}
|
||
className="success"
|
||
title="Save and make this post public"
|
||
>
|
||
Publish
|
||
</button>
|
||
)}
|
||
{post.status === 'draft' && (
|
||
<button
|
||
onClick={handleDiscard}
|
||
className="secondary danger"
|
||
title={hasPublishedVersion ? "Revert to last published version" : "Delete this draft permanently"}
|
||
>
|
||
{hasPublishedVersion ? 'Discard Changes' : 'Discard Draft'}
|
||
</button>
|
||
)}
|
||
{post.status === 'published' && (
|
||
<button onClick={handleDelete} className="secondary danger" title="Delete this post permanently">
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="editor-content">
|
||
<div className="editor-header-row">
|
||
<div className="editor-meta">
|
||
<div className="editor-field">
|
||
<label>Title</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Untitled"
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Tags</label>
|
||
<TagInput
|
||
value={tags}
|
||
onChange={setTags}
|
||
placeholder="Add tags..."
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Author</label>
|
||
<input
|
||
type="text"
|
||
value={author}
|
||
onChange={(e) => setAuthor(e.target.value)}
|
||
placeholder="Author name"
|
||
/>
|
||
</div>
|
||
<div className="editor-field-row">
|
||
<div className="editor-field">
|
||
<label>Slug</label>
|
||
<input
|
||
type="text"
|
||
value={post.slug}
|
||
disabled
|
||
className="disabled"
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Categories</label>
|
||
<div className="multi-select-dropdown" ref={categoriesDropdownRef}>
|
||
<button
|
||
type="button"
|
||
className="multi-select-trigger"
|
||
onClick={() => setCategoriesDropdownOpen(!categoriesDropdownOpen)}
|
||
>
|
||
<span className="multi-select-value">
|
||
{selectedCategories.length === 0
|
||
? 'Select categories...'
|
||
: selectedCategories.length === 1
|
||
? selectedCategories[0]
|
||
: `${selectedCategories.length} categories`}
|
||
</span>
|
||
<span className="multi-select-arrow">{categoriesDropdownOpen ? '▲' : '▼'}</span>
|
||
</button>
|
||
{categoriesDropdownOpen && (
|
||
<div className="multi-select-menu">
|
||
{availableCategories.map((cat) => (
|
||
<label key={cat} className="multi-select-option">
|
||
<input
|
||
type="checkbox"
|
||
checked={selectedCategories.includes(cat)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) {
|
||
setSelectedCategories([...selectedCategories, cat]);
|
||
} else {
|
||
// Don't allow unchecking if it's the last category
|
||
if (selectedCategories.length > 1) {
|
||
setSelectedCategories(selectedCategories.filter(c => c !== cat));
|
||
}
|
||
}
|
||
}}
|
||
/>
|
||
<span>{cat}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{selectedCategories.length > 1 && (
|
||
<div className="multi-select-pills">
|
||
{selectedCategories.map((cat) => (
|
||
<span key={cat} className="multi-select-pill">
|
||
{cat}
|
||
<button
|
||
type="button"
|
||
className="multi-select-pill-remove"
|
||
onClick={() => {
|
||
if (selectedCategories.length > 1) {
|
||
setSelectedCategories(selectedCategories.filter(c => c !== cat));
|
||
}
|
||
}}
|
||
title="Remove category"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<PostLinks
|
||
postId={postId}
|
||
updatedAt={post.updatedAt}
|
||
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="editor-media-panel">
|
||
<LinkedMediaPanel postId={postId} />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="editor-body">
|
||
<div className="editor-toolbar">
|
||
<label>Content</label>
|
||
<div className="editor-mode-toggle">
|
||
<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>
|
||
{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>
|
||
|
||
{editorMode === 'wysiwyg' && (
|
||
<MilkdownEditor
|
||
content={content}
|
||
onChange={setContent}
|
||
placeholder="Start writing..."
|
||
/>
|
||
)}
|
||
|
||
{editorMode === 'markdown' && (
|
||
<MonacoEditor
|
||
height="100%"
|
||
language="markdown-with-macros"
|
||
value={content}
|
||
onChange={(value) => setContent(value || '')}
|
||
onMount={handleEditorDidMount}
|
||
beforeMount={handleEditorWillMount}
|
||
theme="vs-dark-macros"
|
||
options={{
|
||
minimap: { enabled: false },
|
||
wordWrap: 'on',
|
||
lineNumbers: 'on',
|
||
fontSize: 14,
|
||
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||
padding: { top: 12, bottom: 12 },
|
||
automaticLayout: true,
|
||
scrollBeyondLastLine: false,
|
||
renderLineHighlight: 'line',
|
||
quickSuggestions: false,
|
||
formatOnPaste: true,
|
||
cursorStyle: 'line',
|
||
cursorBlinking: 'smooth',
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{editorMode === 'preview' && (
|
||
<div className="editor-preview markdown-body" ref={previewRef}>
|
||
<div className="preview-hydrating-overlay" ref={hydrationOverlayRef} style={{ display: 'none' }}>
|
||
<div className="preview-hydrating-content">
|
||
<div className="preview-hydrating-spinner" />
|
||
<span>Loading photo archive...</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
className="preview-content"
|
||
ref={previewContentRef}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Lightbox for viewing images in content */}
|
||
<Lightbox
|
||
images={allImages}
|
||
initialIndex={lightboxIndex}
|
||
isOpen={lightboxOpen}
|
||
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
|
||
/>
|
||
</div>
|
||
|
||
<div className="editor-footer">
|
||
<span className="text-muted text-small">
|
||
Created: {new Date(post.createdAt).toLocaleString()}
|
||
</span>
|
||
<span className="text-muted text-small">
|
||
Updated: {new Date(post.updatedAt).toLocaleString()}
|
||
</span>
|
||
{post.publishedAt && (
|
||
<span className="text-muted text-small">
|
||
Published: {new Date(post.publishedAt).toLocaleString()}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{showPostSearch && (
|
||
<InsertModal
|
||
mode="link"
|
||
onInsertLink={handleInsertLink}
|
||
onInsertImage={() => {}}
|
||
onClose={() => setShowPostSearch(false)}
|
||
/>
|
||
)}
|
||
|
||
{showMediaSearch && (
|
||
<InsertModal
|
||
mode="image"
|
||
onInsertImage={handleInsertImage}
|
||
onInsertLink={() => {}}
|
||
onClose={() => setShowMediaSearch(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
|
||
const item = media.find(m => m.id === mediaId);
|
||
|
||
const [title, setTitle] = useState(item?.title || '');
|
||
const [alt, setAlt] = useState(item?.alt || '');
|
||
const [caption, setCaption] = useState(item?.caption || '');
|
||
const [author, setAuthor] = useState(item?.author || '');
|
||
const [tags, setTags] = useState(item?.tags.join(', ') || '');
|
||
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
|
||
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
|
||
const [showPostPicker, setShowPostPicker] = useState(false);
|
||
const [postSearchQuery, setPostSearchQuery] = useState('');
|
||
const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]);
|
||
|
||
// Quick action menu state
|
||
const [showQuickActions, setShowQuickActions] = useState(false);
|
||
const [projectLanguage, setProjectLanguage] = useState('en');
|
||
const quickActionsRef = useRef<HTMLDivElement>(null);
|
||
|
||
// AI suggestions modal state
|
||
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [aiSuggestions, setAISuggestions] = useState<AISuggestions | null>(null);
|
||
const [aiError, setAIError] = useState<string | undefined>(undefined);
|
||
|
||
// Load project language setting
|
||
useEffect(() => {
|
||
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
|
||
if (metadata?.mainLanguage) {
|
||
setProjectLanguage(metadata.mainLanguage);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
// Close quick actions menu when clicking outside
|
||
useEffect(() => {
|
||
const handleClickOutside = (event: MouseEvent) => {
|
||
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
|
||
setShowQuickActions(false);
|
||
}
|
||
};
|
||
if (showQuickActions) {
|
||
document.addEventListener('mousedown', handleClickOutside);
|
||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||
}
|
||
}, [showQuickActions]);
|
||
|
||
// Handle AI image analysis for alt text and caption
|
||
const handleAIAnalysis = async () => {
|
||
if (!item || isAnalyzing) return;
|
||
|
||
setShowQuickActions(false);
|
||
setShowAISuggestionsModal(true);
|
||
setIsAnalyzing(true);
|
||
setAISuggestions(null);
|
||
setAIError(undefined);
|
||
|
||
try {
|
||
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
|
||
|
||
if (result?.success) {
|
||
setAISuggestions({
|
||
title: result.title,
|
||
alt: result.alt,
|
||
caption: result.caption,
|
||
});
|
||
} else {
|
||
setAIError(result?.error || 'Failed to analyze image');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to analyze image:', error);
|
||
setAIError((error as Error).message || 'Failed to analyze image');
|
||
} finally {
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
// Handle applying AI suggestions
|
||
const handleApplyAISuggestions = (values: Partial<AISuggestions>) => {
|
||
if (values.title) setTitle(values.title);
|
||
if (values.alt) setAlt(values.alt);
|
||
if (values.caption) setCaption(values.caption);
|
||
setShowAISuggestionsModal(false);
|
||
if (Object.keys(values).length > 0) {
|
||
showToast.success('AI suggestions applied');
|
||
}
|
||
};
|
||
|
||
// Close AI suggestions modal
|
||
const handleCloseAISuggestionsModal = () => {
|
||
setShowAISuggestionsModal(false);
|
||
setAISuggestions(null);
|
||
setAIError(undefined);
|
||
};
|
||
|
||
// Load linked posts for this media and fetch their titles
|
||
useEffect(() => {
|
||
const loadLinkedPosts = async () => {
|
||
if (!mediaId) return;
|
||
try {
|
||
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||
if (links) {
|
||
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
|
||
// Fetch titles for linked posts
|
||
const titles = new Map<string, string>();
|
||
for (const link of links) {
|
||
const post = await window.electronAPI?.posts.get(link.postId);
|
||
if (post) {
|
||
titles.set(link.postId, post.title || 'Untitled');
|
||
}
|
||
}
|
||
setPostTitles(titles);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load linked posts:', error);
|
||
}
|
||
};
|
||
loadLinkedPosts();
|
||
}, [mediaId]);
|
||
|
||
// Fetch posts for the picker when it opens
|
||
useEffect(() => {
|
||
if (!showPostPicker) return;
|
||
const loadPickerPosts = async () => {
|
||
try {
|
||
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
|
||
if (result?.items) {
|
||
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || 'Untitled' })));
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load posts for picker:', error);
|
||
}
|
||
};
|
||
loadPickerPosts();
|
||
}, [showPostPicker]);
|
||
|
||
// Get post titles for display
|
||
const getPostTitle = (postId: string): string => {
|
||
return postTitles.get(postId) || 'Loading...';
|
||
};
|
||
|
||
// Handle linking to a new post
|
||
const handleLinkToPost = async (postId: string, postTitle: string) => {
|
||
try {
|
||
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
|
||
setPostTitles(prev => new Map(prev).set(postId, postTitle));
|
||
setShowPostPicker(false);
|
||
setPostSearchQuery('');
|
||
showToast.success('Linked to post');
|
||
} catch (error) {
|
||
console.error('Failed to link to post:', error);
|
||
showToast.error('Failed to link to post');
|
||
}
|
||
};
|
||
|
||
// Handle unlinking from a post
|
||
const handleUnlinkFromPost = async (postId: string) => {
|
||
try {
|
||
await window.electronAPI?.postMedia.unlink(postId, mediaId);
|
||
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
|
||
showToast.success('Unlinked from post');
|
||
} catch (error) {
|
||
console.error('Failed to unlink from post:', error);
|
||
showToast.error('Failed to unlink from post');
|
||
}
|
||
};
|
||
|
||
// Handle click on a post to navigate to it
|
||
const handlePostClick = (postId: string) => {
|
||
openTab({ type: 'post', id: postId, isTransient: true });
|
||
};
|
||
|
||
// Get unlinked posts for picker, filtered by search
|
||
const unlinkedPosts = pickerPosts.filter(
|
||
p => !linkedPosts.find(l => l.postId === p.id)
|
||
).filter(
|
||
p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase())
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (item) {
|
||
setTitle(item.title || '');
|
||
setAlt(item.alt || '');
|
||
setCaption(item.caption || '');
|
||
setAuthor(item.author || '');
|
||
setTags(item.tags.join(', '));
|
||
}
|
||
}, [item?.id]);
|
||
|
||
if (!item) {
|
||
return <div className="editor-empty">Media not found</div>;
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const updated = await window.electronAPI?.media.update(item.id, {
|
||
title,
|
||
alt,
|
||
caption,
|
||
author: author || undefined,
|
||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||
});
|
||
if (updated) {
|
||
updateMedia(item.id, updated as Partial<typeof item>);
|
||
showToast.success('Media updated');
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update media:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Update Failed',
|
||
message: err.message || 'Failed to update media',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleReplaceFile = async () => {
|
||
try {
|
||
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
|
||
if (updated) {
|
||
updateMedia(item.id, updated as Partial<typeof item>);
|
||
showToast.success('File replaced (thumbnails regenerated)');
|
||
}
|
||
// null means user cancelled or file unchanged - no action needed
|
||
} catch (error) {
|
||
console.error('Failed to replace media file:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Replace Failed',
|
||
message: err.message || 'Failed to replace media file',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
try {
|
||
// Fetch posts that link to this media
|
||
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||
|
||
// Build references array
|
||
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
|
||
|
||
// Add posts that use this media - fetch titles from database
|
||
if (linkedPostsList && linkedPostsList.length > 0) {
|
||
for (const link of linkedPostsList) {
|
||
const post = await window.electronAPI?.posts.get(link.postId);
|
||
if (post) {
|
||
references.push({
|
||
id: post.id,
|
||
title: post.title || 'Untitled',
|
||
type: 'post',
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Show confirmation modal
|
||
showConfirmDeleteModal({
|
||
itemType: 'media',
|
||
itemTitle: getMediaDisplayName(item),
|
||
references,
|
||
onConfirm: async () => {
|
||
try {
|
||
await window.electronAPI?.media.delete(item.id);
|
||
useAppStore.getState().removeMedia(item.id);
|
||
showToast.success('Media deleted');
|
||
} catch (error) {
|
||
console.error('Failed to delete media:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Delete Failed',
|
||
message: err.message || 'Failed to delete media',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to fetch media references:', error);
|
||
const err = error as Error;
|
||
showErrorModal({
|
||
title: 'Error',
|
||
message: err.message || 'Failed to fetch media references',
|
||
stack: err.stack,
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="editor">
|
||
<div className="editor-header">
|
||
<div className="editor-tabs">
|
||
<div className="editor-tab active">
|
||
<span className="editor-tab-title">{getMediaDisplayName(item)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="editor-actions">
|
||
{/* Quick Actions Dropdown */}
|
||
{item.mimeType.startsWith('image/') && (
|
||
<div className="quick-actions-wrapper" ref={quickActionsRef}>
|
||
<button
|
||
className="secondary quick-actions-btn"
|
||
onClick={() => setShowQuickActions(!showQuickActions)}
|
||
disabled={isAnalyzing}
|
||
title="Quick Actions"
|
||
>
|
||
{isAnalyzing ? '⏳ Analyzing...' : '⚡ Quick Actions'}
|
||
</button>
|
||
{showQuickActions && (
|
||
<div className="quick-actions-menu">
|
||
<button
|
||
className="quick-action-item"
|
||
onClick={handleAIAnalysis}
|
||
disabled={isAnalyzing}
|
||
>
|
||
<span className="quick-action-icon">🤖</span>
|
||
<span className="quick-action-text">
|
||
<strong>AI: Generate Title, Alt & Caption</strong>
|
||
<small>Analyzes the image to suggest metadata</small>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<button onClick={handleReplaceFile} className="secondary">Replace File</button>
|
||
<button onClick={handleSave}>Save</button>
|
||
<button onClick={handleDelete} className="secondary danger">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="editor-content media-editor">
|
||
<div className="media-preview">
|
||
{item.mimeType.startsWith('image/') ? (
|
||
<div className="media-preview-image">
|
||
<img
|
||
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
|
||
alt={item.alt || item.originalName}
|
||
onError={(e) => {
|
||
// Fallback to placeholder if image fails to load
|
||
const target = e.target as HTMLImageElement;
|
||
target.style.display = 'none';
|
||
target.parentElement?.classList.add('has-error');
|
||
}}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="media-preview-placeholder">
|
||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
|
||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
|
||
</svg>
|
||
<span>{item.originalName}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="media-details">
|
||
<div className="editor-field">
|
||
<label>File Name</label>
|
||
<input type="text" value={item.originalName} disabled className="disabled" />
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Type</label>
|
||
<input type="text" value={item.mimeType} disabled className="disabled" />
|
||
</div>
|
||
<div className="editor-field-row">
|
||
<div className="editor-field">
|
||
<label>Size</label>
|
||
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
|
||
</div>
|
||
{item.width && item.height && (
|
||
<div className="editor-field">
|
||
<label>Dimensions</label>
|
||
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Title</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Title for lists and search results"
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Alt Text</label>
|
||
<input
|
||
type="text"
|
||
value={alt}
|
||
onChange={(e) => setAlt(e.target.value)}
|
||
placeholder="Describe the image for accessibility"
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Caption</label>
|
||
<textarea
|
||
value={caption}
|
||
onChange={(e) => setCaption(e.target.value)}
|
||
placeholder="Image caption"
|
||
rows={3}
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Tags (comma-separated)</label>
|
||
<input
|
||
type="text"
|
||
value={tags}
|
||
onChange={(e) => setTags(e.target.value)}
|
||
placeholder="tag1, tag2, tag3"
|
||
/>
|
||
</div>
|
||
<div className="editor-field">
|
||
<label>Author</label>
|
||
<input
|
||
type="text"
|
||
value={author}
|
||
onChange={(e) => setAuthor(e.target.value)}
|
||
placeholder="Author name"
|
||
/>
|
||
</div>
|
||
|
||
{/* Linked Posts Section */}
|
||
<div className="editor-field linked-posts-section">
|
||
<label>
|
||
Linked Posts
|
||
<button
|
||
className="add-link-btn"
|
||
onClick={() => setShowPostPicker(!showPostPicker)}
|
||
title="Link to a post"
|
||
>
|
||
+ Link
|
||
</button>
|
||
</label>
|
||
|
||
{showPostPicker && (
|
||
<div className="post-picker">
|
||
<div className="post-picker-search">
|
||
<input
|
||
type="text"
|
||
placeholder="Search posts..."
|
||
value={postSearchQuery}
|
||
onChange={(e) => setPostSearchQuery(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
</div>
|
||
{unlinkedPosts.length === 0 ? (
|
||
<div className="no-posts">{postSearchQuery ? 'No matching posts' : 'No posts available to link'}</div>
|
||
) : (
|
||
<div className="post-picker-list">
|
||
{unlinkedPosts.slice(0, 10).map(post => (
|
||
<div
|
||
key={post.id}
|
||
className="post-picker-item"
|
||
onClick={() => handleLinkToPost(post.id, post.title)}
|
||
>
|
||
{post.title}
|
||
</div>
|
||
))}
|
||
{unlinkedPosts.length > 10 && (
|
||
<div className="post-picker-more">
|
||
+{unlinkedPosts.length - 10} more posts
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{linkedPosts.length === 0 ? (
|
||
<div className="no-linked-posts">Not linked to any posts</div>
|
||
) : (
|
||
<div className="linked-posts-list">
|
||
{linkedPosts.map(({ postId }) => (
|
||
<div key={postId} className="linked-post-item">
|
||
<span
|
||
className="linked-post-title"
|
||
onClick={() => handlePostClick(postId)}
|
||
title="Open post"
|
||
>
|
||
📄 {getPostTitle(postId)}
|
||
</span>
|
||
<button
|
||
className="unlink-btn"
|
||
onClick={() => handleUnlinkFromPost(postId)}
|
||
title="Unlink from post"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI Suggestions Modal */}
|
||
<AISuggestionsModal
|
||
isOpen={showAISuggestionsModal}
|
||
isLoading={isAnalyzing}
|
||
suggestions={aiSuggestions}
|
||
currentValues={{ title, alt, caption }}
|
||
error={aiError}
|
||
onConfirm={handleApplyAISuggestions}
|
||
onClose={handleCloseAISuggestionsModal}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const formatBytes = (bytes: number): string => {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
};
|
||
|
||
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||
|
||
interface DashboardStats {
|
||
totalPosts: number;
|
||
draftCount: number;
|
||
publishedCount: number;
|
||
archivedCount: number;
|
||
}
|
||
|
||
interface TagCount {
|
||
tag: string;
|
||
count: number;
|
||
}
|
||
|
||
interface CategoryCount {
|
||
category: string;
|
||
count: number;
|
||
}
|
||
|
||
interface TagDataWithColor {
|
||
id: string;
|
||
name: string;
|
||
color?: string;
|
||
}
|
||
|
||
const Dashboard: React.FC = () => {
|
||
const { posts, media } = useAppStore();
|
||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
|
||
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
|
||
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
|
||
|
||
useEffect(() => {
|
||
const loadStats = async () => {
|
||
try {
|
||
const [ds, ym, tc, cc, allTagsData] = await Promise.all([
|
||
window.electronAPI?.posts.getDashboardStats(),
|
||
window.electronAPI?.posts.getByYearMonth(),
|
||
window.electronAPI?.posts.getTagsWithCounts(),
|
||
window.electronAPI?.posts.getCategoriesWithCounts(),
|
||
window.electronAPI?.tags.getAll(),
|
||
]);
|
||
if (ds) setStats(ds);
|
||
if (ym) setYearMonthData(ym);
|
||
if (tc) setTagCounts(tc);
|
||
if (cc) setCategoryCounts(cc);
|
||
if (allTagsData) {
|
||
const colorMap = new Map<string, string>();
|
||
for (const tag of allTagsData as TagDataWithColor[]) {
|
||
if (tag.color) {
|
||
colorMap.set(tag.name, tag.color);
|
||
}
|
||
}
|
||
setTagColors(colorMap);
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load dashboard stats:', e);
|
||
}
|
||
};
|
||
loadStats();
|
||
}, [posts.length, media.length]);
|
||
|
||
// Media stats
|
||
const totalMediaSize = media.reduce((sum, m) => sum + (m.size || 0), 0);
|
||
const imageCount = media.filter(m => m.mimeType?.startsWith('image/')).length;
|
||
|
||
// Recent posts (last 5 updated)
|
||
const recentPosts = useMemo(() =>
|
||
[...posts].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 5),
|
||
[posts]
|
||
);
|
||
|
||
// Timeline chart - last 12 months that have posts
|
||
const timelineEntries = useMemo(() => {
|
||
const sorted = [...yearMonthData].sort((a, b) => a.year === b.year ? a.month - b.month : a.year - b.year);
|
||
return sorted.slice(-12);
|
||
}, [yearMonthData]);
|
||
const maxCount = Math.max(1, ...timelineEntries.map(e => e.count));
|
||
|
||
// Tag cloud font sizing
|
||
const tagCloudItems = useMemo(() => {
|
||
if (tagCounts.length === 0) return [];
|
||
const items = tagCounts.slice(0, 40);
|
||
const maxTagCount = Math.max(1, ...items.map(t => t.count));
|
||
const minTagCount = Math.min(...items.map(t => t.count));
|
||
const range = Math.max(1, maxTagCount - minTagCount);
|
||
// Font sizes from 11px to 22px
|
||
return items.map(t => ({
|
||
...t,
|
||
fontSize: 11 + ((t.count - minTagCount) / range) * 11,
|
||
color: tagColors.get(t.tag),
|
||
})).sort((a, b) => a.tag.localeCompare(b.tag)); // alphabetical for cloud layout
|
||
}, [tagCounts, tagColors]);
|
||
|
||
const displayTotalPosts = stats?.totalPosts ?? posts.length;
|
||
const displayDraftCount = stats?.draftCount ?? 0;
|
||
const displayPublishedCount = stats?.publishedCount ?? 0;
|
||
const displayArchivedCount = stats?.archivedCount ?? 0;
|
||
|
||
return (
|
||
<div className="editor-empty">
|
||
<div className="dashboard-content">
|
||
<h1>Dashboard</h1>
|
||
<p className="text-muted">Overview of your blog database</p>
|
||
|
||
<div className="dashboard-stats">
|
||
<div className="stat-card">
|
||
<div className="stat-number">{displayTotalPosts}</div>
|
||
<div className="stat-label">Total Posts</div>
|
||
<div className="stat-breakdown">
|
||
<span className="stat-tag stat-published">{displayPublishedCount} published</span>
|
||
<span className="stat-tag stat-draft">{displayDraftCount} drafts</span>
|
||
{displayArchivedCount > 0 && <span className="stat-tag stat-archived">{displayArchivedCount} archived</span>}
|
||
</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-number">{media.length}</div>
|
||
<div className="stat-label">Media Files</div>
|
||
<div className="stat-breakdown">
|
||
<span className="stat-tag">{imageCount} images</span>
|
||
<span className="stat-tag">{formatBytes(totalMediaSize)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="stat-card">
|
||
<div className="stat-number">{tagCounts.length}</div>
|
||
<div className="stat-label">Tags</div>
|
||
<div className="stat-breakdown">
|
||
<span className="stat-tag">{categoryCounts.length} categories</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{timelineEntries.length > 0 && (
|
||
<div className="dashboard-section">
|
||
<h4>Posts Over Time</h4>
|
||
<div className="timeline-chart">
|
||
{timelineEntries.map((entry) => (
|
||
<div key={`${entry.year}-${entry.month}`} className="timeline-bar-container">
|
||
<div className="timeline-bar" style={{ height: `${(entry.count / maxCount) * 100}%` }}>
|
||
<span className="timeline-bar-count">{entry.count}</span>
|
||
</div>
|
||
<div className="timeline-bar-label">{MONTH_NAMES[entry.month]}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{tagCloudItems.length > 0 && (
|
||
<div className="dashboard-section">
|
||
<h4>Tags</h4>
|
||
<div className="tag-cloud">
|
||
{tagCloudItems.map(item => {
|
||
const hasColor = !!item.color;
|
||
const style: React.CSSProperties = hasColor
|
||
? {
|
||
fontSize: `${item.fontSize}px`,
|
||
backgroundColor: item.color,
|
||
color: getContrastColor(item.color!),
|
||
}
|
||
: { fontSize: `${item.fontSize}px` };
|
||
return (
|
||
<span
|
||
key={item.tag}
|
||
className={`dashboard-tag ${hasColor ? 'has-color' : ''}`}
|
||
style={style}
|
||
title={`${item.count} post${item.count !== 1 ? 's' : ''}`}
|
||
>
|
||
{item.tag}
|
||
</span>
|
||
);
|
||
})}
|
||
{tagCounts.length > 40 && <span className="text-muted tag-cloud-more">+{tagCounts.length - 40} more</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{categoryCounts.length > 0 && (
|
||
<div className="dashboard-section">
|
||
<h4>Categories</h4>
|
||
<div className="tag-cloud">
|
||
{categoryCounts.map(cat => (
|
||
<span
|
||
key={cat.category}
|
||
className="dashboard-tag dashboard-category"
|
||
title={`${cat.count} post${cat.count !== 1 ? 's' : ''}`}
|
||
>
|
||
{cat.category} <span className="tag-count">{cat.count}</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{recentPosts.length > 0 && (
|
||
<div className="dashboard-section">
|
||
<h4>Recently Updated</h4>
|
||
<div className="recent-posts-list">
|
||
{recentPosts.map(post => (
|
||
<div
|
||
key={post.id}
|
||
className="recent-post-item"
|
||
onClick={() => {
|
||
useAppStore.getState().setActiveView('posts');
|
||
useAppStore.getState().setSelectedPost(post.id);
|
||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: true });
|
||
}}
|
||
onDoubleClick={() => {
|
||
useAppStore.getState().setActiveView('posts');
|
||
useAppStore.getState().setSelectedPost(post.id);
|
||
useAppStore.getState().openTab({ type: 'post', id: post.id, isTransient: false });
|
||
}}
|
||
>
|
||
<span className="recent-post-title">{post.title || 'Untitled'}</span>
|
||
<span className={`recent-post-status status-${post.status}`}>{post.status}</span>
|
||
<span className="recent-post-date">{new Date(post.updatedAt).toLocaleDateString()}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const Editor: React.FC = () => {
|
||
const {
|
||
activeView,
|
||
selectedPostId,
|
||
selectedMediaId,
|
||
tabs,
|
||
activeTabId,
|
||
posts,
|
||
media,
|
||
errorModal,
|
||
hideErrorModal,
|
||
confirmDeleteModal,
|
||
hideConfirmDeleteModal,
|
||
isLoading,
|
||
setSelectedPost,
|
||
setSelectedMedia,
|
||
closeTab,
|
||
} = useAppStore();
|
||
|
||
// Get the active tab
|
||
const activeTab = tabs.find(t => t.id === activeTabId);
|
||
|
||
// Determine what to show based on active tab
|
||
// Settings and tags should only show when their tab is active, not based on activeView
|
||
// (activeView controls the sidebar, not the main content area)
|
||
const showPost = activeTab?.type === 'post';
|
||
const showMedia = activeTab?.type === 'media';
|
||
const showSettings = activeTab?.type === 'settings';
|
||
const showTags = activeTab?.type === 'tags';
|
||
const showChat = activeTab?.type === 'chat';
|
||
const showImport = activeTab?.type === 'import';
|
||
const showMetadataDiff = activeTab?.type === 'metadata-diff';
|
||
const showGitDiff = activeTab?.type === 'git-diff';
|
||
|
||
// Clear selectedPostId if the post doesn't exist (e.g., after project switch)
|
||
useEffect(() => {
|
||
if (activeView === 'posts' && selectedPostId && !isLoading) {
|
||
window.electronAPI?.posts.get(selectedPostId).then(post => {
|
||
if (!post) {
|
||
setSelectedPost(null);
|
||
}
|
||
});
|
||
}
|
||
}, [activeView, selectedPostId, isLoading, setSelectedPost]);
|
||
|
||
// Clear selectedMediaId if the media doesn't exist (e.g., after project switch)
|
||
useEffect(() => {
|
||
if (activeView === 'media' && selectedMediaId && !isLoading) {
|
||
const mediaExists = media.some(m => m.id === selectedMediaId);
|
||
if (!mediaExists) {
|
||
setSelectedMedia(null);
|
||
}
|
||
}
|
||
}, [activeView, selectedMediaId, media, isLoading, setSelectedMedia]);
|
||
|
||
// Close media tab if the media doesn't exist anymore
|
||
useEffect(() => {
|
||
if (activeTab && !isLoading) {
|
||
if (activeTab.type === 'media') {
|
||
const mediaExists = media.some(m => m.id === activeTab.id);
|
||
if (!mediaExists) {
|
||
closeTab(activeTab.id);
|
||
}
|
||
}
|
||
}
|
||
}, [activeTab, posts, media, isLoading, closeTab]);
|
||
|
||
// Show error modal if present
|
||
const renderErrorModal = () => (
|
||
<ErrorModal error={errorModal} onClose={hideErrorModal} />
|
||
);
|
||
|
||
// Show confirm delete modal if present
|
||
const renderConfirmDeleteModal = () => (
|
||
<ConfirmDeleteModal details={confirmDeleteModal} onClose={hideConfirmDeleteModal} />
|
||
);
|
||
|
||
// Show settings only if settings tab is active
|
||
if (showSettings) {
|
||
return (
|
||
<div className="editor">
|
||
<SettingsView />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show tags if tags tab is active
|
||
if (showTags) {
|
||
return (
|
||
<div className="editor">
|
||
<TagsView />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show chat if chat tab is active
|
||
if (showChat && activeTabId) {
|
||
return (
|
||
<div className="editor">
|
||
<ChatPanel key={activeTabId} conversationId={activeTabId} />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show import analysis if import tab is active
|
||
if (showImport && activeTabId) {
|
||
return (
|
||
<div className="editor">
|
||
<ImportAnalysisView key={activeTabId} definitionId={activeTabId} />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show metadata diff if metadata-diff tab is active
|
||
if (showMetadataDiff) {
|
||
return (
|
||
<div className="editor">
|
||
<MetadataDiffPanel />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show git diff view if git-diff tab is active
|
||
if (showGitDiff && activeTabId) {
|
||
const filePath = activeTabId.startsWith('git-diff:') ? activeTabId.slice('git-diff:'.length) : activeTabId;
|
||
return (
|
||
<div className="editor">
|
||
<GitDiffView key={activeTabId} filePath={filePath} />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show post editor if a post tab is active
|
||
if (showPost && activeTabId) {
|
||
return (
|
||
<div className="editor">
|
||
<PostEditor key={activeTabId} postId={activeTabId} />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Show media editor if a media tab is active
|
||
if (showMedia && activeTabId) {
|
||
return (
|
||
<div className="editor">
|
||
<MediaEditor key={activeTabId} mediaId={activeTabId} />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// No tab active - show dashboard
|
||
return (
|
||
<div className="editor">
|
||
<Dashboard />
|
||
{renderErrorModal()}
|
||
{renderConfirmDeleteModal()}
|
||
</div>
|
||
);
|
||
};
|