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

@@ -606,3 +606,187 @@
font-size: 11px;
white-space: nowrap;
}
/* Linked Posts Section in Media Editor */
.linked-posts-section label {
display: flex;
justify-content: space-between;
align-items: center;
}
.add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
.add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
.post-picker {
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
margin-top: 8px;
max-height: 200px;
overflow-y: auto;
}
.post-picker-list {
padding: 4px;
}
.post-picker-item {
padding: 6px 8px;
cursor: pointer;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.post-picker-item:hover {
background: var(--vscode-list-hoverBackground);
}
.post-picker-more {
padding: 6px 8px;
color: var(--vscode-descriptionForeground);
font-size: 11px;
font-style: italic;
}
.no-posts,
.no-linked-posts {
padding: 12px 8px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
font-style: italic;
}
.linked-posts-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.linked-post-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--vscode-sideBar-background);
border-radius: 4px;
}
.linked-post-title {
cursor: pointer;
font-size: 12px;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.linked-post-title:hover {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
}
.linked-post-item .unlink-btn {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
padding: 0 4px;
font-size: 14px;
opacity: 0;
transition: opacity 0.1s;
}
.linked-post-item:hover .unlink-btn {
opacity: 1;
}
.linked-post-item .unlink-btn:hover {
color: var(--vscode-errorForeground);
}
/* Gallery Macro Styles for Preview */
.macro-gallery {
margin: 16px 0;
}
.gallery-container {
display: grid;
gap: 8px;
}
.macro-gallery.gallery-cols-1 .gallery-container { grid-template-columns: 1fr; }
.macro-gallery.gallery-cols-2 .gallery-container { grid-template-columns: repeat(2, 1fr); }
.macro-gallery.gallery-cols-3 .gallery-container { grid-template-columns: repeat(3, 1fr); }
.macro-gallery.gallery-cols-4 .gallery-container { grid-template-columns: repeat(4, 1fr); }
.macro-gallery.gallery-cols-5 .gallery-container { grid-template-columns: repeat(5, 1fr); }
.macro-gallery.gallery-cols-6 .gallery-container { grid-template-columns: repeat(6, 1fr); }
.gallery-item {
aspect-ratio: 1;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
background: var(--vscode-input-background);
transition: transform 0.1s, box-shadow 0.1s;
}
.gallery-item:hover {
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.gallery-loading,
.gallery-empty,
.gallery-error {
padding: 24px;
text-align: center;
color: var(--vscode-descriptionForeground);
font-style: italic;
background: var(--vscode-input-background);
border-radius: 4px;
}
.gallery-error {
color: var(--vscode-errorForeground);
}
.gallery-caption {
margin-top: 8px;
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: 13px;
font-style: italic;
}
.macro-error {
color: var(--vscode-errorForeground);
background: var(--vscode-inputValidation-errorBackground);
padding: 4px 8px;
border-radius: 3px;
}
.macro-loading {
color: var(--vscode-descriptionForeground);
font-style: italic;
}

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>

View File

@@ -0,0 +1,285 @@
/**
* LinkedMediaPanel Styles
*/
.linked-media-panel {
background: var(--color-bg-secondary, #252526);
border-radius: 4px;
margin-bottom: 1rem;
}
.linked-media-panel.collapsed {
cursor: pointer;
}
.linked-media-panel.collapsed:hover {
background: var(--color-bg-hover, #2a2d2e);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #3c3c3c);
cursor: pointer;
}
.panel-title {
font-weight: 600;
font-size: 13px;
color: var(--color-text-primary, #ccc);
}
.panel-actions {
display: flex;
gap: 4px;
align-items: center;
}
.panel-action {
background: none;
border: none;
color: var(--color-text-secondary, #8b8b8b);
cursor: pointer;
padding: 2px 6px;
font-size: 14px;
border-radius: 3px;
}
.panel-action:hover {
background: var(--color-bg-hover, #3c3c3c);
color: var(--color-text-primary, #ccc);
}
.expand-icon,
.collapse-icon {
font-size: 10px;
color: var(--color-text-secondary, #8b8b8b);
margin-left: 4px;
}
.panel-content {
padding: 12px;
max-height: 300px;
overflow-y: auto;
}
/* Media Grid */
.linked-media-panel .media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px;
}
.linked-media-panel .media-item {
position: relative;
background: var(--color-bg-tertiary, #1e1e1e);
border-radius: 4px;
overflow: hidden;
transition: transform 0.1s, box-shadow 0.1s;
}
.linked-media-panel .media-item:hover {
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.linked-media-panel .media-item.drag-over {
box-shadow: 0 0 0 2px var(--color-accent, #007acc);
}
.linked-media-panel .media-item[draggable="true"] {
cursor: grab;
}
.linked-media-panel .media-item[draggable="true"]:active {
cursor: grabbing;
}
.linked-media-panel .media-thumbnail {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary, #252526);
cursor: pointer;
}
.linked-media-panel .media-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
.linked-media-panel .media-thumbnail .media-icon {
font-size: 24px;
opacity: 0.7;
}
.linked-media-panel .media-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px;
gap: 4px;
}
.linked-media-panel .media-name {
font-size: 10px;
color: var(--color-text-secondary, #8b8b8b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.linked-media-panel .unlink-btn {
background: none;
border: none;
color: var(--color-text-secondary, #8b8b8b);
cursor: pointer;
padding: 0 2px;
font-size: 12px;
opacity: 0;
transition: opacity 0.1s;
}
.linked-media-panel .media-item:hover .unlink-btn {
opacity: 1;
}
.linked-media-panel .unlink-btn:hover {
color: var(--color-error, #f14c4c);
}
.linked-media-panel .media-order {
position: absolute;
top: 4px;
left: 4px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 2px;
pointer-events: none;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 20px;
color: var(--color-text-secondary, #8b8b8b);
}
.empty-state p {
margin-bottom: 12px;
font-size: 13px;
}
.empty-state button {
background: var(--color-accent, #007acc);
border: none;
color: white;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.empty-state button:hover {
background: var(--color-accent-hover, #0587d4);
}
/* Loading */
.loading {
text-align: center;
padding: 20px;
color: var(--color-text-secondary, #8b8b8b);
font-size: 13px;
}
/* Media Picker */
.media-picker {
border-top: 1px solid var(--color-border, #3c3c3c);
background: var(--color-bg-tertiary, #1e1e1e);
}
.media-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--color-border, #3c3c3c);
font-size: 12px;
color: var(--color-text-secondary, #8b8b8b);
}
.media-picker-header button {
background: none;
border: none;
color: var(--color-text-secondary, #8b8b8b);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
}
.media-picker-header button:hover {
color: var(--color-text-primary, #ccc);
}
.media-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 6px;
padding: 12px;
max-height: 200px;
overflow-y: auto;
}
.media-picker-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: background 0.1s;
}
.media-picker-item:hover {
background: var(--color-bg-hover, #2a2d2e);
}
.media-picker-item img {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 3px;
}
.media-picker-item .media-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: var(--color-bg-secondary, #252526);
border-radius: 3px;
}
.media-picker-item .media-name {
margin-top: 4px;
font-size: 9px;
text-align: center;
max-width: 60px;
}
.no-media {
grid-column: 1 / -1;
text-align: center;
padding: 16px;
color: var(--color-text-secondary, #8b8b8b);
font-size: 12px;
}

View File

@@ -0,0 +1,275 @@
/**
* LinkedMediaPanel Component
*
* Displays media files linked to a post with the ability to:
* - View linked media in a grid/list
* - Import new media files (automatically linked to post)
* - Unlink media files from post
* - Reorder media files via drag and drop
* - Link existing media to the post
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, MediaData } from '../../store';
import { showToast } from '../Toast';
import './LinkedMediaPanel.css';
interface LinkedMediaPanelProps {
postId: string;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
export const LinkedMediaPanel: React.FC<LinkedMediaPanelProps> = ({
postId,
collapsed = false,
onToggleCollapse,
}) => {
const [linkedMedia, setLinkedMedia] = useState<MediaData[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [showMediaPicker, setShowMediaPicker] = useState(false);
const { media: allMedia } = useAppStore();
// Load linked media for this post
const loadLinkedMedia = useCallback(async () => {
if (!postId) return;
try {
setIsLoading(true);
const mediaData = await window.electronAPI?.postMedia.getMediaDataForPost(postId);
setLinkedMedia(mediaData || []);
} catch (error) {
console.error('Failed to load linked media:', error);
} finally {
setIsLoading(false);
}
}, [postId]);
useEffect(() => {
loadLinkedMedia();
}, [loadLinkedMedia]);
// Handle importing new media with auto-link
const handleImportMedia = async () => {
try {
// Get imported media using the standard dialog
const imported = await window.electronAPI?.media.importDialog();
if (!imported || imported.length === 0) return;
// Link each imported media to this post
for (const media of imported) {
await window.electronAPI?.postMedia.link(postId, media.id);
}
showToast.success(`Imported and linked ${imported.length} file(s)`);
// Refresh the linked media list
loadLinkedMedia();
} catch (error) {
console.error('Failed to import media:', error);
showToast.error('Failed to import media');
}
};
// Handle unlinking media
const handleUnlink = async (mediaId: string) => {
try {
await window.electronAPI?.postMedia.unlink(postId, mediaId);
showToast.success('Media unlinked from post');
loadLinkedMedia();
} catch (error) {
console.error('Failed to unlink media:', error);
showToast.error('Failed to unlink media');
}
};
// Handle linking existing media
const handleLinkExisting = async (mediaId: string) => {
try {
await window.electronAPI?.postMedia.link(postId, mediaId);
showToast.success('Media linked to post');
setShowMediaPicker(false);
loadLinkedMedia();
} catch (error) {
console.error('Failed to link media:', error);
showToast.error('Failed to link media');
}
};
// Drag and drop reordering
const handleDragStart = (e: React.DragEvent, index: number) => {
e.dataTransfer.setData('text/plain', index.toString());
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverIndex(index);
};
const handleDragLeave = () => {
setDragOverIndex(null);
};
const handleDrop = async (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
setDragOverIndex(null);
const sourceIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (sourceIndex === targetIndex) return;
// Build new order
const newOrder = [...linkedMedia];
const [removed] = newOrder.splice(sourceIndex, 1);
newOrder.splice(targetIndex, 0, removed);
const mediaIds = newOrder.map(m => m.id);
try {
await window.electronAPI?.postMedia.reorder(postId, mediaIds);
setLinkedMedia(newOrder);
} catch (error) {
console.error('Failed to reorder media:', error);
loadLinkedMedia(); // Revert on failure
}
};
// Handle click on media item to open media viewer
const handleMediaClick = (mediaId: string) => {
useAppStore.getState().openTab({ type: 'media', id: mediaId, isTransient: true });
};
// Get thumbnail URL for a media item
const getThumbnailUrl = (media: MediaData): string | null => {
if (media.mimeType?.startsWith('image/')) {
return `bds-media://${media.id}`;
}
return null;
};
// Get unlinked media (for picker)
const unlinkedMedia = allMedia.filter(
m => !linkedMedia.find(l => l.id === m.id)
);
if (collapsed) {
return (
<div className="linked-media-panel collapsed" onClick={onToggleCollapse}>
<div className="panel-header">
<span className="panel-title">
📷 Media ({linkedMedia.length})
</span>
<span className="expand-icon"></span>
</div>
</div>
);
}
return (
<div className="linked-media-panel">
<div className="panel-header" onClick={onToggleCollapse}>
<span className="panel-title">📷 Linked Media</span>
<div className="panel-actions">
<button
className="panel-action"
onClick={(e) => { e.stopPropagation(); handleImportMedia(); }}
title="Import and link media"
>
+
</button>
<button
className="panel-action"
onClick={(e) => { e.stopPropagation(); setShowMediaPicker(!showMediaPicker); }}
title="Link existing media"
>
🔗
</button>
{onToggleCollapse && <span className="collapse-icon"></span>}
</div>
</div>
{showMediaPicker && (
<div className="media-picker">
<div className="media-picker-header">
<span>Select media to link</span>
<button onClick={() => setShowMediaPicker(false)}>×</button>
</div>
<div className="media-picker-grid">
{unlinkedMedia.length === 0 ? (
<div className="no-media">No unlinked media available</div>
) : (
unlinkedMedia.map(media => (
<div
key={media.id}
className="media-picker-item"
onClick={() => handleLinkExisting(media.id)}
title={media.originalName}
>
{media.mimeType?.startsWith('image/') ? (
<img src={`bds-media://${media.id}`} alt={media.originalName} />
) : (
<div className="media-icon">📄</div>
)}
<span className="media-name">{media.originalName}</span>
</div>
))
)}
</div>
</div>
)}
<div className="panel-content">
{isLoading ? (
<div className="loading">Loading...</div>
) : linkedMedia.length === 0 ? (
<div className="empty-state">
<p>No media linked to this post</p>
<button onClick={handleImportMedia}>Import Media</button>
</div>
) : (
<div className="media-grid">
{linkedMedia.map((media, index) => (
<div
key={media.id}
className={`media-item ${dragOverIndex === index ? 'drag-over' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, index)}
>
<div
className="media-thumbnail"
onClick={() => handleMediaClick(media.id)}
>
{getThumbnailUrl(media) ? (
<img src={getThumbnailUrl(media)!} alt={media.originalName} />
) : (
<div className="media-icon">📄</div>
)}
</div>
<div className="media-info">
<span className="media-name" title={media.originalName}>
{media.originalName}
</span>
<button
className="unlink-btn"
onClick={(e) => { e.stopPropagation(); handleUnlink(media.id); }}
title="Unlink from post"
>
×
</button>
</div>
<div className="media-order">{index + 1}</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default LinkedMediaPanel;

View File

@@ -0,0 +1,2 @@
export { LinkedMediaPanel } from './LinkedMediaPanel';
export { default } from './LinkedMediaPanel';

View File

@@ -15,5 +15,6 @@ export { SettingsView } from './SettingsView';
export { TagsView, scrollToTagsSection, type TagsCategory } from './TagsView';
export { TagInput } from './TagInput';
export { PostLinks } from './PostLinks';
export { LinkedMediaPanel } from './LinkedMediaPanel';
export { ErrorModal, type ErrorDetails } from './ErrorModal';
export { ChatPanel } from './ChatPanel';

View File

@@ -1,12 +1,16 @@
/**
* Gallery Macro
*
* Renders an image gallery from a linked media file or folder.
* Renders an image gallery from linked media files for a post.
* Uses the post-media linking system to display media attached to the current post.
* Images are clickable to open in a lightbox.
*
* Usage: [[gallery link="media/photos" columns="3" caption="My Photos"]]
* Usage:
* [[gallery]] - Shows all linked media for current post
* [[gallery columns="4"]] - Custom column count
* [[gallery caption="My Photos"]] - With caption
*
* Parameters:
* - link (required): Path to media file or folder
* - columns: Number of columns (default: 3)
* - caption: Gallery caption
*/
@@ -16,12 +20,9 @@ import type { MacroDefinition, MacroParams, MacroRenderContext } from '../types'
const galleryMacro: MacroDefinition = {
name: 'gallery',
description: 'Renders an image gallery from linked media',
description: 'Renders an image gallery from linked media files with lightbox support',
validate(params: MacroParams): string | undefined {
if (!params.link) {
return 'Gallery macro requires a "link" parameter';
}
if (params.columns) {
const cols = parseInt(params.columns, 10);
if (isNaN(cols) || cols < 1 || cols > 6) {
@@ -32,41 +33,37 @@ const galleryMacro: MacroDefinition = {
},
editorPreview(params: MacroParams): string {
const link = params.link || '?';
return `📷 Gallery: ${link}`;
const cols = params.columns || '3';
return `📷 Gallery (${cols} cols)`;
},
render(params: MacroParams, context: MacroRenderContext): string {
const { link, columns = '3', caption } = params;
const { columns = '3', caption } = params;
const colCount = parseInt(columns, 10) || 3;
// Build the gallery HTML
// Build the gallery HTML with lightbox support
const classes = ['macro-gallery', `gallery-cols-${colCount}`];
if (context.isPreview) {
classes.push('gallery-preview');
// Data attributes for hydration - JS will load linked media and populate
const dataAttrs = [
`data-columns="${colCount}"`,
`data-lightbox="true"`,
];
if (context.postId) {
dataAttrs.push(`data-post-id="${context.postId}"`);
}
let html = `<div class="${classes.join(' ')}" data-link="${link}">`;
let html = `<div class="${classes.join(' ')}" ${dataAttrs.join(' ')}>`;
// In preview mode, show a placeholder
// In production, this would load actual images
if (context.isPreview) {
html += `<div class="gallery-placeholder">`;
html += `<span class="gallery-icon">🖼️</span>`;
html += `<span class="gallery-info">Gallery: ${link}</span>`;
if (caption) {
html += `<span class="gallery-caption">${caption}</span>`;
}
html += `</div>`;
} else {
// Production render would load images here
// For now, create a placeholder that frontend JS can hydrate
html += `<div class="gallery-container" data-columns="${colCount}">`;
html += `<!-- Gallery images loaded dynamically from: ${link} -->`;
html += `</div>`;
if (caption) {
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
}
// Gallery container that will be populated by hydration script
// The hydration script uses: window.electronAPI.postMedia.getMediaDataForPost(postId)
// and renders images with bds-media:// protocol
html += `<div class="gallery-container gallery-lightbox">`;
html += `<div class="gallery-loading">Loading gallery...</div>`;
html += `</div>`;
if (caption) {
html += `<figcaption class="gallery-caption">${caption}</figcaption>`;
}
html += `</div>`;

View File

@@ -172,6 +172,16 @@ export interface SyncTagsResult {
added: string[];
}
// Post-Media Link types
export interface MediaLinkData {
id: string;
projectId: string;
postId: string;
mediaId: string;
sortOrder: number;
createdAt: string;
}
// Chat/AI types
export interface ChatConversation {
id: string;
@@ -282,6 +292,17 @@ export interface ElectronAPI {
regenerateThumbnails: (id: string) => Promise<Record<string, string> | null>;
regenerateMissingThumbnails: () => Promise<{ processed: number; generated: number; failed: number }>;
};
postMedia: {
link: (postId: string, mediaId: string) => Promise<MediaLinkData>;
unlink: (postId: string, mediaId: string) => Promise<void>;
getForPost: (postId: string) => Promise<MediaLinkData[]>;
getForMedia: (mediaId: string) => Promise<MediaLinkData[]>;
getMediaDataForPost: (postId: string) => Promise<MediaData[]>;
reorder: (postId: string, mediaIds: string[]) => Promise<void>;
isLinked: (postId: string, mediaId: string) => Promise<boolean>;
import: (postId: string, filePath: string) => Promise<MediaLinkData>;
rebuild: () => Promise<void>;
};
sync: {
configure: (config: SyncConfig) => Promise<void>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;