feat: next phase of basic work

This commit is contained in:
2026-02-10 11:33:19 +01:00
parent 5979fa3374
commit 78b2847bad
27 changed files with 2325 additions and 508 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel } from './components';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, ToastContainer, showToast } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import './App.css';
@@ -115,12 +115,15 @@ const App: React.FC = () => {
unsubscribers.push(
window.electronAPI?.on('sync:started', () => {
setSyncStatus('syncing');
showToast.loading('Syncing...');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('sync:completed', async () => {
setSyncStatus('idle');
showToast.dismiss();
showToast.success('Sync completed');
const pending = await window.electronAPI?.sync.getPendingCount();
if (pending) {
setPendingChanges(pending);
@@ -131,6 +134,8 @@ const App: React.FC = () => {
unsubscribers.push(
window.electronAPI?.on('sync:failed', () => {
setSyncStatus('error');
showToast.dismiss();
showToast.error('Sync failed');
}) || (() => {})
);
@@ -146,6 +151,7 @@ const App: React.FC = () => {
window.electronAPI?.on('task:completed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
showToast.success(`Task completed: ${t.message}`);
}) || (() => {})
);
@@ -153,6 +159,7 @@ const App: React.FC = () => {
window.electronAPI?.on('task:failed', (task: unknown) => {
const t = task as TaskProgress;
updateTask(t.taskId, t);
showToast.error(`Task failed: ${t.error || t.message}`);
}) || (() => {})
);
@@ -256,6 +263,7 @@ const App: React.FC = () => {
</div>
</div>
<StatusBar />
<ToastContainer />
</div>
);
};

View File

@@ -154,6 +154,58 @@
border-radius: 4px;
}
.editor-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.editor-mode-toggle {
display: flex;
gap: 4px;
}
.editor-mode-toggle button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.editor-mode-toggle button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.editor-mode-toggle button.active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.editor-preview {
flex: 1;
background-color: var(--vscode-input-background);
border-radius: 4px;
padding: 16px;
overflow-y: auto;
font-size: 14px;
line-height: 1.6;
}
.editor-field-row {
display: flex;
gap: 12px;
width: 100%;
}
.editor-field-row .editor-field {
flex: 1;
}
.editor-footer {
display: flex;
align-items: center;

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import MonacoEditor from '@monaco-editor/react';
import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast';
import './Editor.css';
interface PostEditorProps {
@@ -11,13 +13,17 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const [title, setTitle] = useState(post.title);
const [content, setContent] = useState(post.content);
const [tags, setTags] = useState(post.tags.join(', '));
const [categories, setCategories] = useState(post.categories.join(', '));
const [isDirty, setIsDirty] = useState(false);
const [editorMode, setEditorMode] = useState<'markdown' | 'preview'>('markdown');
const editorRef = useRef<unknown>(null);
// Reset when post changes
useEffect(() => {
setTitle(post.title);
setContent(post.content);
setTags(post.tags.join(', '));
setCategories(post.categories.join(', '));
setIsDirty(false);
}, [post.id]);
@@ -26,9 +32,10 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const hasChanges =
title !== post.title ||
content !== post.content ||
tags !== post.tags.join(', ');
tags !== post.tags.join(', ') ||
categories !== post.categories.join(', ');
setIsDirty(hasChanges);
}, [title, content, tags, post]);
}, [title, content, tags, categories, post]);
const handleSave = useCallback(async () => {
if (!isDirty) return;
@@ -38,16 +45,19 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
title,
content,
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0),
});
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
setIsDirty(false);
showToast.success('Post saved');
}
} catch (error) {
console.error('Failed to save post:', error);
showToast.error('Failed to save post');
}
}, [post.id, title, content, tags, isDirty, updatePost]);
}, [post.id, title, content, tags, categories, isDirty, updatePost]);
const handlePublish = async () => {
await handleSave();
@@ -55,9 +65,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const updated = await window.electronAPI?.posts.publish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
showToast.success('Post published');
}
} catch (error) {
console.error('Failed to publish post:', error);
showToast.error('Failed to publish post');
}
};
@@ -66,9 +78,11 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
const updated = await window.electronAPI?.posts.unpublish(post.id);
if (updated) {
updatePost(post.id, updated as Partial<PostData>);
showToast.success('Post unpublished');
}
} catch (error) {
console.error('Failed to unpublish post:', error);
showToast.error('Failed to unpublish post');
}
};
@@ -77,12 +91,20 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
try {
await window.electronAPI?.posts.delete(post.id);
useAppStore.getState().removePost(post.id);
useAppStore.getState().setSelectedPost(null);
showToast.success('Post deleted');
} catch (error) {
console.error('Failed to delete post:', error);
showToast.error('Failed to delete post');
}
}
};
// Handle Monaco editor mount
const handleEditorDidMount = (editor: unknown) => {
editorRef.current = editor;
};
// Save on Ctrl+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -158,25 +180,76 @@ const PostEditor: React.FC<PostEditorProps> = ({ post }) => {
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 className="editor-field-row">
<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 className="editor-field">
<label>Categories (comma-separated)</label>
<input
type="text"
value={categories}
onChange={(e) => setCategories(e.target.value)}
placeholder="category1, category2"
/>
</div>
</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 className="editor-toolbar">
<label>Content (Markdown)</label>
<div className="editor-mode-toggle">
<button
className={editorMode === 'markdown' ? 'active' : ''}
onClick={() => setEditorMode('markdown')}
>
Markdown
</button>
<button
className={editorMode === 'preview' ? 'active' : ''}
onClick={() => setEditorMode('preview')}
>
Preview
</button>
</div>
</div>
{editorMode === 'markdown' ? (
<MonacoEditor
height="100%"
defaultLanguage="markdown"
value={content}
onChange={(value) => setContent(value || '')}
onMount={handleEditorDidMount}
theme="vs-dark"
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
fontSize: 14,
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
padding: { top: 12, bottom: 12 },
automaticLayout: true,
scrollBeyondLastLine: false,
renderLineHighlight: 'line',
quickSuggestions: false,
formatOnPaste: true,
cursorStyle: 'line',
cursorBlinking: 'smooth',
}}
/>
) : (
<div className="editor-preview markdown-body">
{/* Simple markdown preview - could be enhanced with a proper renderer */}
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>{content}</pre>
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,277 @@
.project-selector {
position: relative;
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.project-selector-trigger {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
color: var(--vscode-input-foreground);
cursor: pointer;
font-size: 13px;
text-align: left;
}
.project-selector-trigger:hover {
background-color: var(--vscode-list-hoverBackground);
}
.project-selector-trigger:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.project-icon {
flex-shrink: 0;
opacity: 0.8;
}
.project-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-arrow {
flex-shrink: 0;
opacity: 0.6;
}
.project-dropdown {
position: absolute;
top: 100%;
left: 12px;
right: 12px;
background-color: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.project-dropdown-header {
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
border-bottom: 1px solid var(--vscode-dropdown-border);
}
.project-list {
max-height: 200px;
overflow-y: auto;
}
.project-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--vscode-dropdown-foreground);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.project-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.project-item.active {
background-color: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
}
.project-item-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.check-icon {
flex-shrink: 0;
color: var(--vscode-terminal-ansiGreen);
}
.project-empty {
padding: 16px 12px;
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: 12px;
}
.project-dropdown-footer {
padding: 8px;
border-top: 1px solid var(--vscode-dropdown-border);
}
.create-project-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 6px 12px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.create-project-btn:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background-color: var(--vscode-editor-background);
border: 1px solid var(--vscode-widget-border);
border-radius: 6px;
width: 400px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--vscode-widget-border);
}
.modal-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--vscode-foreground);
}
.modal-close {
background: transparent;
border: none;
padding: 4px;
color: var(--vscode-foreground);
cursor: pointer;
opacity: 0.7;
border-radius: 3px;
}
.modal-close:hover {
opacity: 1;
background-color: var(--vscode-list-hoverBackground);
}
.modal-body {
padding: 16px;
}
.form-field {
margin-bottom: 16px;
}
.form-field:last-child {
margin-bottom: 0;
}
.form-field label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--vscode-foreground);
}
.form-field input,
.form-field textarea {
width: 100%;
padding: 8px 10px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
color: var(--vscode-input-foreground);
font-size: 13px;
font-family: inherit;
resize: vertical;
}
.form-field input:focus,
.form-field textarea:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.form-field input::placeholder,
.form-field textarea::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--vscode-widget-border);
}
.btn-primary {
padding: 6px 14px;
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 6px 14px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.btn-secondary:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}

View File

@@ -0,0 +1,202 @@
import React, { useState, useRef, useEffect } from 'react';
import { useAppStore, ProjectData } from '../../store';
import { showToast } from '../Toast';
import './ProjectSelector.css';
export const ProjectSelector: React.FC = () => {
const { projects, activeProject, setProjects, setActiveProject, setPosts, setMedia, setSelectedPost, setSelectedMedia } = useAppStore();
const [isOpen, setIsOpen] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
// Load projects on mount
useEffect(() => {
const loadProjects = async () => {
try {
const allProjects = await window.electronAPI?.projects.getAll();
if (allProjects) {
setProjects(allProjects as ProjectData[]);
}
const active = await window.electronAPI?.projects.getActive();
if (active) {
setActiveProject(active as ProjectData);
}
} catch (error) {
console.error('Failed to load projects:', error);
}
};
loadProjects();
}, [setProjects, setActiveProject]);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSwitchProject = async (project: ProjectData) => {
if (project.id === activeProject?.id) {
setIsOpen(false);
return;
}
try {
const updatedProject = await window.electronAPI?.projects.setActive(project.id);
if (updatedProject) {
setActiveProject(updatedProject as ProjectData);
// Clear current selection and reload data
setSelectedPost(null);
setSelectedMedia(null);
// Reload posts and media for new project
const [posts, media] = await Promise.all([
window.electronAPI?.posts.getAll(),
window.electronAPI?.media.getAll(),
]);
if (posts) setPosts(posts);
if (media) setMedia(media);
showToast.success(`Switched to ${project.name}`);
}
} catch (error) {
console.error('Failed to switch project:', error);
showToast.error('Failed to switch project');
}
setIsOpen(false);
};
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!newProjectName.trim()) return;
try {
const newProject = await window.electronAPI?.projects.create({
name: newProjectName.trim(),
description: newProjectDescription.trim() || undefined,
});
if (newProject) {
setProjects([...projects, newProject as ProjectData]);
showToast.success(`Created project "${newProjectName}"`);
setNewProjectName('');
setNewProjectDescription('');
setShowCreateModal(false);
// Optionally switch to the new project
await handleSwitchProject(newProject as ProjectData);
}
} catch (error) {
console.error('Failed to create project:', error);
showToast.error('Failed to create project');
}
};
return (
<div className="project-selector" ref={dropdownRef}>
<button
className="project-selector-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Switch project"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" className="project-icon">
<path d="M14.5 3H7.71l-.85-.85A.5.5 0 0 0 6.5 2h-5a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5v-10a.5.5 0 0 0-.5-.5zm-13 1h5.29l.85.85c.1.1.23.15.36.15h6.5v9h-13V4z"/>
</svg>
<span className="project-name">{activeProject?.name || 'Select Project'}</span>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="dropdown-arrow">
<path d="M4.5 5.5L8 9l3.5-3.5h-7z"/>
</svg>
</button>
{isOpen && (
<div className="project-dropdown">
<div className="project-dropdown-header">
<span>PROJECTS</span>
</div>
<div className="project-list">
{projects.map(project => (
<button
key={project.id}
className={`project-item ${project.id === activeProject?.id ? 'active' : ''}`}
onClick={() => handleSwitchProject(project)}
>
<span className="project-item-name">{project.name}</span>
{project.id === activeProject?.id && (
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="check-icon">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
)}
</button>
))}
{projects.length === 0 && (
<div className="project-empty">No projects yet</div>
)}
</div>
<div className="project-dropdown-footer">
<button className="create-project-btn" onClick={() => { setShowCreateModal(true); setIsOpen(false); }}>
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
New Project
</button>
</div>
</div>
)}
{showCreateModal && (
<div className="modal-overlay" onClick={() => setShowCreateModal(false)}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h3>Create New Project</h3>
<button className="modal-close" onClick={() => setShowCreateModal(false)}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"/>
</svg>
</button>
</div>
<form onSubmit={handleCreateProject}>
<div className="modal-body">
<div className="form-field">
<label htmlFor="project-name">Project Name</label>
<input
id="project-name"
type="text"
value={newProjectName}
onChange={e => setNewProjectName(e.target.value)}
placeholder="My Blog"
autoFocus
/>
</div>
<div className="form-field">
<label htmlFor="project-description">Description (optional)</label>
<textarea
id="project-description"
value={newProjectDescription}
onChange={e => setNewProjectDescription(e.target.value)}
placeholder="A brief description of this project..."
rows={3}
/>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn-secondary" onClick={() => setShowCreateModal(false)}>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={!newProjectName.trim()}>
Create Project
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ProjectSelector;

View File

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

View File

@@ -201,3 +201,261 @@
font-size: 12px;
margin-top: 8px;
}
/* Search Box */
.search-box {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px 8px;
position: relative;
}
.search-box input {
flex: 1;
padding: 6px 28px 6px 8px;
font-size: 12px;
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
color: var(--vscode-input-foreground);
border-radius: 3px;
}
.search-box input::placeholder {
color: var(--vscode-input-placeholderForeground);
}
.search-box input:focus {
outline: none;
border-color: var(--vscode-focusBorder);
}
.search-box button[type="submit"] {
position: absolute;
right: 40px;
background: transparent;
border: none;
padding: 4px;
color: var(--vscode-descriptionForeground);
cursor: pointer;
opacity: 0.7;
}
.search-box button[type="submit"]:hover {
opacity: 1;
}
.search-box .clear-search {
position: absolute;
right: 16px;
background: transparent;
border: none;
padding: 4px;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 10px;
opacity: 0.7;
}
.search-box .clear-search:hover {
opacity: 1;
}
/* Sidebar header actions */
.sidebar-actions {
display: flex;
gap: 4px;
}
.sidebar-action.active {
background-color: var(--vscode-list-activeSelectionBackground);
opacity: 1;
}
/* Calendar View */
.calendar-view {
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
margin-bottom: 8px;
}
.calendar-header .clear-filter {
background: transparent;
border: none;
color: var(--vscode-descriptionForeground);
cursor: pointer;
font-size: 10px;
padding: 2px 4px;
opacity: 0.7;
}
.calendar-header .clear-filter:hover {
opacity: 1;
}
.calendar-years {
display: flex;
flex-direction: column;
gap: 2px;
}
.calendar-year-header {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
cursor: pointer;
border-radius: 3px;
font-size: 12px;
color: var(--vscode-sideBar-foreground);
}
.calendar-year-header:hover {
background-color: var(--vscode-list-hoverBackground);
}
.calendar-year-header.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
.calendar-year-header .expand-icon {
font-size: 8px;
color: var(--vscode-descriptionForeground);
width: 10px;
}
.calendar-year-header .year-label {
flex: 1;
}
.calendar-year-header .year-count {
font-size: 10px;
color: var(--vscode-descriptionForeground);
background-color: var(--vscode-badge-background);
padding: 1px 6px;
border-radius: 8px;
}
.calendar-months {
display: flex;
flex-direction: column;
gap: 1px;
padding-left: 16px;
margin-top: 2px;
}
.calendar-month {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 6px;
cursor: pointer;
border-radius: 3px;
font-size: 12px;
color: var(--vscode-sideBar-foreground);
}
.calendar-month:hover {
background-color: var(--vscode-list-hoverBackground);
}
.calendar-month.selected {
background-color: var(--vscode-list-activeSelectionBackground);
}
.calendar-month .month-count {
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.calendar-empty {
font-size: 12px;
color: var(--vscode-descriptionForeground);
padding: 8px;
text-align: center;
}
/* Filter Panel */
.filter-panel {
padding: 8px 12px;
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.filter-section {
margin-bottom: 12px;
}
.filter-section:last-child {
margin-bottom: 0;
}
.filter-header {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
margin-bottom: 6px;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: 2px 8px;
font-size: 11px;
border-radius: 12px;
cursor: pointer;
transition: background-color 0.15s;
}
.filter-chip:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.filter-chip.active {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
/* Filter Status */
.filter-status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 11px;
color: var(--vscode-descriptionForeground);
background-color: var(--vscode-list-hoverBackground);
border-bottom: 1px solid var(--vscode-sideBar-border);
}
.filter-status button {
background: transparent;
border: none;
color: var(--vscode-textLink-foreground);
cursor: pointer;
font-size: 11px;
padding: 0;
}
.filter-status button:hover {
text-decoration: underline;
}

View File

@@ -1,5 +1,7 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast';
import { ProjectSelector } from '../ProjectSelector';
import './Sidebar.css';
const formatDate = (dateString: string) => {
@@ -13,8 +15,290 @@ const formatFileSize = (bytes: number) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface CalendarViewProps {
onDateSelect: (year: number, month?: number) => void;
selectedYear?: number;
selectedMonth?: number;
}
const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [expandedYear, setExpandedYear] = useState<number | null>(null);
useEffect(() => {
const loadData = async () => {
const data = await window.electronAPI?.posts.getByYearMonth();
if (data) {
setYearMonthData(data as { year: number; month: number; count: number }[]);
}
};
loadData();
}, []);
// Group by year
const years = [...new Set(yearMonthData.map(d => d.year))].sort((a, b) => b - a);
const getYearCount = (year: number) => {
return yearMonthData.filter(d => d.year === year).reduce((sum, d) => sum + d.count, 0);
};
const getMonthsForYear = (year: number) => {
return yearMonthData.filter(d => d.year === year).sort((a, b) => b.month - a.month);
};
return (
<div className="calendar-view">
<div className="calendar-header">
<span>ARCHIVE</span>
{(selectedYear || selectedMonth !== undefined) && (
<button className="clear-filter" onClick={() => onDateSelect(0)} title="Clear filter">
</button>
)}
</div>
<div className="calendar-years">
{years.map(year => (
<div key={year} className="calendar-year">
<div
className={`calendar-year-header ${selectedYear === year && selectedMonth === undefined ? 'selected' : ''}`}
onClick={() => {
setExpandedYear(expandedYear === year ? null : year);
onDateSelect(year);
}}
>
<span className="expand-icon">{expandedYear === year ? '▼' : '▶'}</span>
<span className="year-label">{year}</span>
<span className="year-count">{getYearCount(year)}</span>
</div>
{expandedYear === year && (
<div className="calendar-months">
{getMonthsForYear(year).map(({ month, count }) => (
<div
key={month}
className={`calendar-month ${selectedYear === year && selectedMonth === month ? 'selected' : ''}`}
onClick={(e) => {
e.stopPropagation();
onDateSelect(year, month);
}}
>
<span className="month-label">{MONTH_NAMES[month]}</span>
<span className="month-count">{count}</span>
</div>
))}
</div>
)}
</div>
))}
{years.length === 0 && (
<div className="calendar-empty">No posts yet</div>
)}
</div>
</div>
);
};
interface FilterPanelProps {
tags: string[];
categories: string[];
selectedTags: string[];
selectedCategories: string[];
onTagSelect: (tags: string[]) => void;
onCategorySelect: (categories: string[]) => void;
}
const FilterPanel: React.FC<FilterPanelProps> = ({
tags,
categories,
selectedTags,
selectedCategories,
onTagSelect,
onCategorySelect,
}) => {
return (
<div className="filter-panel">
{tags.length > 0 && (
<div className="filter-section">
<div className="filter-header">TAGS</div>
<div className="filter-chips">
{tags.map(tag => (
<button
key={tag}
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
onClick={() => {
if (selectedTags.includes(tag)) {
onTagSelect(selectedTags.filter(t => t !== tag));
} else {
onTagSelect([...selectedTags, tag]);
}
}}
>
{tag}
</button>
))}
</div>
</div>
)}
{categories.length > 0 && (
<div className="filter-section">
<div className="filter-header">CATEGORIES</div>
<div className="filter-chips">
{categories.map(cat => (
<button
key={cat}
className={`filter-chip ${selectedCategories.includes(cat) ? 'active' : ''}`}
onClick={() => {
if (selectedCategories.includes(cat)) {
onCategorySelect(selectedCategories.filter(c => c !== cat));
} else {
onCategorySelect([...selectedCategories, cat]);
}
}}
>
{cat}
</button>
))}
</div>
</div>
)}
</div>
);
};
interface SearchBoxProps {
onSearch: (query: string) => void;
}
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form className="search-box" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Search posts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" title="Search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
</svg>
</button>
{query && (
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title="Clear">
</button>
)}
</form>
);
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost } = useAppStore();
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<PostData[] | null>(null);
const [selectedYear, setSelectedYear] = useState<number | undefined>();
const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null);
// Load available tags and categories
useEffect(() => {
const loadFilters = async () => {
const [tags, categories] = await Promise.all([
window.electronAPI?.posts.getTags(),
window.electronAPI?.posts.getCategories(),
]);
if (tags) setAvailableTags(tags as string[]);
if (categories) setAvailableCategories(categories as string[]);
};
loadFilters();
}, [posts]);
// Handle search
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults(null);
return;
}
try {
const results = await window.electronAPI?.posts.search(query);
if (results) {
// Map search results to PostData (search returns SearchResult with score)
const postIds = (results as { id: string }[]).map(r => r.id);
setSearchResults(posts.filter(p => postIds.includes(p.id)));
}
} catch (error) {
console.error('Search failed:', error);
showToast.error('Search failed');
}
};
// Handle date selection
const handleDateSelect = async (year: number, month?: number) => {
if (year === 0) {
// Clear filter
setSelectedYear(undefined);
setSelectedMonth(undefined);
setFilteredPosts(null);
return;
}
setSelectedYear(year);
setSelectedMonth(month);
try {
const results = await window.electronAPI?.posts.filter({
year,
month,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
}
} catch (error) {
console.error('Filter failed:', error);
}
};
// Handle tag/category filter changes
useEffect(() => {
const applyFilters = async () => {
if (!selectedYear && selectedTags.length === 0 && selectedCategories.length === 0) {
setFilteredPosts(null);
return;
}
try {
const results = await window.electronAPI?.posts.filter({
year: selectedYear,
month: selectedMonth,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
}
} catch (error) {
console.error('Filter failed:', error);
}
};
applyFilters();
}, [selectedTags, selectedCategories]);
const handleCreatePost = async () => {
try {
@@ -24,16 +308,33 @@ const PostsList: React.FC = () => {
});
if (newPost) {
setSelectedPost((newPost as PostData).id);
showToast.success('Post created');
}
} catch (error) {
console.error('Failed to create post:', error);
showToast.error('Failed to create post');
}
};
// Determine which posts to display
const displayPosts = searchResults ?? filteredPosts ?? posts;
const isFiltered = searchResults !== null || filteredPosts !== null;
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
const groupedPosts = {
draft: posts.filter(p => p.status === 'draft'),
published: posts.filter(p => p.status === 'published'),
archived: posts.filter(p => p.status === 'archived'),
draft: displayPosts.filter(p => p.status === 'draft'),
published: displayPosts.filter(p => p.status === 'published'),
archived: displayPosts.filter(p => p.status === 'archived'),
};
const clearAllFilters = () => {
setSearchQuery('');
setSearchResults(null);
setSelectedYear(undefined);
setSelectedMonth(undefined);
setSelectedTags([]);
setSelectedCategories([]);
setFilteredPosts(null);
};
return (
@@ -41,14 +342,57 @@ const PostsList: React.FC = () => {
<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 className="sidebar-actions">
<button
className={`sidebar-action ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
title="Toggle Filters"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
</svg>
</button>
<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>
</div>
<SearchBox onSearch={handleSearch} />
{showFilters && (
<>
<CalendarView
onDateSelect={handleDateSelect}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<FilterPanel
tags={availableTags}
categories={availableCategories}
selectedTags={selectedTags}
selectedCategories={selectedCategories}
onTagSelect={setSelectedTags}
onCategorySelect={setSelectedCategories}
/>
</>
)}
{hasActiveFilters && (
<div className="filter-status">
<span>
{displayPosts.length} result{displayPosts.length !== 1 ? 's' : ''}
{searchQuery && ` for "${searchQuery}"`}
</span>
<button onClick={clearAllFilters} title="Clear all filters">
Clear filters
</button>
</div>
)}
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
@@ -112,12 +456,19 @@ const PostsList: React.FC = () => {
</div>
)}
{posts.length === 0 && (
{displayPosts.length === 0 && !isFiltered && (
<div className="sidebar-empty">
<p>No posts yet</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
{displayPosts.length === 0 && isFiltered && (
<div className="sidebar-empty">
<p>No matching posts</p>
<button onClick={clearAllFilters}>Clear filters</button>
</div>
)}
</div>
);
};
@@ -280,6 +631,7 @@ export const Sidebar: React.FC = () => {
return (
<div className="sidebar">
<ProjectSelector />
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsPanel />}

View File

@@ -0,0 +1,14 @@
.toast-container {
z-index: 9999 !important;
}
/* Custom styling for toast animations */
:root {
--toast-enter-duration: 200ms;
--toast-exit-duration: 150ms;
}
/* Override for VS Code dark theme compatibility */
[data-sonner-toast] {
font-family: var(--vscode-font-family, 'Segoe UI', sans-serif);
}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import { Toaster, toast } from 'react-hot-toast';
import './Toast.css';
// Re-export toast for use throughout the app
export { toast };
// Toast types
export type ToastType = 'success' | 'error' | 'loading' | 'info';
// Custom toast functions
export const showToast = {
success: (message: string) => toast.success(message, {
duration: 4000,
position: 'bottom-right',
}),
error: (message: string) => toast.error(message, {
duration: 6000,
position: 'bottom-right',
}),
info: (message: string) => toast(message, {
duration: 4000,
position: 'bottom-right',
icon: '',
}),
loading: (message: string) => toast.loading(message, {
position: 'bottom-right',
}),
promise: <T,>(
promise: Promise<T>,
msgs: { loading: string; success: string; error: string }
) => toast.promise(promise, msgs, {
position: 'bottom-right',
}),
dismiss: (toastId?: string) => {
if (toastId) {
toast.dismiss(toastId);
} else {
toast.dismiss();
}
},
};
// Toast container component
export const ToastContainer: React.FC = () => {
return (
<Toaster
position="bottom-right"
reverseOrder={false}
gutter={8}
containerClassName="toast-container"
toastOptions={{
// Default options for all toasts
duration: 4000,
style: {
background: 'var(--vscode-notifications-background, #252526)',
color: 'var(--vscode-notifications-foreground, #cccccc)',
border: '1px solid var(--vscode-notifications-border, #3c3c3c)',
borderRadius: '4px',
padding: '12px 16px',
fontSize: '13px',
maxWidth: '400px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
},
// Type-specific styling
success: {
iconTheme: {
primary: 'var(--vscode-testing-iconPassed, #89d185)',
secondary: 'var(--vscode-notifications-background, #252526)',
},
},
error: {
iconTheme: {
primary: 'var(--vscode-testing-iconFailed, #f14c4c)',
secondary: 'var(--vscode-notifications-background, #252526)',
},
},
}}
/>
);
};
export default ToastContainer;

View File

@@ -0,0 +1 @@
export { ToastContainer, toast, showToast, type ToastType } from './Toast';

View File

@@ -3,3 +3,5 @@ export { Sidebar } from './Sidebar';
export { Editor } from './Editor';
export { StatusBar } from './StatusBar';
export { Panel } from './Panel';
export { ToastContainer, toast, showToast, type ToastType } from './Toast';
export { ProjectSelector } from './ProjectSelector';

View File

@@ -1,6 +1,16 @@
import { create } from 'zustand';
// Types
export interface ProjectData {
id: string;
name: string;
slug: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PostData {
id: string;
title: string;
@@ -43,6 +53,10 @@ export interface TaskProgress {
// App State Store
interface AppState {
// Projects
projects: ProjectData[];
activeProject: ProjectData | null;
// UI State
activeView: 'posts' | 'media' | 'settings';
sidebarVisible: boolean;
@@ -64,6 +78,13 @@ interface AppState {
isLoading: boolean;
error: string | null;
// Project Actions
setProjects: (projects: ProjectData[]) => void;
setActiveProject: (project: ProjectData | null) => void;
addProject: (project: ProjectData) => void;
updateProject: (id: string, project: Partial<ProjectData>) => void;
removeProject: (id: string) => void;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings') => void;
toggleSidebar: () => void;
@@ -93,6 +114,10 @@ interface AppState {
}
export const useAppStore = create<AppState>((set) => ({
// Initial Project State
projects: [],
activeProject: null,
// Initial UI State
activeView: 'posts',
sidebarVisible: true,
@@ -114,6 +139,17 @@ export const useAppStore = create<AppState>((set) => ({
isLoading: false,
error: null,
// Project Actions
setProjects: (projects) => set({ projects }),
setActiveProject: (activeProject) => set({ activeProject }),
addProject: (project) => set((state) => ({ projects: [...state.projects, project] })),
updateProject: (id, updatedProject) => set((state) => ({
projects: state.projects.map((p) => (p.id === id ? { ...p, ...updatedProject } : p)),
})),
removeProject: (id) => set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
})),
// UI Actions
setActiveView: (view) => set({ activeView: view }),
toggleSidebar: () => set((state) => ({ sidebarVisible: !state.sidebarVisible })),

View File

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

View File

@@ -1,7 +1,18 @@
// Type definitions for the Electron API exposed via preload
export interface ProjectData {
id: string;
name: string;
slug: string;
description?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface PostData {
id: string;
projectId: string;
title: string;
slug: string;
excerpt?: string;
@@ -15,8 +26,27 @@ export interface PostData {
categories: string[];
}
export interface PostFilter {
status?: 'draft' | 'published' | 'archived';
tags?: string[];
categories?: string[];
year?: number;
month?: number;
from?: string;
to?: string;
}
export interface SearchResult {
id: string;
title: string;
slug: string;
excerpt?: string;
score: number;
}
export interface MediaData {
id: string;
projectId: string;
filename: string;
originalName: string;
mimeType: string;
@@ -56,6 +86,15 @@ export interface SyncResult {
}
export interface ElectronAPI {
projects: {
create: (data: { name: string; description?: string; slug?: string }) => Promise<ProjectData>;
update: (id: string, data: Partial<ProjectData>) => Promise<ProjectData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<ProjectData | null>;
getAll: () => Promise<ProjectData[]>;
getActive: () => Promise<ProjectData | null>;
setActive: (id: string) => Promise<ProjectData | null>;
};
posts: {
create: (data: Partial<PostData>) => Promise<PostData>;
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
@@ -66,6 +105,11 @@ export interface ElectronAPI {
publish: (id: string) => Promise<PostData | null>;
unpublish: (id: string) => Promise<PostData | null>;
rebuildFromFiles: () => Promise<void>;
search: (query: string) => Promise<SearchResult[]>;
filter: (filter: PostFilter) => Promise<PostData[]>;
getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
};
media: {
import: (sourcePath: string, metadata?: Partial<MediaData>) => Promise<MediaData>;