initial commit
This commit is contained in:
20
src/renderer/App.css
Normal file
20
src/renderer/App.css
Normal 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
263
src/renderer/App.tsx
Normal 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;
|
||||
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal file
82
src/renderer/components/ActivityBar/ActivityBar.css
Normal 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;
|
||||
}
|
||||
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal file
76
src/renderer/components/ActivityBar/ActivityBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/ActivityBar/index.ts
Normal file
1
src/renderer/components/ActivityBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ActivityBar } from './ActivityBar';
|
||||
298
src/renderer/components/Editor/Editor.css
Normal file
298
src/renderer/components/Editor/Editor.css
Normal 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);
|
||||
}
|
||||
405
src/renderer/components/Editor/Editor.tsx
Normal file
405
src/renderer/components/Editor/Editor.tsx
Normal 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 />;
|
||||
};
|
||||
1
src/renderer/components/Editor/index.ts
Normal file
1
src/renderer/components/Editor/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Editor } from './Editor';
|
||||
155
src/renderer/components/Panel/Panel.css
Normal file
155
src/renderer/components/Panel/Panel.css
Normal 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); }
|
||||
}
|
||||
69
src/renderer/components/Panel/Panel.tsx
Normal file
69
src/renderer/components/Panel/Panel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/Panel/index.ts
Normal file
1
src/renderer/components/Panel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Panel } from './Panel';
|
||||
203
src/renderer/components/Sidebar/Sidebar.css
Normal file
203
src/renderer/components/Sidebar/Sidebar.css
Normal 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;
|
||||
}
|
||||
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal file
288
src/renderer/components/Sidebar/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/Sidebar/index.ts
Normal file
1
src/renderer/components/Sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
91
src/renderer/components/StatusBar/StatusBar.css
Normal file
91
src/renderer/components/StatusBar/StatusBar.css
Normal 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;
|
||||
}
|
||||
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal file
73
src/renderer/components/StatusBar/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
src/renderer/components/StatusBar/index.ts
Normal file
1
src/renderer/components/StatusBar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { StatusBar } from './StatusBar';
|
||||
5
src/renderer/components/index.ts
Normal file
5
src/renderer/components/index.ts
Normal 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
13
src/renderer/index.html
Normal 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
10
src/renderer/main.tsx
Normal 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>
|
||||
);
|
||||
160
src/renderer/store/appStore.ts
Normal file
160
src/renderer/store/appStore.ts
Normal 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 }),
|
||||
}));
|
||||
1
src/renderer/store/index.ts
Normal file
1
src/renderer/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAppStore, type PostData, type MediaData, type TaskProgress } from './appStore';
|
||||
286
src/renderer/styles/global.css
Normal file
286
src/renderer/styles/global.css
Normal 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
109
src/renderer/types/electron.d.ts
vendored
Normal 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 {};
|
||||
Reference in New Issue
Block a user