initial commit

This commit is contained in:
2026-02-10 11:04:44 +01:00
commit 5979fa3374
57 changed files with 19344 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
.activity-bar {
width: 48px;
height: 100%;
background-color: var(--vscode-activityBar-background);
display: flex;
flex-direction: column;
justify-content: space-between;
border-right: 1px solid var(--vscode-panel-border);
}
.activity-bar-top,
.activity-bar-bottom {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 0;
}
.activity-bar-item {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--vscode-activityBar-foreground);
opacity: 0.6;
cursor: pointer;
position: relative;
padding: 0;
border-radius: 0;
}
.activity-bar-item:hover {
opacity: 1;
background: transparent;
}
.activity-bar-item.active {
opacity: 1;
}
.activity-bar-item.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: var(--vscode-activityBar-foreground);
}
.activity-bar-item.syncing svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.activity-bar-badge {
position: absolute;
top: 8px;
right: 8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
background-color: var(--vscode-activityBarBadge-background);
color: var(--vscode-activityBarBadge-foreground);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { useAppStore } from '../../store';
import './ActivityBar.css';
// Simple SVG icons
const PostsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/>
<path d="M8 12h8v2H8zm0 4h8v2H8z"/>
</svg>
);
const MediaIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
);
const SettingsIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>
);
const SyncIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/>
</svg>
);
export const ActivityBar: React.FC = () => {
const { activeView, setActiveView, syncStatus, pendingChanges } = useAppStore();
const totalPending = pendingChanges.posts + pendingChanges.media;
return (
<div className="activity-bar">
<div className="activity-bar-top">
<button
className={`activity-bar-item ${activeView === 'posts' ? 'active' : ''}`}
onClick={() => setActiveView('posts')}
title="Posts"
>
<PostsIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'media' ? 'active' : ''}`}
onClick={() => setActiveView('media')}
title="Media"
>
<MediaIcon />
</button>
</div>
<div className="activity-bar-bottom">
<button
className={`activity-bar-item ${syncStatus === 'syncing' ? 'syncing' : ''}`}
onClick={() => window.electronAPI?.sync.start()}
title={`Sync (${totalPending} pending)`}
>
<SyncIcon />
{totalPending > 0 && (
<span className="activity-bar-badge">{totalPending}</span>
)}
</button>
<button
className={`activity-bar-item ${activeView === 'settings' ? 'active' : ''}`}
onClick={() => setActiveView('settings')}
title="Settings"
>
<SettingsIcon />
</button>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,298 @@
.editor {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
overflow: hidden;
}
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
height: 35px;
background-color: var(--vscode-tab-activeBackground);
border-bottom: 1px solid var(--vscode-panel-border);
}
.editor-tabs {
display: flex;
align-items: center;
gap: 2px;
}
.editor-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--vscode-tab-inactiveBackground);
color: var(--vscode-tab-inactiveForeground);
font-size: 13px;
border-radius: 4px 4px 0 0;
}
.editor-tab.active {
background-color: var(--vscode-tab-activeBackground);
color: var(--vscode-tab-activeForeground);
}
.editor-tab-dirty {
color: var(--vscode-notificationsWarningIcon-foreground);
font-size: 10px;
}
.editor-actions {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.status-badge.status-draft {
background-color: rgba(204, 167, 0, 0.2);
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-badge.status-published {
background-color: rgba(115, 201, 145, 0.2);
color: var(--vscode-testing-iconPassed);
}
.status-badge.status-archived {
background-color: rgba(133, 133, 133, 0.2);
color: var(--vscode-descriptionForeground);
}
.editor-actions button {
padding: 4px 10px;
font-size: 12px;
}
.editor-actions button.danger:hover {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
padding: 16px;
overflow-y: auto;
gap: 16px;
}
.editor-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.editor-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 200px;
}
.editor-field label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-field input,
.editor-field textarea {
padding: 8px 10px;
border-radius: 4px;
}
.editor-field input.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.editor-field-row {
display: flex;
gap: 12px;
}
.editor-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 300px;
}
.editor-body label {
font-size: 11px;
font-weight: 500;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.editor-body textarea {
flex: 1;
resize: none;
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
line-height: 1.5;
padding: 12px;
border-radius: 4px;
}
.editor-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-top: 1px solid var(--vscode-panel-border);
background-color: var(--vscode-sideBar-background);
}
/* Media Editor */
.media-editor {
flex-direction: row;
gap: 24px;
}
.media-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 8px;
min-height: 300px;
}
.media-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vscode-descriptionForeground);
}
.media-details {
width: 320px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-details textarea {
resize: vertical;
}
/* Empty State / Welcome */
.editor-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-editor-background);
}
.welcome-content {
max-width: 600px;
text-align: center;
}
.welcome-content h1 {
font-size: 28px;
font-weight: 400;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-content > p {
margin-bottom: 40px;
}
.welcome-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 40px;
}
.welcome-action {
padding: 20px;
background-color: var(--vscode-sideBar-background);
border-radius: 8px;
text-align: left;
}
.welcome-action h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: var(--vscode-editor-foreground);
}
.welcome-action p {
font-size: 12px;
color: var(--vscode-descriptionForeground);
margin-bottom: 16px;
line-height: 1.5;
}
.welcome-action button {
width: 100%;
}
.welcome-shortcuts {
text-align: left;
background-color: var(--vscode-sideBar-background);
padding: 20px;
border-radius: 8px;
}
.welcome-shortcuts h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shortcut-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.shortcut-item kbd {
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
}
.shortcut-item span {
color: var(--vscode-descriptionForeground);
}

View File

@@ -0,0 +1,405 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAppStore, PostData } from '../../store';
import './Editor.css';
interface PostEditorProps {
post: PostData;
}
const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const { updatePost } = useAppStore();
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', '));
const [isDirty, setIsDirty] = useState(false);
// Reset when post changes
useEffect(() => {
setTitle(post.title);
setContent(post.content);
setTags(post.tags.join(', '));
setIsDirty(false);
}, [post.id]);
// Track changes
useEffect(() => {
const hasChanges =
title !== post.title ||
content !== post.content ||
tags !== post.tags.join(', ');
setIsDirty(hasChanges);
}, [title, content, tags, post]);
const handleSave = useCallback(async () => {
if (!isDirty) return;
try {
const updated = await window.electronAPI?.posts.update(post.id, {
title,
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
setIsDirty(false);
}
} catch (error) {
console.error('Failed to save post:', error);
}
}, [post.id, title, content, tags, isDirty, updatePost]);
const handlePublish = async () => {
await handleSave();
try {
const updated = await window.electronAPI?.posts.publish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to publish post:', error);
}
};
const handleUnpublish = async () => {
try {
const updated = await window.electronAPI?.posts.unpublish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
}
} catch (error) {
console.error('Failed to unpublish post:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this post?')) {
try {
await window.electronAPI?.posts.delete(post.id);
useAppStore.getState().removePost(post.id);
} catch (error) {
console.error('Failed to delete post:', error);
}
}
};
// Save on Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSave();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSave]);
// Listen for menu events
useEffect(() => {
const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave);
const unsubscribePublish = window.electronAPI?.on('menu:publishSelected', handlePublish);
const unsubscribeUnpublish = window.electronAPI?.on('menu:unpublishSelected', handleUnpublish);
return () => {
unsubscribeSave?.();
unsubscribePublish?.();
unsubscribeUnpublish?.();
};
}, [handleSave]);
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className={`editor-tab active ${isDirty ? 'dirty' : ''}`}>
<span className="editor-tab-title">{post.title || 'Untitled'}</span>
{isDirty && <span className="editor-tab-dirty"></span>}
</div>
</div>
<div className="editor-actions">
<span className={`status-badge status-${post.status}`}>
{post.status}
</span>
{post.status === 'draft' ? (
<button onClick={handlePublish} title="Publish">Publish</button>
) : (
<button onClick={handleUnpublish} className="secondary" title="Unpublish">
Unpublish
</button>
)}
<button onClick={handleSave} disabled={!isDirty} title="Save (Ctrl+S)">
Save
</button>
<button onClick={handleDelete} className="secondary danger" title="Delete">
Delete
</button>
</div>
</div>
<div className="editor-content">
<div className="editor-meta">
<div className="editor-field">
<label>Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
/>
</div>
<div className="editor-field">
<label>Slug</label>
<input
type="text"
value={post.slug}
disabled
className="disabled"
/>
</div>
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</div>
</div>
<div className="editor-body">
<label>Content (Markdown)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write your post content in Markdown..."
spellCheck
/>
</div>
</div>
<div className="editor-footer">
<span className="text-muted text-small">
Created: {new Date(post.createdAt).toLocaleString()}
</span>
<span className="text-muted text-small">
Updated: {new Date(post.updatedAt).toLocaleString()}
</span>
{post.publishedAt && (
<span className="text-muted text-small">
Published: {new Date(post.publishedAt).toLocaleString()}
</span>
)}
</div>
</div>
);
};
const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
const { media, updateMedia } = 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(', ') || '');
useEffect(() => {
if (item) {
setAlt(item.alt || '');
setCaption(item.caption || '');
setTags(item.tags.join(', '));
}
}, [item?.id]);
if (!item) {
return <div className="editor-empty">Media not found</div>;
}
const handleSave = async () => {
try {
const updated = await window.electronAPI?.media.update(item.id, {
alt,
caption,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
});
if (updated) {
updateMedia(item.id, updated as Partial<typeof item>);
}
} catch (error) {
console.error('Failed to update media:', error);
}
};
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this media file?')) {
try {
await window.electronAPI?.media.delete(item.id);
useAppStore.getState().removeMedia(item.id);
} catch (error) {
console.error('Failed to delete media:', error);
}
}
};
return (
<div className="editor">
<div className="editor-header">
<div className="editor-tabs">
<div className="editor-tab active">
<span className="editor-tab-title">{item.originalName}</span>
</div>
</div>
<div className="editor-actions">
<button onClick={handleSave}>Save</button>
<button onClick={handleDelete} className="secondary danger">Delete</button>
</div>
</div>
<div className="editor-content media-editor">
<div className="media-preview">
{item.mimeType.startsWith('image/') ? (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
<span>{item.originalName}</span>
</div>
) : (
<div className="media-preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
<span>{item.originalName}</span>
</div>
)}
</div>
<div className="media-details">
<div className="editor-field">
<label>File Name</label>
<input type="text" value={item.originalName} disabled className="disabled" />
</div>
<div className="editor-field">
<label>Type</label>
<input type="text" value={item.mimeType} disabled className="disabled" />
</div>
<div className="editor-field-row">
<div className="editor-field">
<label>Size</label>
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
</div>
{item.width && item.height && (
<div className="editor-field">
<label>Dimensions</label>
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
</div>
)}
</div>
<div className="editor-field">
<label>Alt Text</label>
<input
type="text"
value={alt}
onChange={(e) => setAlt(e.target.value)}
placeholder="Describe the image for accessibility"
/>
</div>
<div className="editor-field">
<label>Caption</label>
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Image caption"
rows={3}
/>
</div>
<div className="editor-field">
<label>Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1, tag2, tag3"
/>
</div>
</div>
</div>
</div>
);
};
const WelcomeScreen: React.FC = () => {
return (
<div className="editor-empty">
<div className="welcome-content">
<h1>Blogging Desktop Server</h1>
<p className="text-muted">bDS - Your offline-first blogging platform</p>
<div className="welcome-actions">
<div className="welcome-action">
<h3>Create a New Post</h3>
<p>Start writing your next blog post with Markdown support.</p>
<button onClick={() => window.electronAPI?.posts.create({ title: 'New Post' })}>
New Post
</button>
</div>
<div className="welcome-action">
<h3>Import Media</h3>
<p>Add images and files to use in your posts.</p>
<button className="secondary" onClick={() => window.electronAPI?.media.importDialog()}>
Import Media
</button>
</div>
<div className="welcome-action">
<h3>Configure Sync</h3>
<p>Connect to Turso for cloud synchronization.</p>
<button className="secondary" onClick={() => useAppStore.getState().setActiveView('settings')}>
Open Settings
</button>
</div>
</div>
<div className="welcome-shortcuts">
<h4>Keyboard Shortcuts</h4>
<div className="shortcut-list">
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>N</kbd>
<span>New Post</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>S</kbd>
<span>Save</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>B</kbd>
<span>Toggle Sidebar</span>
</div>
<div className="shortcut-item">
<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd>
<span>Publish</span>
</div>
</div>
</div>
</div>
</div>
);
};
export const Editor: React.FC = () => {
const { activeView, selectedPostId, selectedMediaId, posts } = useAppStore();
if (activeView === 'posts' && selectedPostId) {
const post = posts.find(p => p.id === selectedPostId);
if (post) {
return <PostEditor post={post} />;
}
}
if (activeView === 'media' && selectedMediaId) {
return <MediaEditor mediaId={selectedMediaId} />;
}
return <WelcomeScreen />;
};

View File

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

View File

@@ -0,0 +1,155 @@
.panel {
height: 200px;
display: flex;
flex-direction: column;
background-color: var(--vscode-panel-background);
border-top: 1px solid var(--vscode-panel-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 35px;
padding: 0 8px;
background-color: var(--vscode-sideBar-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.panel-tabs {
display: flex;
gap: 2px;
}
.panel-tab {
padding: 6px 12px;
font-size: 12px;
color: var(--vscode-tab-inactiveForeground);
cursor: pointer;
border-bottom: 2px solid transparent;
}
.panel-tab:hover {
color: var(--vscode-tab-activeForeground);
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
border-bottom-color: var(--vscode-focusBorder);
}
.panel-close {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
padding: 0;
}
.panel-close:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-editor-foreground);
}
.panel-content {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.task-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
background-color: var(--vscode-sideBar-background);
border-radius: 4px;
font-size: 12px;
}
.task-status {
width: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.task-spinner {
width: 12px;
height: 12px;
border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-focusBorder);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.task-check {
color: var(--vscode-testing-iconPassed);
font-weight: bold;
}
.task-error {
color: var(--vscode-notificationsErrorIcon-foreground);
font-weight: bold;
}
.task-pending {
color: var(--vscode-descriptionForeground);
}
.task-info {
flex: 1;
min-width: 0;
}
.task-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-progress-bar {
height: 3px;
background-color: var(--vscode-input-background);
border-radius: 2px;
margin-top: 4px;
overflow: hidden;
}
.task-progress-fill {
height: 100%;
background-color: var(--vscode-focusBorder);
transition: width 0.2s ease;
}
.task-cancel {
padding: 2px 8px;
font-size: 11px;
background-color: var(--vscode-button-secondaryBackground);
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { useAppStore } from '../../store';
import './Panel.css';
export const Panel: React.FC = () => {
const { panelVisible, tasks } = useAppStore();
if (!panelVisible) {
return null;
}
const recentTasks = tasks.slice(-10).reverse();
return (
<div className="panel">
<div className="panel-header">
<div className="panel-tabs">
<div className="panel-tab active">Tasks</div>
<div className="panel-tab">Output</div>
<div className="panel-tab">Sync Log</div>
</div>
<button
className="panel-close"
onClick={() => useAppStore.getState().togglePanel()}
title="Close Panel"
>
×
</button>
</div>
<div className="panel-content">
{recentTasks.length === 0 ? (
<div className="panel-empty">No recent tasks</div>
) : (
<div className="task-list">
{recentTasks.map(task => (
<div key={task.taskId} className={`task-item status-${task.status}`}>
<div className="task-status">
{task.status === 'running' && <span className="task-spinner" />}
{task.status === 'completed' && <span className="task-check"></span>}
{task.status === 'failed' && <span className="task-error"></span>}
{task.status === 'pending' && <span className="task-pending"></span>}
</div>
<div className="task-info">
<div className="task-message">{task.message}</div>
{task.status === 'running' && (
<div className="task-progress-bar">
<div
className="task-progress-fill"
style={{ width: `${task.progress}%` }}
/>
</div>
)}
</div>
{task.status === 'running' && (
<button
className="task-cancel"
onClick={() => window.electronAPI?.tasks.cancel(task.taskId)}
>
Cancel
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,203 @@
.sidebar {
width: 280px;
height: 100%;
background-color: var(--vscode-sideBar-background);
border-right: 1px solid var(--vscode-sideBar-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-section {
margin-bottom: 4px;
}
.sidebar-section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-sideBar-foreground);
}
.sidebar-action {
background: transparent;
border: none;
padding: 2px;
color: var(--vscode-sideBar-foreground);
cursor: pointer;
opacity: 0.7;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
}
.sidebar-action:hover {
opacity: 1;
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-section-title {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.section-icon {
font-size: 8px;
}
.sidebar-list {
display: flex;
flex-direction: column;
}
.sidebar-item {
padding: 6px 12px 6px 24px;
cursor: pointer;
border-left: 2px solid transparent;
}
.sidebar-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.sidebar-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
border-left-color: var(--vscode-focusBorder);
}
.sidebar-item-title {
font-size: 13px;
color: var(--vscode-sideBar-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-item-meta {
font-size: 11px;
color: var(--vscode-descriptionForeground);
margin-top: 2px;
}
.sidebar-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.sidebar-empty p {
margin-bottom: 16px;
}
/* Media Grid */
.media-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2px;
padding: 4px;
}
.media-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
}
.media-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.media-item.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
.media-thumbnail {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-input-background);
border-radius: 4px;
flex-shrink: 0;
}
.media-item-info {
flex: 1;
min-width: 0;
}
.media-item-name {
font-size: 12px;
color: var(--vscode-sideBar-foreground);
}
.media-item-size {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
/* Settings Panel */
.settings-panel {
padding: 0 12px 12px;
}
.settings-group {
margin-bottom: 24px;
}
.settings-group h3 {
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-sideBar-foreground);
}
.settings-field {
margin-bottom: 12px;
}
.settings-field label {
display: block;
font-size: 12px;
margin-bottom: 4px;
color: var(--vscode-descriptionForeground);
}
.settings-field input {
width: 100%;
padding: 6px 8px;
}
.settings-group button {
width: 100%;
margin-bottom: 8px;
}
.settings-status {
font-size: 12px;
margin-top: 8px;
}

View File

@@ -0,0 +1,288 @@
import React from 'react';
import { useAppStore, PostData } from '../../store';
import './Sidebar.css';
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
};
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost } = useAppStore();
const handleCreatePost = async () => {
try {
const newPost = await window.electronAPI?.posts.create({
title: 'Untitled Post',
content: '# New Post\n\nStart writing your content here...',
});
if (newPost) {
setSelectedPost((newPost as PostData).id);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
const groupedPosts = {
draft: posts.filter(p => p.status === 'draft'),
published: posts.filter(p => p.status === 'published'),
archived: posts.filter(p => p.status === 'archived'),
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>POSTS</span>
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-draft"></span>
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>
))}
</div>
</div>
)}
{groupedPosts.published.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-published"></span>
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>
))}
</div>
</div>
)}
{groupedPosts.archived.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-archived"></span>
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>
))}
</div>
</div>
)}
{posts.length === 0 && (
<div className="sidebar-empty">
<p>No posts yet</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
</div>
);
};
const MediaList: React.FC = () => {
const { media, selectedMediaId, setSelectedMedia } = useAppStore();
const handleImportMedia = async () => {
try {
await window.electronAPI?.media.importDialog();
} catch (error) {
console.error('Failed to import media:', error);
}
};
return (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>MEDIA</span>
<button className="sidebar-action" onClick={handleImportMedia} title="Import Media">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
<div className="sidebar-list media-grid">
{media.map(item => (
<div
key={item.id}
className={`media-item ${selectedMediaId === item.id ? 'selected' : ''}`}
onClick={() => setSelectedMedia(item.id)}
title={item.originalName}
>
{item.mimeType.startsWith('image/') ? (
<div className="media-thumbnail">
{/* Would load actual image in production */}
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
) : (
<div className="media-thumbnail">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
</div>
)}
<div className="media-item-info">
<div className="media-item-name truncate">{item.originalName}</div>
<div className="media-item-size">{formatFileSize(item.size)}</div>
</div>
</div>
))}
</div>
{media.length === 0 && (
<div className="sidebar-empty">
<p>No media files</p>
<button onClick={handleImportMedia}>Import media</button>
</div>
)}
</div>
);
};
const SettingsPanel: React.FC = () => {
const { syncConfigured } = useAppStore();
const [tursoUrl, setTursoUrl] = React.useState('');
const [tursoToken, setTursoToken] = React.useState('');
const handleSaveSync = async () => {
try {
await window.electronAPI?.sync.configure({
tursoUrl,
tursoAuthToken: tursoToken,
autoSync: true,
syncInterval: 5,
});
} catch (error) {
console.error('Failed to configure sync:', error);
}
};
return (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>SETTINGS</span>
</div>
</div>
<div className="settings-group">
<h3>Cloud Sync (Turso/LibSQL)</h3>
<div className="settings-field">
<label>Turso Database URL</label>
<input
type="text"
placeholder="libsql://your-db.turso.io"
value={tursoUrl}
onChange={(e) => setTursoUrl(e.target.value)}
/>
</div>
<div className="settings-field">
<label>Auth Token</label>
<input
type="password"
placeholder="Your auth token"
value={tursoToken}
onChange={(e) => setTursoToken(e.target.value)}
/>
</div>
<button onClick={handleSaveSync}>
{syncConfigured ? 'Update Sync Settings' : 'Enable Sync'}
</button>
{syncConfigured && (
<p className="settings-status status-published"> Sync is configured</p>
)}
</div>
<div className="settings-group">
<h3>Data Management</h3>
<button
className="secondary"
onClick={() => window.electronAPI?.posts.rebuildFromFiles()}
>
Rebuild Posts Database
</button>
<button
className="secondary"
onClick={() => window.electronAPI?.media.rebuildFromFiles()}
>
Rebuild Media Database
</button>
<button
className="secondary"
onClick={async () => {
const paths = await window.electronAPI?.app.getDataPaths();
if (paths) {
window.electronAPI?.app.openFolder(paths.posts);
}
}}
>
Open Data Folder
</button>
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
if (!sidebarVisible) {
return null;
}
return (
<div className="sidebar">
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsPanel />}
</div>
);
};

View File

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

View File

@@ -0,0 +1,91 @@
.status-bar {
height: 22px;
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--vscode-statusBar-background);
color: var(--vscode-statusBar-foreground);
font-size: 12px;
padding: 0 8px;
user-select: none;
}
.status-bar-left,
.status-bar-right {
display: flex;
align-items: center;
gap: 4px;
}
.status-bar-item {
display: flex;
align-items: center;
gap: 6px;
padding: 0 8px;
height: 100%;
}
.status-bar-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.status-bar-item.warning {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.sync-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--vscode-testing-iconPassed);
}
.sync-indicator.syncing {
animation: pulse 1s infinite;
background-color: var(--vscode-notificationsInfoIcon-foreground);
}
.sync-indicator.error {
background-color: var(--vscode-notificationsErrorIcon-foreground);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.task-spinner {
width: 10px;
height: 10px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-dot.status-draft {
background-color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-dot.status-published {
background-color: var(--vscode-testing-iconPassed);
}
.status-dot.status-archived {
background-color: var(--vscode-descriptionForeground);
}
.status-bar-item.brand {
font-weight: 600;
letter-spacing: 0.5px;
}

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { useAppStore } from '../../store';
import './StatusBar.css';
export const StatusBar: React.FC = () => {
const {
syncStatus,
syncConfigured,
pendingChanges,
posts,
media,
tasks,
selectedPostId,
} = useAppStore();
const runningTasks = tasks.filter(t => t.status === 'running');
const totalPending = pendingChanges.posts + pendingChanges.media;
const selectedPost = posts.find(p => p.id === selectedPostId);
return (
<div className="status-bar">
<div className="status-bar-left">
{/* Sync Status */}
<div className={`status-bar-item ${!syncConfigured ? 'warning' : ''}`}>
<span className={`sync-indicator ${syncStatus}`} />
{!syncConfigured ? (
<span>Sync not configured</span>
) : syncStatus === 'syncing' ? (
<span>Syncing...</span>
) : totalPending > 0 ? (
<span>{totalPending} pending</span>
) : (
<span>Synced</span>
)}
</div>
{/* Running Tasks */}
{runningTasks.length > 0 && (
<div className="status-bar-item">
<span className="task-spinner" />
<span>{runningTasks[0].message}</span>
{runningTasks.length > 1 && (
<span className="text-muted">+{runningTasks.length - 1} more</span>
)}
</div>
)}
</div>
<div className="status-bar-right">
{/* Current Post Info */}
{selectedPost && (
<div className="status-bar-item">
<span className={`status-dot status-${selectedPost.status}`} />
<span>{selectedPost.status}</span>
</div>
)}
{/* Stats */}
<div className="status-bar-item">
<span>{posts.length} posts</span>
</div>
<div className="status-bar-item">
<span>{media.length} media</span>
</div>
{/* App Name */}
<div className="status-bar-item brand">
<span>bDS</span>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,5 @@
export { ActivityBar } from './ActivityBar';
export { Sidebar } from './Sidebar';
export { Editor } from './Editor';
export { StatusBar } from './StatusBar';
export { Panel } from './Panel';