feat: more feature implementations

This commit is contained in:
2026-02-10 13:40:44 +01:00
parent 867b22add0
commit 9f35e74d0f
33 changed files with 4560 additions and 130 deletions

View File

@@ -0,0 +1,106 @@
.credentials-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.credentials-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
}
.credentials-tabs button {
padding: 6px 12px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.credentials-tabs button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.credentials-tabs button.active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.credentials-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.credentials-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.credentials-header h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: var(--vscode-foreground);
}
.credentials-header p {
font-size: 12px;
margin: 0;
line-height: 1.5;
}
.credentials-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.credentials-field label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
}
.credentials-field input {
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.toggle-visibility {
background: none;
border: none;
padding: 2px 4px;
cursor: pointer;
font-size: 14px;
opacity: 0.7;
transition: opacity 0.15s;
}
.toggle-visibility:hover {
opacity: 1;
}
.credentials-actions {
display: flex;
gap: 8px;
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--vscode-panel-border);
}
.credentials-actions button {
padding: 8px 16px;
font-size: 12px;
}

View File

@@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react';
import { showToast } from '../Toast';
import './CredentialsPanel.css';
interface Credentials {
tursoUrl: string;
tursoToken: string;
ftpHost?: string;
ftpUser?: string;
ftpPassword?: string;
sshHost?: string;
sshUser?: string;
sshKeyPath?: string;
}
export const CredentialsPanel: React.FC = () => {
const [credentials, setCredentials] = useState<Credentials>({
tursoUrl: '',
tursoToken: '',
ftpHost: '',
ftpUser: '',
ftpPassword: '',
sshHost: '',
sshUser: '',
sshKeyPath: '',
});
const [activeTab, setActiveTab] = useState<'sync' | 'ftp' | 'ssh'>('sync');
const [showTokens, setShowTokens] = useState(false);
// Load saved credentials (in a real app, use secure storage)
useEffect(() => {
const loadCredentials = async () => {
try {
const savedCreds = localStorage.getItem('bds-credentials');
if (savedCreds) {
setCredentials(JSON.parse(savedCreds));
}
} catch (error) {
console.error('Failed to load credentials:', error);
}
};
loadCredentials();
}, []);
const handleSave = async () => {
try {
// Save to localStorage (in production, use secure storage)
localStorage.setItem('bds-credentials', JSON.stringify(credentials));
// Configure sync if Turso credentials are set
if (credentials.tursoUrl && credentials.tursoToken) {
await window.electronAPI?.sync.configure({
tursoUrl: credentials.tursoUrl,
tursoAuthToken: credentials.tursoToken,
autoSync: true,
syncInterval: 5,
});
}
showToast.success('Credentials saved');
} catch (error) {
console.error('Failed to save credentials:', error);
showToast.error('Failed to save credentials');
}
};
const handleClear = (type: 'sync' | 'ftp' | 'ssh') => {
const newCreds = { ...credentials };
switch (type) {
case 'sync':
newCreds.tursoUrl = '';
newCreds.tursoToken = '';
break;
case 'ftp':
newCreds.ftpHost = '';
newCreds.ftpUser = '';
newCreds.ftpPassword = '';
break;
case 'ssh':
newCreds.sshHost = '';
newCreds.sshUser = '';
newCreds.sshKeyPath = '';
break;
}
setCredentials(newCreds);
};
const handleTestConnection = async (type: 'sync' | 'ftp' | 'ssh') => {
showToast.loading(`Testing ${type.toUpperCase()} connection...`);
// Simulate connection test
await new Promise(resolve => setTimeout(resolve, 1500));
// In a real implementation, this would test the actual connection
if (type === 'sync' && credentials.tursoUrl && credentials.tursoToken) {
showToast.dismiss();
showToast.success('Sync connection successful');
} else {
showToast.dismiss();
showToast.error('Connection failed - check credentials');
}
};
return (
<div className="credentials-panel">
<div className="credentials-tabs">
<button
className={activeTab === 'sync' ? 'active' : ''}
onClick={() => setActiveTab('sync')}
>
Cloud Sync
</button>
<button
className={activeTab === 'ftp' ? 'active' : ''}
onClick={() => setActiveTab('ftp')}
>
FTP
</button>
<button
className={activeTab === 'ssh' ? 'active' : ''}
onClick={() => setActiveTab('ssh')}
>
SSH
</button>
</div>
<div className="credentials-content">
{activeTab === 'sync' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>Turso/LibSQL Cloud Sync</h4>
<p className="text-muted">
Connect to Turso for cloud database synchronization.
</p>
</div>
<div className="credentials-field">
<label>Database URL</label>
<input
type="text"
placeholder="libsql://your-database.turso.io"
value={credentials.tursoUrl}
onChange={(e) => setCredentials({ ...credentials, tursoUrl: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>
Auth Token
<button
className="toggle-visibility"
onClick={() => setShowTokens(!showTokens)}
>
{showTokens ? '👁' : '👁‍🗨'}
</button>
</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Your authentication token"
value={credentials.tursoToken}
onChange={(e) => setCredentials({ ...credentials, tursoToken: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('sync')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('sync')}>
Clear
</button>
</div>
</div>
)}
{activeTab === 'ftp' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>FTP Publishing</h4>
<p className="text-muted">
Configure FTP for publishing your blog to a web server.
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<input
type="text"
placeholder="ftp.example.com"
value={credentials.ftpHost}
onChange={(e) => setCredentials({ ...credentials, ftpHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<input
type="text"
placeholder="ftp-user"
value={credentials.ftpUser}
onChange={(e) => setCredentials({ ...credentials, ftpUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Password</label>
<input
type={showTokens ? 'text' : 'password'}
placeholder="Password"
value={credentials.ftpPassword}
onChange={(e) => setCredentials({ ...credentials, ftpPassword: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('ftp')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('ftp')}>
Clear
</button>
</div>
</div>
)}
{activeTab === 'ssh' && (
<div className="credentials-form">
<div className="credentials-header">
<h4>SSH Publishing</h4>
<p className="text-muted">
Configure SSH for secure publishing to your server.
</p>
</div>
<div className="credentials-field">
<label>Host</label>
<input
type="text"
placeholder="server.example.com"
value={credentials.sshHost}
onChange={(e) => setCredentials({ ...credentials, sshHost: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>Username</label>
<input
type="text"
placeholder="ssh-user"
value={credentials.sshUser}
onChange={(e) => setCredentials({ ...credentials, sshUser: e.target.value })}
/>
</div>
<div className="credentials-field">
<label>SSH Key Path</label>
<input
type="text"
placeholder="~/.ssh/id_rsa"
value={credentials.sshKeyPath}
onChange={(e) => setCredentials({ ...credentials, sshKeyPath: e.target.value })}
/>
</div>
<div className="credentials-actions">
<button onClick={handleSave}>Save</button>
<button className="secondary" onClick={() => handleTestConnection('ssh')}>
Test Connection
</button>
<button className="secondary danger" onClick={() => handleClear('ssh')}>
Clear
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default CredentialsPanel;

View File

@@ -0,0 +1 @@
export { CredentialsPanel } from './CredentialsPanel';

View File

@@ -186,6 +186,22 @@
color: var(--vscode-button-foreground);
}
.gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
margin-left: auto;
}
.gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
@@ -196,6 +212,63 @@
line-height: 1.6;
}
.editor-preview .preview-content {
max-width: 800px;
margin: 0 auto;
}
.editor-preview h1,
.editor-preview h2,
.editor-preview h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
color: var(--vscode-foreground);
}
.editor-preview h1 { font-size: 2em; }
.editor-preview h2 { font-size: 1.5em; }
.editor-preview h3 { font-size: 1.25em; }
.editor-preview code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
}
.editor-preview pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px;
border-radius: 6px;
overflow-x: auto;
}
.editor-preview pre code {
background: none;
padding: 0;
}
.editor-preview blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 16px;
margin-left: 0;
color: var(--vscode-descriptionForeground);
}
.editor-preview a {
color: var(--vscode-textLink-foreground);
}
.editor-preview a:hover {
color: var(--vscode-textLink-activeForeground);
}
.editor-preview img {
max-width: 100%;
border-radius: 6px;
cursor: pointer;
}
.editor-field-row {
display: flex;
gap: 12px;

View File

@@ -2,8 +2,43 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import MonacoEditor from '@monaco-editor/react';
import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast';
import { WysiwygEditor } from '../WysiwygEditor';
import { Lightbox, useMarkdownImages } from '../Lightbox';
import { PostLinks } from '../PostLinks';
import './Editor.css';
// Simple markdown to HTML converter for preview
const markdownToHtml = (markdown: string): string => {
return markdown
// Escape HTML
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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 />');
};
type EditorMode = 'markdown' | 'wysiwyg' | 'preview';
interface PostEditorProps {
post: PostData;
}
@@ -15,9 +50,14 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const [tags, setTags] = useState(post.tags.join(', '));
const [categories, setCategories] = useState(post.categories.join(', '));
const [isDirty, setIsDirty] = useState(false);
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown');
const [editorMode, setEditorMode] = useState<EditorMode>('wysiwyg');
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const editorRef = useRef<unknown>(null);
// Extract images from content for lightbox
const images = useMarkdownImages(content);
// Reset when post changes
useEffect(() => {
setTitle(post.title);
@@ -200,27 +240,59 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
/>
</div>
</div>
<PostLinks
postId={post.id}
onPostClick={(id) => useAppStore.getState().setSelectedPost(id)}
/>
</div>
<div className="editor-body">
<div className="editor-toolbar">
<label>Content (Markdown)</label>
<label>Content</label>
<div className="editor-mode-toggle">
<button
className={editorMode === 'wysiwyg' ? 'active' : ''}
onClick={() => setEditorMode('wysiwyg')}
title="Visual editor"
>
Visual
</button>
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => setEditorMode('markdown')}
title="Markdown source"
>
Markdown
</button>
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => setEditorMode('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>
)}
</div>
{editorMode === 'markdown' ? (
{editorMode === 'wysiwyg' && (
<WysiwygEditor
content={content}
onChange={setContent}
placeholder="Start writing..."
/>
)}
{editorMode === 'markdown' && (
<MonacoEditor
height="100%"
defaultLanguage="markdown"
@@ -244,13 +316,25 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
cursorBlinking: 'smooth',
}}
/>
) : (
)}
{editorMode === 'preview' && (
<div className="editor-preview markdown-body">
{/* Simple markdown preview - could be enhanced with a proper renderer */}
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre>
<div
className="preview-content"
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
/>
</div>
)}
</div>
{/* Lightbox for viewing images in content */}
<Lightbox
images={images}
initialIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</div>
<div className="editor-footer">

View File

@@ -0,0 +1,253 @@
/* Lightbox Overlay */
.lightbox-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.lightbox-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
/* Image Container */
.lightbox-image-container {
max-width: 90%;
max-height: 80%;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-image-container.zoomed {
max-width: 100%;
max-height: 100%;
overflow: auto;
cursor: zoom-out;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
cursor: zoom-in;
animation: scaleIn 0.2s ease-out;
}
.lightbox-image-container.zoomed .lightbox-image {
max-width: none;
max-height: none;
cursor: zoom-out;
}
@keyframes scaleIn {
from {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Close Button */
.lightbox-close {
position: absolute;
top: 16px;
right: 16px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background-color 0.2s;
z-index: 10;
}
.lightbox-close:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Navigation Arrows */
.lightbox-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
color: white;
cursor: pointer;
transition: background-color 0.2s;
z-index: 10;
}
.lightbox-nav:hover {
background: rgba(255, 255, 255, 0.2);
}
.lightbox-prev {
left: 16px;
}
.lightbox-next {
right: 16px;
}
/* Footer */
.lightbox-footer {
position: absolute;
bottom: 60px;
left: 50%;
transform: translateX(-50%);
text-align: center;
color: white;
max-width: 80%;
}
.lightbox-caption {
font-size: 14px;
margin: 0 0 8px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.lightbox-counter {
font-size: 12px;
opacity: 0.7;
}
/* Thumbnails */
.lightbox-thumbnails {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 8px;
padding: 8px;
background: rgba(0, 0, 0, 0.5);
border-radius: 8px;
}
.lightbox-thumbnail {
width: 48px;
height: 48px;
padding: 0;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.2s, opacity 0.2s;
opacity: 0.6;
}
.lightbox-thumbnail:hover {
opacity: 0.9;
}
.lightbox-thumbnail.active {
border-color: white;
opacity: 1;
}
.lightbox-thumbnail img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Image Gallery Grid */
.image-gallery {
display: grid;
gap: 8px;
border-radius: 8px;
overflow: hidden;
}
.image-gallery.gallery-2 {
grid-template-columns: repeat(2, 1fr);
}
.image-gallery.gallery-3 {
grid-template-columns: repeat(3, 1fr);
}
.image-gallery.gallery-4 {
grid-template-columns: repeat(2, 1fr);
}
.gallery-item {
aspect-ratio: 1;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.gallery-item:hover {
transform: scale(1.02);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Single Image */
.single-image {
cursor: pointer;
display: inline-block;
max-width: 100%;
}
.single-image img {
max-width: 100%;
border-radius: 6px;
transition: box-shadow 0.2s;
}
.single-image:hover img {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.image-caption {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-align: center;
margin-top: 8px;
}

View File

@@ -0,0 +1,235 @@
import React, { useState, useEffect, useCallback } from 'react';
import './Lightbox.css';
interface LightboxImage {
src: string;
alt?: string;
caption?: string;
}
interface LightboxProps {
images: LightboxImage[];
initialIndex?: number;
isOpen: boolean;
onClose: () => void;
}
export const Lightbox: React.FC<LightboxProps> = ({
images,
initialIndex = 0,
isOpen,
onClose,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isZoomed, setIsZoomed] = useState(false);
useEffect(() => {
setCurrentIndex(initialIndex);
}, [initialIndex]);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
break;
case 'ArrowRight':
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
break;
}
}, [isOpen, images.length, onClose]);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
if (!isOpen || images.length === 0) {
return null;
}
const currentImage = images[currentIndex];
const hasMultiple = images.length > 1;
const handlePrev = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
};
const handleNext = () => {
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const toggleZoom = () => {
setIsZoomed(!isZoomed);
};
return (
<div className="lightbox-overlay" onClick={handleBackdropClick}>
<div className="lightbox-container">
{/* Close button */}
<button className="lightbox-close" onClick={onClose} title="Close (Esc)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
{/* Navigation arrows */}
{hasMultiple && (
<>
<button className="lightbox-nav lightbox-prev" onClick={handlePrev} title="Previous (←)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
<button className="lightbox-nav lightbox-next" onClick={handleNext} title="Next (→)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
</svg>
</button>
</>
)}
{/* Main image */}
<div className={`lightbox-image-container ${isZoomed ? 'zoomed' : ''}`}>
<img
src={currentImage.src}
alt={currentImage.alt || ''}
onClick={toggleZoom}
className="lightbox-image"
/>
</div>
{/* Caption and counter */}
<div className="lightbox-footer">
{currentImage.caption && (
<p className="lightbox-caption">{currentImage.caption}</p>
)}
{hasMultiple && (
<div className="lightbox-counter">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
{/* Thumbnail strip for galleries */}
{hasMultiple && images.length <= 10 && (
<div className="lightbox-thumbnails">
{images.map((image, index) => (
<button
key={index}
className={`lightbox-thumbnail ${index === currentIndex ? 'active' : ''}`}
onClick={() => setCurrentIndex(index)}
>
<img src={image.src} alt={image.alt || ''} />
</button>
))}
</div>
)}
</div>
</div>
);
};
// Hook to extract images from markdown content
export function useMarkdownImages(content: string): LightboxImage[] {
const [images, setImages] = useState<LightboxImage[]>([]);
useEffect(() => {
// Match markdown image syntax: ![alt](src)
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g;
const matches: LightboxImage[] = [];
let match;
while ((match = imageRegex.exec(content)) !== null) {
matches.push({
alt: match[1] || undefined,
src: match[2],
});
}
setImages(matches);
}, [content]);
return images;
}
// Component to render images with lightbox support
interface ImageGalleryProps {
images: LightboxImage[];
}
export const ImageGallery: React.FC<ImageGalleryProps> = ({ images }) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
if (images.length === 0) {
return null;
}
const openLightbox = (index: number) => {
setSelectedIndex(index);
setLightboxOpen(true);
};
if (images.length === 1) {
return (
<>
<div className="single-image" onClick={() => openLightbox(0)}>
<img src={images[0].src} alt={images[0].alt || ''} />
{images[0].caption && <p className="image-caption">{images[0].caption}</p>}
</div>
<Lightbox
images={images}
initialIndex={selectedIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</>
);
}
return (
<>
<div className={`image-gallery gallery-${Math.min(images.length, 4)}`}>
{images.map((image, index) => (
<div
key={index}
className="gallery-item"
onClick={() => openLightbox(index)}
>
<img src={image.src} alt={image.alt || ''} />
</div>
))}
</div>
<Lightbox
images={images}
initialIndex={selectedIndex}
isOpen={lightboxOpen}
onClose={() => setLightboxOpen(false)}
/>
</>
);
};
export default Lightbox;

View File

@@ -0,0 +1 @@
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';

View File

@@ -0,0 +1,107 @@
.post-links {
margin-top: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
background-color: var(--vscode-sideBar-background);
overflow: hidden;
}
.post-links-loading {
padding: 8px 12px;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.post-links-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: none;
border: none;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.post-links-toggle:hover {
background-color: var(--vscode-list-hoverBackground);
}
.post-links-icon {
font-size: 14px;
}
.post-links-count {
flex: 1;
text-align: left;
color: var(--vscode-descriptionForeground);
}
.post-links-chevron {
font-size: 10px;
color: var(--vscode-descriptionForeground);
transition: transform 0.15s;
}
.post-links-content {
border-top: 1px solid var(--vscode-panel-border);
padding: 8px 0;
}
.post-links-section {
padding: 4px 0;
}
.post-links-section:not(:last-child) {
border-bottom: 1px solid var(--vscode-panel-border);
margin-bottom: 4px;
padding-bottom: 8px;
}
.post-links-heading {
display: flex;
align-items: center;
gap: 6px;
margin: 0 0 4px 0;
padding: 0 12px;
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.post-links-arrow {
font-size: 12px;
}
.post-links-list {
list-style: none;
margin: 0;
padding: 0;
}
.post-link-item {
display: block;
width: 100%;
padding: 4px 12px 4px 24px;
background: none;
border: none;
color: var(--vscode-textLink-foreground);
font-size: 12px;
text-align: left;
cursor: pointer;
transition: background-color 0.15s;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.post-link-item:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}

View File

@@ -0,0 +1,117 @@
import React, { useState, useEffect } from 'react';
import './PostLinks.css';
interface PostLinkInfo {
id: string;
title: string;
slug: string;
}
interface PostLinksProps {
postId: string;
onPostClick?: (postId: string) => void;
}
export const PostLinks: React.FC<PostLinksProps> = ({ postId, onPostClick }) => {
const [linksTo, setLinksTo] = useState<PostLinkInfo[]>([]);
const [linkedBy, setLinkedBy] = useState<PostLinkInfo[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const loadLinks = async () => {
setLoading(true);
try {
const [to, by] = await Promise.all([
window.electronAPI?.posts.getLinksTo(postId),
window.electronAPI?.posts.getLinkedBy(postId),
]);
setLinksTo(to || []);
setLinkedBy(by || []);
} catch (error) {
console.error('Failed to load post links:', error);
} finally {
setLoading(false);
}
};
loadLinks();
}, [postId]);
const totalLinks = linksTo.length + linkedBy.length;
if (loading) {
return (
<div className="post-links">
<div className="post-links-loading">Loading links...</div>
</div>
);
}
if (totalLinks === 0) {
return null;
}
return (
<div className="post-links">
<button
className={`post-links-toggle ${expanded ? 'expanded' : ''}`}
onClick={() => setExpanded(!expanded)}
>
<span className="post-links-icon">🔗</span>
<span className="post-links-count">{totalLinks} link{totalLinks !== 1 ? 's' : ''}</span>
<span className="post-links-chevron">{expanded ? '▼' : '▶'}</span>
</button>
{expanded && (
<div className="post-links-content">
{linksTo.length > 0 && (
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Links to ({linksTo.length})
</h4>
<ul className="post-links-list">
{linksTo.map(link => (
<li key={link.id}>
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
>
{link.title}
</button>
</li>
))}
</ul>
</div>
)}
{linkedBy.length > 0 && (
<div className="post-links-section">
<h4 className="post-links-heading">
<span className="post-links-arrow"></span>
Linked by ({linkedBy.length})
</h4>
<ul className="post-links-list">
{linkedBy.map(link => (
<li key={link.id}>
<button
className="post-link-item"
onClick={() => onPostClick?.(link.id)}
title={`Open: ${link.title}`}
>
{link.title}
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
);
};
export default PostLinks;

View File

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

View File

@@ -0,0 +1,73 @@
.resizable-panel {
position: relative;
display: flex;
flex-shrink: 0;
}
.resizable-panel.horizontal {
flex-direction: row;
height: 100%;
}
.resizable-panel.vertical {
flex-direction: column;
width: 100%;
}
.resizable-panel.resizing {
pointer-events: none;
}
.resizable-panel.resizing .resizable-panel-content {
pointer-events: none;
}
.resizable-panel-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Resizer handle */
.resizer {
flex-shrink: 0;
background: transparent;
transition: background-color 0.15s;
z-index: 10;
}
.resizer.horizontal {
width: 4px;
cursor: col-resize;
}
.resizer.vertical {
height: 4px;
cursor: row-resize;
}
.resizer:hover,
.resizable-panel.resizing .resizer {
background-color: var(--vscode-sash-hoverBorder, #0078d4);
}
/* Double-click to reset */
.resizer::after {
content: '';
position: absolute;
}
.resizer.horizontal::after {
top: 0;
bottom: 0;
left: -2px;
right: -2px;
}
.resizer.vertical::after {
left: 0;
right: 0;
top: -2px;
bottom: -2px;
}

View File

@@ -0,0 +1,123 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import './ResizablePanel.css';
interface ResizablePanelProps {
children: React.ReactNode;
direction: 'horizontal' | 'vertical';
initialSize: number;
minSize?: number;
maxSize?: number;
storageKey?: string;
className?: string;
resizerPosition?: 'start' | 'end';
}
export const ResizablePanel: React.FC<ResizablePanelProps> = ({
children,
direction,
initialSize,
minSize = 150,
maxSize = 600,
storageKey,
className = '',
resizerPosition = 'end',
}) => {
// Load saved size from localStorage
const getSavedSize = () => {
if (storageKey) {
const saved = localStorage.getItem(`bds-panel-${storageKey}`);
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= minSize && parsed <= maxSize) {
return parsed;
}
}
}
return initialSize;
};
const [size, setSize] = useState(getSavedSize);
const [isResizing, setIsResizing] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const startPosRef = useRef(0);
const startSizeRef = useRef(0);
// Save size to localStorage
useEffect(() => {
if (storageKey && !isResizing) {
localStorage.setItem(`bds-panel-${storageKey}`, size.toString());
}
}, [size, storageKey, isResizing]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startPosRef.current = direction === 'horizontal' ? e.clientX : e.clientY;
startSizeRef.current = size;
}, [direction, size]);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (!isResizing) return;
const currentPos = direction === 'horizontal' ? e.clientX : e.clientY;
let delta = currentPos - startPosRef.current;
// Reverse delta if resizer is at start
if (resizerPosition === 'start') {
delta = -delta;
}
const newSize = Math.max(minSize, Math.min(maxSize, startSizeRef.current + delta));
setSize(newSize);
}, [isResizing, direction, minSize, maxSize, resizerPosition]);
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = direction === 'horizontal' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing, handleMouseMove, handleMouseUp, direction]);
const style: React.CSSProperties = direction === 'horizontal'
? { width: size }
: { height: size };
return (
<div
ref={panelRef}
className={`resizable-panel ${direction} ${className} ${isResizing ? 'resizing' : ''}`}
style={style}
>
{resizerPosition === 'start' && (
<div
className={`resizer ${direction}`}
onMouseDown={handleMouseDown}
/>
)}
<div className="resizable-panel-content">
{children}
</div>
{resizerPosition === 'end' && (
<div
className={`resizer ${direction}`}
onMouseDown={handleMouseDown}
/>
)}
</div>
);
};
export default ResizablePanel;

View File

@@ -0,0 +1 @@
export { ResizablePanel } from './ResizablePanel';

View File

@@ -67,7 +67,10 @@
}
.sidebar-item {
padding: 6px 12px 6px 24px;
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 12px 6px 12px;
cursor: pointer;
border-left: 2px solid transparent;
}
@@ -81,6 +84,39 @@
border-left-color: var(--vscode-focusBorder);
}
.post-type-icon {
font-size: 14px;
line-height: 1.4;
flex-shrink: 0;
opacity: 0.85;
}
.sidebar-item-content {
flex: 1;
min-width: 0;
}
/* Post type specific styling */
.sidebar-item.post-type-picture {
background: linear-gradient(90deg, rgba(139, 92, 246, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-aside {
background: linear-gradient(90deg, rgba(245, 158, 11, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-quote {
background: linear-gradient(90deg, rgba(34, 197, 94, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-link {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.05) 0%, transparent 100%);
}
.sidebar-item.post-type-video {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 100%);
}
.sidebar-item-title {
font-size: 13px;
color: var(--vscode-sideBar-foreground);

View File

@@ -14,6 +14,28 @@ const formatFileSize = (bytes: number) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
// Get post type icon based on categories
const getPostTypeIcon = (categories: string[]): { icon: string; type: string } => {
const lowerCategories = categories.map(c => c.toLowerCase());
if (lowerCategories.includes('picture') || lowerCategories.includes('photo') || lowerCategories.includes('image')) {
return { icon: '🖼️', type: 'picture' };
}
if (lowerCategories.includes('aside') || lowerCategories.includes('note') || lowerCategories.includes('quick')) {
return { icon: '📝', type: 'aside' };
}
if (lowerCategories.includes('link') || lowerCategories.includes('bookmark')) {
return { icon: '🔗', type: 'link' };
}
if (lowerCategories.includes('video')) {
return { icon: '🎬', type: 'video' };
}
if (lowerCategories.includes('quote')) {
return { icon: '💬', type: 'quote' };
}
// Default to article
return { icon: '📄', type: 'article' };
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface CalendarViewProps {
@@ -399,16 +421,22 @@ const PostsList: React.FC = () => {
Drafts ({groupedPosts.draft.length})
</div>
<div className="sidebar-list">
{groupedPosts.draft.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
{groupedPosts.draft.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
</div>
);
})}
</div>
</div>
)}
@@ -420,16 +448,22 @@ const PostsList: React.FC = () => {
Published ({groupedPosts.published.length})
</div>
<div className="sidebar-list">
{groupedPosts.published.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div>
))}
{groupedPosts.published.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt)}</div>
</div>
</div>
);
})}
</div>
</div>
)}
@@ -441,16 +475,22 @@ const PostsList: React.FC = () => {
Archived ({groupedPosts.archived.length})
</div>
<div className="sidebar-list">
{groupedPosts.archived.map(post => (
<div
key={post.id}
className={`sidebar-item ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
))}
{groupedPosts.archived.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${selectedPostId === post.id ? 'selected' : ''}`}
onClick={() => setSelectedPost(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt)}</div>
</div>
</div>
);
})}
</div>
</div>
)}

View File

@@ -0,0 +1,230 @@
.task-popup-wrapper {
position: relative;
}
.task-popup-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-statusBar-foreground);
font-size: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.task-popup-trigger:hover {
background-color: var(--vscode-statusBarItem-hoverBackground);
}
.task-popup-trigger.active {
background-color: var(--vscode-statusBarItem-activeBackground);
}
.task-popup {
position: absolute;
bottom: 100%;
left: 0;
margin-bottom: 8px;
width: 360px;
max-height: 400px;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
overflow: hidden;
animation: slideUp 0.15s ease-out;
z-index: 100;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.task-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.task-popup-header h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--vscode-foreground);
}
.task-popup-header .text-button {
background: none;
border: none;
color: var(--vscode-textLink-foreground);
font-size: 11px;
cursor: pointer;
padding: 0;
}
.task-popup-header .text-button:hover {
text-decoration: underline;
}
.task-section {
padding: 8px 0;
}
.task-section:not(:last-child) {
border-bottom: 1px solid var(--vscode-panel-border);
}
.task-section-title {
padding: 4px 16px 8px;
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.task-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
gap: 12px;
}
.task-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.task-item-info {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1;
min-width: 0;
}
.task-item-details {
flex: 1;
min-width: 0;
}
.task-item-message {
font-size: 12px;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-item-error {
font-size: 11px;
color: var(--vscode-notificationsErrorIcon-foreground);
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress-bar {
height: 4px;
background-color: var(--vscode-progressBar-background);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-button-background);
border-radius: 2px;
transition: width 0.3s ease-out;
}
.task-cancel {
background: none;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 12px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
}
.task-item:hover .task-cancel {
opacity: 1;
}
.task-cancel:hover {
background-color: var(--vscode-toolbar-hoverBackground);
color: var(--vscode-notificationsErrorIcon-foreground);
}
.task-time {
font-size: 11px;
color: var(--vscode-descriptionForeground);
flex-shrink: 0;
}
.task-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 12px;
flex-shrink: 0;
}
.task-icon.success {
color: var(--vscode-testing-iconPassed);
}
.task-icon.error {
color: var(--vscode-notificationsErrorIcon-foreground);
}
.task-icon.pending {
color: var(--vscode-descriptionForeground);
}
.task-icon.cancelled {
color: var(--vscode-descriptionForeground);
}
.task-spinner {
width: 14px;
height: 14px;
border: 2px solid var(--vscode-panel-border);
border-top-color: var(--vscode-button-background);
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.task-empty {
padding: 24px 16px;
text-align: center;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,190 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore } from '../../store';
import './TaskPopup.css';
export const TaskPopup: React.FC = () => {
const { tasks } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const popupRef = useRef<HTMLDivElement>(null);
const runningTasks = tasks.filter(t => t.status === 'running');
const pendingTasks = tasks.filter(t => t.status === 'pending');
const recentTasks = tasks
.filter(t => t.status === 'completed' || t.status === 'failed')
.sort((a, b) => {
const aTime = a.endTime ? new Date(a.endTime).getTime() : 0;
const bTime = b.endTime ? new Date(b.endTime).getTime() : 0;
return bTime - aTime;
})
.slice(0, 5);
const hasActiveTasks = runningTasks.length > 0 || pendingTasks.length > 0;
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const handleCancel = async (taskId: string) => {
await window.electronAPI?.tasks.cancel(taskId);
};
const handleClearCompleted = async () => {
await window.electronAPI?.tasks.clearCompleted();
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <span className="task-spinner" />;
case 'completed':
return <span className="task-icon success"></span>;
case 'failed':
return <span className="task-icon error"></span>;
case 'pending':
return <span className="task-icon pending"></span>;
case 'cancelled':
return <span className="task-icon cancelled"></span>;
default:
return null;
}
};
if (!hasActiveTasks && recentTasks.length === 0) {
return null;
}
return (
<div className="task-popup-wrapper" ref={popupRef}>
<button
className={`task-popup-trigger ${hasActiveTasks ? 'active' : ''}`}
onClick={() => setIsOpen(!isOpen)}
title={`${runningTasks.length} running, ${pendingTasks.length} pending`}
>
{runningTasks.length > 0 ? (
<>
<span className="task-spinner" />
<span>{runningTasks.length} running</span>
</>
) : pendingTasks.length > 0 ? (
<>
<span className="task-icon pending"></span>
<span>{pendingTasks.length} pending</span>
</>
) : (
<span>Tasks</span>
)}
</button>
{isOpen && (
<div className="task-popup">
<div className="task-popup-header">
<h4>Background Tasks</h4>
{recentTasks.length > 0 && (
<button className="text-button" onClick={handleClearCompleted}>
Clear completed
</button>
)}
</div>
{runningTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Running</div>
{runningTasks.map(task => (
<div key={task.taskId} className="task-item running">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
</div>
)}
{pendingTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Pending</div>
{pendingTasks.map(task => (
<div key={task.taskId} className="task-item pending">
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
</div>
</div>
<button
className="task-cancel"
onClick={() => handleCancel(task.taskId)}
title="Cancel task"
>
</button>
</div>
))}
</div>
)}
{recentTasks.length > 0 && (
<div className="task-section">
<div className="task-section-title">Recent</div>
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item ${task.status}`}>
<div className="task-item-info">
{getStatusIcon(task.status)}
<div className="task-item-details">
<div className="task-item-message">{task.message}</div>
{task.error && (
<div className="task-item-error">{task.error}</div>
)}
</div>
</div>
{task.endTime && (
<span className="task-time">{formatTime(task.endTime)}</span>
)}
</div>
))}
</div>
)}
{runningTasks.length === 0 && pendingTasks.length === 0 && recentTasks.length === 0 && (
<div className="task-empty">No active tasks</div>
)}
</div>
)}
</div>
);
};
export default TaskPopup;

View File

@@ -0,0 +1 @@
export { TaskPopup } from './TaskPopup';

View File

@@ -0,0 +1,296 @@
.wysiwyg-editor {
display: flex;
flex-direction: column;
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
overflow: hidden;
}
/* Toolbar */
.wysiwyg-toolbar {
display: flex;
align-items: center;
padding: 8px 12px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
flex-wrap: wrap;
gap: 4px;
}
.toolbar-group {
display: flex;
align-items: center;
gap: 2px;
}
.toolbar-divider {
width: 1px;
height: 20px;
background-color: var(--vscode-panel-border);
margin: 0 8px;
}
.wysiwyg-toolbar button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.wysiwyg-toolbar button:hover:not(:disabled) {
background-color: var(--vscode-toolbar-hoverBackground);
}
.wysiwyg-toolbar button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.wysiwyg-toolbar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Editor Content */
.wysiwyg-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.wysiwyg-content .ProseMirror {
outline: none;
min-height: 100%;
color: var(--vscode-editor-foreground);
font-size: 15px;
line-height: 1.7;
}
.wysiwyg-content .ProseMirror > * + * {
margin-top: 0.75em;
}
/* Placeholder */
.wysiwyg-content .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--vscode-descriptionForeground);
pointer-events: none;
height: 0;
}
/* Typography */
.wysiwyg-content h1 {
font-size: 2em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content h2 {
font-size: 1.5em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content h3 {
font-size: 1.25em;
font-weight: 600;
margin-top: 1em;
margin-bottom: 0.5em;
color: var(--vscode-editor-foreground);
}
.wysiwyg-content p {
margin: 0.5em 0;
}
.wysiwyg-content strong {
font-weight: 600;
}
.wysiwyg-content em {
font-style: italic;
}
.wysiwyg-content u {
text-decoration: underline;
}
.wysiwyg-content s {
text-decoration: line-through;
}
/* Links */
.wysiwyg-content a,
.wysiwyg-content .editor-link {
color: var(--vscode-textLink-foreground);
text-decoration: underline;
cursor: pointer;
}
.wysiwyg-content a:hover {
color: var(--vscode-textLink-activeForeground);
}
/* Lists */
.wysiwyg-content ul,
.wysiwyg-content ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.wysiwyg-content li {
margin: 0.25em 0;
}
.wysiwyg-content ul {
list-style-type: disc;
}
.wysiwyg-content ol {
list-style-type: decimal;
}
/* Blockquote */
.wysiwyg-content blockquote {
border-left: 3px solid var(--vscode-textBlockQuote-border);
padding-left: 1em;
margin: 1em 0;
color: var(--vscode-textBlockQuote-foreground);
font-style: italic;
}
/* Code */
.wysiwyg-content code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 0.9em;
}
.wysiwyg-content pre {
background-color: var(--vscode-textCodeBlock-background);
padding: 12px 16px;
border-radius: 6px;
overflow-x: auto;
margin: 1em 0;
}
.wysiwyg-content pre code {
padding: 0;
background: none;
font-size: 0.9em;
line-height: 1.5;
}
/* Images */
.wysiwyg-content img,
.wysiwyg-content .editor-image {
max-width: 100%;
height: auto;
border-radius: 6px;
margin: 1em 0;
cursor: pointer;
transition: box-shadow 0.2s;
}
.wysiwyg-content img:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Horizontal Rule */
.wysiwyg-content hr {
border: none;
border-top: 1px solid var(--vscode-panel-border);
margin: 2em 0;
}
/* Bubble Menu */
.bubble-menu {
display: flex;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
padding: 4px;
gap: 2px;
}
.bubble-menu button {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 4px 8px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 13px;
cursor: pointer;
}
.bubble-menu button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.bubble-menu button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.bubble-menu .divider {
width: 1px;
background-color: var(--vscode-panel-border);
margin: 2px 4px;
}
/* Floating Menu */
.floating-menu {
display: flex;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
padding: 4px;
gap: 2px;
}
.floating-menu button {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
background-color: transparent;
border: none;
border-radius: 4px;
color: var(--vscode-foreground);
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.floating-menu button:hover {
background-color: var(--vscode-toolbar-hoverBackground);
}
.floating-menu button.is-active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}

View File

@@ -0,0 +1,379 @@
import React, { useEffect, useCallback } from 'react';
import { useEditor, EditorContent, BubbleMenu, FloatingMenu } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Underline from '@tiptap/extension-underline';
import Placeholder from '@tiptap/extension-placeholder';
import TurndownService from 'turndown';
import './WysiwygEditor.css';
// Convert HTML to Markdown
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
bulletListMarker: '-',
});
// Add custom rules for turndown
turndownService.addRule('strikethrough', {
filter: ['del', 's', 'strike'],
replacement: (content) => `~~${content}~~`,
});
interface WysiwygEditorProps {
content: string;
onChange: (markdown: string) => void;
placeholder?: string;
}
// Simple markdown to HTML converter for TipTap
function markdownToHtml(markdown: string): string {
// Simple markdown parser - for production use a proper library
let html = markdown
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
// Strikethrough
.replace(/~~(.+?)~~/g, '<del>$1</del>')
// Code blocks
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="$1">$2</code></pre>')
// Inline code
.replace(/`([^`]+)`/g, '<code>$1</code>')
// Links
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
// Images
.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Blockquotes
.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>')
// Unordered lists
.replace(/^\s*[-*+] (.+)$/gim, '<li>$1</li>')
// Ordered lists
.replace(/^\d+\. (.+)$/gim, '<li>$1</li>')
// Horizontal rule
.replace(/^(?:---+|___+|\*\*\*+)$/gim, '<hr>')
// Paragraphs (double newlines)
.replace(/\n\n/g, '</p><p>')
// Single line breaks
.replace(/\n/g, '<br>');
// Wrap in paragraphs if not starting with a block element
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
// Fix consecutive blockquotes
html = html.replace(/<\/blockquote>\s*<blockquote>/g, '<br>');
// Wrap list items in ul/ol
html = html.replace(/(<li>.*<\/li>)+/g, (match) => {
// Check if it's ordered (starts with number) or unordered
return '<ul>' + match + '</ul>';
});
return html;
}
export const WysiwygEditor: React.FC<WysiwygEditorProps> = ({
content,
onChange,
placeholder = 'Start writing your content...',
}) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'editor-link',
},
}),
Image.configure({
HTMLAttributes: {
class: 'editor-image',
},
}),
Underline,
Placeholder.configure({
placeholder,
}),
],
content: markdownToHtml(content),
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const markdown = turndownService.turndown(html);
onChange(markdown);
},
});
useEffect(() => {
if (editor && content) {
const currentHtml = editor.getHTML();
const newHtml = markdownToHtml(content);
// Only update if content is significantly different
if (turndownService.turndown(currentHtml) !== content) {
editor.commands.setContent(newHtml);
}
}
}, [content]);
const addImage = useCallback(() => {
const url = window.prompt('Enter image URL:');
if (url && editor) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
const setLink = useCallback(() => {
if (!editor) return;
const previousUrl = editor.getAttributes('link').href;
const url = window.prompt('Enter URL:', previousUrl);
if (url === null) return;
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}, [editor]);
if (!editor) {
return null;
}
return (
<div className="wysiwyg-editor">
{/* Bubble menu appears when text is selected */}
{editor && (
<BubbleMenu className="bubble-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="Bold"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="Italic"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') ? 'is-active' : ''}
title="Underline"
>
<u>U</u>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
title="Strikethrough"
>
<s>S</s>
</button>
<div className="divider" />
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Link">
🔗
</button>
<button
onClick={() => editor.chain().focus().toggleCode().run()}
className={editor.isActive('code') ? 'is-active' : ''}
title="Code"
>
{'</>'}
</button>
</BubbleMenu>
)}
{/* Floating menu appears on empty lines */}
{editor && (
<FloatingMenu className="floating-menu" editor={editor} tippyOptions={{ duration: 100 }}>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
>
H3
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
>
List
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'is-active' : ''}
>
Quote
</button>
<button onClick={addImage}>
🖼 Image
</button>
</FloatingMenu>
)}
{/* Toolbar */}
<div className="wysiwyg-toolbar">
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
className={editor.isActive('heading', { level: 1 }) ? 'is-active' : ''}
title="Heading 1"
>
H1
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
className={editor.isActive('heading', { level: 2 }) ? 'is-active' : ''}
title="Heading 2"
>
H2
</button>
<button
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
className={editor.isActive('heading', { level: 3 }) ? 'is-active' : ''}
title="Heading 3"
>
H3
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBold().run()}
className={editor.isActive('bold') ? 'is-active' : ''}
title="Bold (Ctrl+B)"
>
<strong>B</strong>
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
className={editor.isActive('italic') ? 'is-active' : ''}
title="Italic (Ctrl+I)"
>
<em>I</em>
</button>
<button
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={editor.isActive('underline') ? 'is-active' : ''}
title="Underline (Ctrl+U)"
>
<u>U</u>
</button>
<button
onClick={() => editor.chain().focus().toggleStrike().run()}
className={editor.isActive('strike') ? 'is-active' : ''}
title="Strikethrough"
>
<s>S</s>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive('bulletList') ? 'is-active' : ''}
title="Bullet List"
>
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'is-active' : ''}
title="Numbered List"
>
1.
</button>
<button
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive('blockquote') ? 'is-active' : ''}
title="Quote"
>
</button>
<button
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive('codeBlock') ? 'is-active' : ''}
title="Code Block"
>
{'{}'}
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button onClick={setLink} className={editor.isActive('link') ? 'is-active' : ''} title="Insert Link">
🔗
</button>
<button onClick={addImage} title="Insert Image">
🖼
</button>
<button
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
</button>
</div>
<div className="toolbar-divider" />
<div className="toolbar-group">
<button
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
</button>
<button
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
</button>
</div>
</div>
{/* Editor Content */}
<EditorContent className="wysiwyg-content" editor={editor} />
</div>
);
};
export default WysiwygEditor;

View File

@@ -0,0 +1 @@
export { WysiwygEditor } from './WysiwygEditor';

View File

@@ -5,3 +5,9 @@ export { StatusBar } from './StatusBar';
export { Panel } from './Panel';
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
export { ProjectSelector } from './ProjectSelector';
export { WysiwygEditor } from './WysiwygEditor';
export { Lightbox, ImageGallery, useMarkdownImages } from './Lightbox';
export { TaskPopup } from './TaskPopup';
export { ResizablePanel } from './ResizablePanel';
export { CredentialsPanel } from './CredentialsPanel';
export { PostLinks } from './PostLinks';

View File

@@ -1,4 +1,8 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Storage key for persisted state
const STORAGE_KEY = 'bds-app-state';
// Types
export interface ProjectData {
@@ -113,84 +117,99 @@ interface AppState {
setError: (error: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// Initial Project State
projects: [],
activeProject: null,
// Initial UI State
activeView: 'posts',
sidebarVisible: true,
panelVisible: false,
selectedPostId: null,
selectedMediaId: null,
// Initial Data
posts: [],
media: [],
tasks: [],
// Initial Sync State
syncStatus: 'idle',
syncConfigured: false,
pendingChanges: { posts: 0, media: 0 },
// Initial Loading State
isLoading: false,
error: null,
// Project Actions
setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }),
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
updateProject: (id, updatedProject) => set((state) => ({
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
})),
removeProject: (id) => set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
})),
// UI Actions
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
setSelectedPost: (id) => set({ selectedPostId: id }),
setSelectedMedia: (id) => set({ selectedMediaId: id }),
// Post Actions
setPosts: (posts) => set({ posts }),
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
updatePost: (id, updatedPost) => set((state) => ({
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
})),
removePost: (id) => set((state) => ({
posts: state.posts.filter((p) => p.id !== id),
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
})),
// Media Actions
setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
updateMedia: (id, updatedMedia) => set((state) => ({
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
})),
removeMedia: (id) => set((state) => ({
media: state.media.filter((m) => m.id !== id),
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
})),
// Task Actions
setTasks: (tasks) => set({ tasks }),
updateTask: (taskId, task) => set((state) => ({
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
})),
// Sync Actions
setSyncStatus: (syncStatus) => set({ syncStatus }),
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
}));
export const useAppStore = create<AppState>()(
persist(
(set) => ({
// Initial Project State
projects: [],
activeProject: null,
// Initial UI State
activeView: 'posts',
sidebarVisible: true,
panelVisible: false,
selectedPostId: null,
selectedMediaId: null,
// Initial Data
posts: [],
media: [],
tasks: [],
// Initial Sync State
syncStatus: 'idle',
syncConfigured: false,
pendingChanges: { posts: 0, media: 0 },
// Initial Loading State
isLoading: false,
error: null,
// Project Actions
setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }),
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
updateProject: (id, updatedProject) => set((state) => ({
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
})),
removeProject: (id) => set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
})),
// UI Actions
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),
togglePanel: () => set((state) => ({ panelVisible: !state.panelVisible })),
setSelectedPost: (id) => set({ selectedPostId: id }),
setSelectedMedia: (id) => set({ selectedMediaId: id }),
// Post Actions
setPosts: (posts) => set({ posts }),
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
updatePost: (id, updatedPost) => set((state) => ({
posts: state.posts.map((p) => (p.id === id ? { ...p, ...updatedPost } : p)),
})),
removePost: (id) => set((state) => ({
posts: state.posts.filter((p) => p.id !== id),
selectedPostId: state.selectedPostId === id ? null : state.selectedPostId,
})),
// Media Actions
setMedia: (media) => set({ media }),
addMedia: (media) => set((state) => ({ media: [...state.media, media] })),
updateMedia: (id, updatedMedia) => set((state) => ({
media: state.media.map((m) => (m.id === id ? { ...m, ...updatedMedia } : m)),
})),
removeMedia: (id) => set((state) => ({
media: state.media.filter((m) => m.id !== id),
selectedMediaId: state.selectedMediaId === id ? null : state.selectedMediaId,
})),
// Task Actions
setTasks: (tasks) => set({ tasks }),
updateTask: (taskId, task) => set((state) => ({
tasks: state.tasks.map((t) => (t.taskId === taskId ? { ...t, ...task } : t)),
})),
// Sync Actions
setSyncStatus: (syncStatus) => set({ syncStatus }),
setSyncConfigured: (syncConfigured) => set({ syncConfigured }),
setPendingChanges: (pendingChanges) => set({ pendingChanges }),
// Loading Actions
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
}),
{
name: STORAGE_KEY,
// Only persist UI state, not data (which is loaded from backend)
partialize: (state) => ({
activeView: state.activeView,
sidebarVisible: state.sidebarVisible,
panelVisible: state.panelVisible,
selectedPostId: state.selectedPostId,
selectedMediaId: state.selectedMediaId,
}),
}
)
);