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

20
src/renderer/App.css Normal file
View File

@@ -0,0 +1,20 @@
.app {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background-color: var(--vscode-editor-background);
}
.app-main {
flex: 1;
display: flex;
overflow: hidden;
}
.app-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}

263
src/renderer/App.tsx Normal file
View File

@@ -0,0 +1,263 @@
import React, { useEffect } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import './App.css';
const App: React.FC = () => {
const {
setPosts,
setMedia,
addPost,
updatePost,
removePost,
addMedia,
updateMedia,
removeMedia,
setTasks,
updateTask,
setSyncStatus,
setSyncConfigured,
setPendingChanges,
setLoading,
toggleSidebar,
togglePanel,
setActiveView,
setSelectedPost,
} = useAppStore();
// Load initial data
useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// Load posts
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
// Load media
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
// Check sync status
const syncConfigured = await window.electronAPI?.sync.isConfigured();
setSyncConfigured(syncConfigured || false);
// Get pending changes count
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
// Load tasks
const tasks = await window.electronAPI?.tasks.getAll();
if (tasks) {
setTasks(tasks as TaskProgress[]);
}
} catch (error) {
console.error('Failed to load initial data:', error);
} finally {
setLoading(false);
}
};
loadData();
}, []);
// Set up event listeners for real-time updates
useEffect(() => {
const unsubscribers: Array<() => void> = [];
// Post events
unsubscribers.push(
window.electronAPI?.on('post:created', (post: unknown) => {
addPost(post as PostData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:updated', (post: unknown) => {
const p = post as PostData;
updatePost(p.id, p);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('post:deleted', (id: unknown) => {
removePost(id as string);
}) || (() => {})
);
// Media events
unsubscribers.push(
window.electronAPI?.on('media:imported', (media: unknown) => {
addMedia(media as MediaData);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:updated', (media: unknown) => {
const m = media as MediaData;
updateMedia(m.id, m);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('media:deleted', (id: unknown) => {
removeMedia(id as string);
}) || (() => {})
);
// Sync events
unsubscribers.push(
window.electronAPI?.on('sync:started', () => {
setSyncStatus('syncing');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:completed', async () => {
setSyncStatus('idle');
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:failed', () => {
setSyncStatus('error');
}) || (() => {})
);
// Task events
unsubscribers.push(
window.electronAPI?.on('task:progress', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:completed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('task:failed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
}) || (() => {})
);
// Menu events
unsubscribers.push(
window.electronAPI?.on('menu:newPost', async () => {
const post = await window.electronAPI?.posts.create({
title: 'New Post',
content: '# New Post\n\nStart writing...',
});
if (post) {
setSelectedPost((post as PostData).id);
setActiveView('posts');
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:importMedia', () => {
window.electronAPI?.media.importDialog();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:toggleSidebar', () => {
toggleSidebar();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:togglePanel', () => {
togglePanel();
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewPosts', () => {
setActiveView('posts');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:viewMedia', () => {
setActiveView('media');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:syncNow', () => {
window.electronAPI?.sync.start('bidirectional');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pushChanges', () => {
window.electronAPI?.sync.start('push');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:pullChanges', () => {
window.electronAPI?.sync.start('pull');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:configureSync', () => {
setActiveView('settings');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:rebuildDatabase', async () => {
await window.electronAPI?.posts.rebuildFromFiles();
await window.electronAPI?.media.rebuildFromFiles();
// Reload data
const posts = await window.electronAPI?.posts.getAll();
if (posts) {
setPosts(posts as PostData[]);
}
const media = await window.electronAPI?.media.getAll();
if (media) {
setMedia(media as MediaData[]);
}
}) || (() => {})
);
return () => {
unsubscribers.forEach(unsub => unsub());
};
}, []);
return (
<div className="app">
<div className="app-main">
<ActivityBar />
<Sidebar />
<div className="app-content">
<Editor />
<Panel />
</div>
</div>
<StatusBar />
</div>
);
};
export default App;

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';

13
src/renderer/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: file:;" />
<title>Blogging Desktop Server</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>

10
src/renderer/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,160 @@
import { create } from 'zustand';
// Types
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
// App State Store
interface AppState {
// UI State
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
selectedMediaId: string | null;
// Data
posts: PostData[];
media: MediaData[];
tasks: TaskProgress[];
// Sync
syncStatus: 'idle' | 'syncing' | 'error';
syncConfigured: boolean;
pendingChanges: { posts: number; media: number };
// Loading states
isLoading: boolean;
error: string | null;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;
setSelectedMedia: (id: string | null) => void;
setPosts: (posts: PostData[]) => void;
addPost: (post: PostData) => void;
updatePost: (id: string, post: Partial<PostData>) => void;
removePost: (id: string) => void;
setMedia: (media: MediaData[]) => void;
addMedia: (media: MediaData) => void;
updateMedia: (id: string, media: Partial<MediaData>) => void;
removeMedia: (id: string) => void;
setTasks: (tasks: TaskProgress[]) => void;
updateTask: (taskId: string, task: Partial<TaskProgress>) => void;
setSyncStatus: (status: 'idle' | 'syncing' | 'error') => void;
setSyncConfigured: (configured: boolean) => void;
setPendingChanges: (changes: { posts: number; media: number }) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
}
export const useAppStore = create<AppState>((set) => ({
// 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,
// 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 }),
}));

View File

@@ -0,0 +1 @@
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';

View File

@@ -0,0 +1,286 @@
/* VS Code-inspired CSS Variables and Global Styles */
:root {
/* Background colors */
--vscode-editor-background: #1e1e1e;
--vscode-sideBar-background: #252526;
--vscode-activityBar-background: #333333;
--vscode-panel-background: #1e1e1e;
--vscode-titleBar-activeBackground: #3c3c3c;
--vscode-statusBar-background: #007acc;
--vscode-tab-activeBackground: #1e1e1e;
--vscode-tab-inactiveBackground: #2d2d2d;
--vscode-list-hoverBackground: #2a2d2e;
--vscode-list-activeSelectionBackground: #094771;
--vscode-list-inactiveSelectionBackground: #37373d;
--vscode-input-background: #3c3c3c;
--vscode-dropdown-background: #3c3c3c;
--vscode-button-background: #0e639c;
--vscode-button-hoverBackground: #1177bb;
--vscode-button-secondaryBackground: #3a3d41;
/* Foreground colors */
--vscode-editor-foreground: #d4d4d4;
--vscode-sideBar-foreground: #cccccc;
--vscode-activityBar-foreground: #ffffff;
--vscode-statusBar-foreground: #ffffff;
--vscode-tab-activeForeground: #ffffff;
--vscode-tab-inactiveForeground: #969696;
--vscode-input-foreground: #cccccc;
--vscode-input-placeholderForeground: #a6a6a6;
--vscode-descriptionForeground: #858585;
--vscode-button-foreground: #ffffff;
/* Border colors */
--vscode-panel-border: #80808059;
--vscode-sideBar-border: #80808059;
--vscode-tab-border: #252526;
--vscode-input-border: #3c3c3c;
--vscode-focusBorder: #007fd4;
/* Status colors */
--vscode-notificationsInfoIcon-foreground: #75beff;
--vscode-notificationsWarningIcon-foreground: #cca700;
--vscode-notificationsErrorIcon-foreground: #f48771;
--vscode-testing-iconPassed: #73c991;
--vscode-testing-iconFailed: #f14c4c;
/* Badge colors */
--vscode-badge-background: #4d4d4d;
--vscode-badge-foreground: #ffffff;
--vscode-activityBarBadge-background: #007acc;
--vscode-activityBarBadge-foreground: #ffffff;
/* Scrollbar */
--vscode-scrollbarSlider-background: rgba(121, 121, 121, 0.4);
--vscode-scrollbarSlider-hoverBackground: rgba(100, 100, 100, 0.7);
--vscode-scrollbarSlider-activeBackground: rgba(191, 191, 191, 0.4);
/* Font settings */
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--vscode-editor-font-family: 'Consolas', 'Courier New', monospace;
--vscode-font-size: 13px;
--vscode-editor-font-size: 14px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-editor-foreground);
background-color: var(--vscode-editor-background);
overflow: hidden;
user-select: none;
}
#root {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--vscode-scrollbarSlider-background);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--vscode-scrollbarSlider-hoverBackground);
}
::-webkit-scrollbar-thumb:active {
background: var(--vscode-scrollbarSlider-activeBackground);
}
/* Input styles */
input, textarea, select {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 4px 8px;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--vscode-focusBorder);
}
input::placeholder, textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
/* Button styles */
button {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
color: var(--vscode-button-foreground);
background-color: var(--vscode-button-background);
border: none;
padding: 6px 14px;
cursor: pointer;
border-radius: 2px;
}
button:hover {
background-color: var(--vscode-button-hoverBackground);
}
button:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: 2px;
}
button.secondary {
background-color: var(--vscode-button-secondaryBackground);
}
button.secondary:hover {
background-color: #4a4d51;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Icon button */
.icon-button {
background: transparent;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
}
.icon-button:hover {
background-color: var(--vscode-list-hoverBackground);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 6px;
font-size: 11px;
font-weight: 500;
background-color: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
border-radius: 9px;
}
.badge.primary {
background-color: var(--vscode-activityBarBadge-background);
}
/* Status indicators */
.status-draft {
color: var(--vscode-notificationsWarningIcon-foreground);
}
.status-published {
color: var(--vscode-testing-iconPassed);
}
.status-archived {
color: var(--vscode-descriptionForeground);
}
/* Text styles */
.text-muted {
color: var(--vscode-descriptionForeground);
}
.text-small {
font-size: 11px;
}
/* Utility classes */
.flex {
display: flex;
}
.flex-1 {
flex: 1;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
.p-1 {
padding: 4px;
}
.p-2 {
padding: 8px;
}
.p-3 {
padding: 12px;
}
.px-2 {
padding-left: 8px;
padding-right: 8px;
}
.py-1 {
padding-top: 4px;
padding-bottom: 4px;
}
.overflow-hidden {
overflow: hidden;
}
.overflow-auto {
overflow: auto;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

109
src/renderer/types/electron.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
// Type definitions for the Electron API exposed via preload
export interface PostData {
id: string;
title: string;
slug: string;
excerpt?: string;
content: string;
status: 'draft' | 'published' | 'archived';
author?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
tags: string[];
categories: string[];
}
export interface MediaData {
id: string;
filename: string;
originalName: string;
mimeType: string;
size: number;
width?: number;
height?: number;
alt?: string;
caption?: string;
createdAt: string;
updatedAt: string;
tags: string[];
}
export interface TaskProgress {
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
startTime: string;
endTime?: string;
error?: string;
}
export interface SyncConfig {
tursoUrl: string;
tursoAuthToken: string;
autoSync: boolean;
syncInterval: number;
}
export interface SyncResult {
success: boolean;
pushed: number;
pulled: number;
conflicts: number;
errors: string[];
}
export interface ElectronAPI {
posts: {
create: (data: Partial<PostData>) => Promise<PostData>;
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getAll: () => Promise<PostData[]>;
getByStatus: (status: string) => Promise<PostData[]>;
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
rebuildFromFiles: () => Promise<void>;
};
media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;
importDialog: () => Promise<MediaData[]>;
update: (id: string, data: Partial<MediaData>) => Promise<MediaData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<MediaData | null>;
getAll: () => Promise<MediaData[]>;
rebuildFromFiles: () => Promise<void>;
};
sync: {
configure: (config: SyncConfig) => Promise<void>;
start: (direction?: 'push' | 'pull' | 'bidirectional') => Promise<SyncResult>;
getStatus: () => Promise<'idle' | 'syncing' | 'error'>;
isConfigured: () => Promise<boolean>;
getPendingCount: () => Promise<{ posts: number; media: number }>;
getLog: (limit?: number) => Promise<unknown[]>;
stopAutoSync: () => Promise<void>;
};
tasks: {
getAll: () => Promise<TaskProgress[]>;
getRunning: () => Promise<TaskProgress[]>;
cancel: (taskId: string) => Promise<boolean>;
clearCompleted: () => Promise<void>;
};
app: {
getDataPaths: () => Promise<{ database: string; posts: string; media: string }>;
openFolder: (folderPath: string) => Promise<string>;
showItemInFolder: (itemPath: string) => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
}
}
export {};