feat: gallery macro
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, '<')
|
||||
.replace(/>/g, '>')
|
||||
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>
|
||||
|
||||
285
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css
Normal file
285
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.css
Normal 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;
|
||||
}
|
||||
275
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx
Normal file
275
src/renderer/components/LinkedMediaPanel/LinkedMediaPanel.tsx
Normal 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;
|
||||
2
src/renderer/components/LinkedMediaPanel/index.ts
Normal file
2
src/renderer/components/LinkedMediaPanel/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { LinkedMediaPanel } from './LinkedMediaPanel';
|
||||
export { default } from './LinkedMediaPanel';
|
||||
@@ -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';
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
21
src/renderer/types/electron.d.ts
vendored
21
src/renderer/types/electron.d.ts
vendored
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user