feat: gallery macro

This commit is contained in:
2026-02-12 16:52:44 +01:00
parent 5c6fcb46ef
commit 924a165fb3
16 changed files with 1846 additions and 46 deletions

View File

@@ -5,12 +5,14 @@ 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 { SettingsView } from '../SettingsView';
import { TagsView } from '../TagsView';
import { TagInput } from '../TagInput';
import { ChatPanel } from '../ChatPanel';
import { AutoSaveManager } from '../../utils';
import { parseMacros, getMacro } from '../../macros/registry';
import './Editor.css';
// Module-level AutoSaveManager for idle-time based auto-saving
@@ -103,12 +105,41 @@ const resolveMediaUrls = (content: string, mediaList: MediaData[]): string => {
});
};
// Render a macro synchronously for preview
const renderMacroSync = (name: string, params: Record<string, string>, postId?: string): string => {
const macro = getMacro(name);
if (!macro) {
return `<span class="macro-error">Unknown macro: ${name}</span>`;
}
try {
const result = macro.render(params, { postId, isPreview: true });
// If it returns a promise, show loading state (shouldn't happen for gallery)
if (result instanceof Promise) {
return `<div class="macro-loading">Loading ${name}...</div>`;
}
return result;
} catch (e) {
return `<span class="macro-error">Error rendering ${name}</span>`;
}
};
// Simple markdown to HTML converter for preview
const markdownToHtml = (markdown: string): string => {
return markdown
// Escape HTML
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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>')
@@ -133,6 +164,69 @@ const markdownToHtml = (markdown: string): string => {
.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 mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
if (!mediaData || mediaData.length === 0) {
galleryContainer.innerHTML = '<div class="gallery-empty">No media linked to this post</div>';
continue;
}
// Filter to images only
const images = mediaData.filter(m => m.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((media, index) => `
<div class="gallery-item" data-index="${index}">
<img
src="bds-media://${media.id}"
alt="${media.alt || media.originalName}"
title="${media.originalName}"
/>
</div>
`).join('');
// Set up lightbox click handlers
const items = galleryContainer.querySelectorAll('.gallery-item');
const imageData = images.map(m => ({
src: `bds-media://${m.id}`,
alt: m.alt || m.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>';
}
}
};
interface PostEditorProps {
post: PostData;
}
@@ -159,7 +253,9 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
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 editorRef = useRef<unknown>(null);
const previewRef = useRef<HTMLDivElement>(null);
const isDirty = checkIsDirty(post.id);
@@ -188,6 +284,34 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
// 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 when in preview mode
useEffect(() => {
if (editorMode !== 'preview' || !previewRef.current) return;
// Small delay to ensure DOM is updated
const timer = setTimeout(() => {
if (previewRef.current) {
hydrateGalleries(
previewRef.current,
post.id,
(index, imgs) => {
setGalleryImages(imgs);
setLightboxIndex(index);
setLightboxOpen(true);
}
);
}
}, 100);
return () => clearTimeout(timer);
}, [editorMode, post.id, resolvedContent]);
// Track latest values for auto-save on unmount/switch
const pendingChangesRef = useRef<{
@@ -512,6 +636,8 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
postId={post.id}
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
/>
<LinkedMediaPanel postId={post.id} />
</div>
<div className="editor-body">
@@ -586,10 +712,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
)}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body">
<div className="editor-preview markdown-body" ref={previewRef}>
<div
className="preview-content"
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent) }}
dangerouslySetInnerHTML={{ __html: markdownToHtml(resolvedContent, post.id) }}
/>
</div>
)}
@@ -597,10 +723,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
{/* Lightbox for viewing images in content */}
<Lightbox
images={images}
images={allImages}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
onClose={() => { setLightboxOpen(false); setGalleryImages([]); }}
/>
</div>
@@ -622,12 +748,71 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia, showErrorModal } = useAppStore();
const { media, posts, updateMedia, showErrorModal, openTab } = useAppStore();
const item = media.find(m => m.id === mediaId);
const [alt, setAlt] = useState(item?.alt || '');
const [caption, setCaption] = useState(item?.caption || '');
const [tags, setTags] = useState(item?.tags.join(', ') || '');
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
const [showPostPicker, setShowPostPicker] = useState(false);
// Load linked posts for this media
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 })));
}
} catch (error) {
console.error('Failed to load linked posts:', error);
}
};
loadLinkedPosts();
}, [mediaId]);
// Get post titles for display
const getPostTitle = (postId: string): string => {
const post = posts.find(p => p.id === postId);
return post?.title || 'Untitled';
};
// Handle linking to a new post
const handleLinkToPost = async (postId: string) => {
try {
await window.electronAPI?.postMedia.link(postId, mediaId);
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
setShowPostPicker(false);
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
const unlinkedPosts = posts.filter(
p => !linkedPosts.find(l => l.postId === p.id)
);
useEffect(() => {
if (item) {
@@ -768,6 +953,70 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
placeholder="tag1, tag2, tag3"
/>
</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">
{unlinkedPosts.length === 0 ? (
<div className="no-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 || 'Untitled'}
</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>
</div>