feat: dashboard on start

This commit is contained in:
2026-02-11 06:23:55 +01:00
parent b7b1a4881f
commit 948873a971
7 changed files with 501 additions and 140 deletions

View File

@@ -668,6 +668,81 @@ export class PostEngine extends EventEmitter {
return Array.from(categories).sort();
}
async getTagsWithCounts(): Promise<{ tag: string; count: number }[]> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ tags: posts.tags })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
const tagCounts = new Map<string, number>();
for (const row of dbPosts) {
const parsed: string[] = JSON.parse(row.tags || '[]');
for (const tag of parsed) {
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
}
}
return Array.from(tagCounts.entries())
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count);
}
async getCategoriesWithCounts(): Promise<{ category: string; count: number }[]> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ categories: posts.categories })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
const catCounts = new Map<string, number>();
for (const row of dbPosts) {
const parsed: string[] = JSON.parse(row.categories || '[]');
for (const cat of parsed) {
catCounts.set(cat, (catCounts.get(cat) || 0) + 1);
}
}
return Array.from(catCounts.entries())
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count);
}
async getDashboardStats(): Promise<{
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
}> {
const db = getDatabase().getLocal();
const dbPosts = await db
.select({ status: posts.status })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId))
.all();
let draftCount = 0;
let publishedCount = 0;
let archivedCount = 0;
for (const row of dbPosts) {
switch (row.status) {
case 'draft': draftCount++; break;
case 'published': publishedCount++; break;
case 'archived': archivedCount++; break;
}
}
return {
totalPosts: dbPosts.length,
draftCount,
publishedCount,
archivedCount,
};
}
async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
const allPosts = await this.getAllPostsUnpaginated();
const counts = new Map<string, { year: number; month: number; count: number }>();

View File

@@ -168,6 +168,21 @@ export function registerIpcHandlers(): void {
return engine.getPostsByYearMonth();
});
ipcMain.handle('posts:getTagsWithCounts', async () => {
const engine = getPostEngine();
return engine.getTagsWithCounts();
});
ipcMain.handle('posts:getCategoriesWithCounts', async () => {
const engine = getPostEngine();
return engine.getCategoriesWithCounts();
});
ipcMain.handle('posts:getDashboardStats', async () => {
const engine = getPostEngine();
return engine.getDashboardStats();
});
ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
const engine = getPostEngine();
return engine.getLinksTo(id);

View File

@@ -32,6 +32,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTags: () => ipcRenderer.invoke('posts:getTags'),
getCategories: () => ipcRenderer.invoke('posts:getCategories'),
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'),
getTagsWithCounts: () => ipcRenderer.invoke('posts:getTagsWithCounts'),
getCategoriesWithCounts: () => ipcRenderer.invoke('posts:getCategoriesWithCounts'),
getDashboardStats: () => ipcRenderer.invoke('posts:getDashboardStats'),
getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
@@ -134,6 +137,9 @@ export interface ElectronAPI {
getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
getTagsWithCounts: () => Promise<{ tag: string; count: number }[]>;
getCategoriesWithCounts: () => Promise<{ category: string; count: number }[]>;
getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>;
getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
rebuildLinks: () => Promise<void>;

View File

@@ -370,101 +370,239 @@
resize: vertical;
}
/* Empty State / Welcome */
/* Empty State / Dashboard */
.editor-empty {
flex: 1;
display: flex;
align-items: center;
align-items: flex-start;
justify-content: center;
background-color: var(--vscode-editor-background);
overflow-y: auto;
padding: 40px 20px;
}
.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 {
.dashboard-content {
max-width: 720px;
width: 100%;
}
.welcome-shortcuts {
text-align: left;
background-color: var(--vscode-sideBar-background);
padding: 20px;
border-radius: 8px;
.dashboard-content h1 {
font-size: 24px;
font-weight: 400;
margin-bottom: 4px;
color: var(--vscode-editor-foreground);
}
.welcome-shortcuts h4 {
font-size: 12px;
.dashboard-content > .text-muted {
margin-bottom: 24px;
display: block;
}
/* Stat cards */
.dashboard-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
}
.stat-number {
font-size: 32px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vscode-editor-foreground);
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shortcut-item {
.stat-breakdown {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
flex-wrap: wrap;
gap: 6px;
}
.shortcut-item kbd {
background-color: var(--vscode-input-background);
border: 1px solid var(--vscode-input-border);
padding: 2px 6px;
.stat-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-family: var(--vscode-editor-font-family);
background-color: var(--vscode-input-background);
color: var(--vscode-descriptionForeground);
}
.stat-published {
color: var(--vscode-testing-iconPassed, #73c991);
}
.stat-draft {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.stat-archived {
color: var(--vscode-descriptionForeground);
}
/* Dashboard sections */
.dashboard-section {
background-color: var(--vscode-sideBar-background);
border-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.dashboard-section h4 {
font-size: 11px;
font-weight: 600;
color: var(--vscode-descriptionForeground);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
/* Timeline chart */
.timeline-chart {
display: flex;
align-items: flex-end;
gap: 4px;
height: 100px;
}
.timeline-bar-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.timeline-bar {
width: 100%;
max-width: 40px;
background-color: var(--vscode-button-background);
border-radius: 3px 3px 0 0;
margin-top: auto;
min-height: 4px;
position: relative;
transition: opacity 0.15s;
}
.timeline-bar:hover {
opacity: 0.8;
}
.timeline-bar-count {
position: absolute;
top: -16px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: var(--vscode-descriptionForeground);
}
.timeline-bar-label {
font-size: 9px;
color: var(--vscode-descriptionForeground);
margin-top: 4px;
white-space: nowrap;
}
/* Tag cloud */
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
align-items: baseline;
line-height: 1.6;
}
.dashboard-tag {
padding: 2px 8px;
border-radius: 10px;
background-color: var(--vscode-input-background);
color: var(--vscode-editor-foreground);
cursor: default;
transition: opacity 0.15s;
white-space: nowrap;
}
.dashboard-tag:hover {
opacity: 0.75;
}
.tag-cloud-more {
font-size: 11px;
}
.shortcut-item span {
color: var(--vscode-descriptionForeground);
.tag-count {
font-size: 10px;
opacity: 0.5;
margin-left: 2px;
}
.dashboard-category {
font-size: 12px;
border: 1px solid var(--vscode-input-border);
}
/* Recent posts */
.recent-posts-list {
display: flex;
flex-direction: column;
}
.recent-post-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.recent-post-item:hover {
background-color: var(--vscode-list-hoverBackground);
}
.recent-post-title {
flex: 1;
color: var(--vscode-editor-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-post-status {
font-size: 10px;
padding: 1px 6px;
border-radius: 3px;
background-color: var(--vscode-input-background);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.recent-post-status.status-published {
color: var(--vscode-testing-iconPassed, #73c991);
}
.recent-post-status.status-draft {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.recent-post-date {
color: var(--vscode-descriptionForeground);
font-size: 11px;
white-space: nowrap;
}

View File

@@ -748,76 +748,202 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
);
};
const WelcomeScreen: React.FC = () => {
const { setSelectedPost } = useAppStore();
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const handleNewPost = async () => {
try {
const newPost = await window.electronAPI?.posts.create({
title: '',
content: '',
tags: [],
categories: [],
});
if (newPost) {
setSelectedPost(newPost.id);
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface DashboardStats {
totalPosts: number;
draftCount: number;
publishedCount: number;
archivedCount: number;
}
interface TagCount {
tag: string;
count: number;
}
interface CategoryCount {
category: string;
count: number;
}
const Dashboard: React.FC = () => {
const { posts, media } = useAppStore();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
useEffect(() => {
const loadStats = async () => {
try {
const [ds, ym, tc, cc] = await Promise.all([
window.electronAPI?.posts.getDashboardStats(),
window.electronAPI?.posts.getByYearMonth(),
window.electronAPI?.posts.getTagsWithCounts(),
window.electronAPI?.posts.getCategoriesWithCounts(),
]);
if (ds) setStats(ds);
if (ym) setYearMonthData(ym);
if (tc) setTagCounts(tc);
if (cc) setCategoryCounts(cc);
} catch (e) {
console.error('Failed to load dashboard stats:', e);
}
} catch (error) {
console.error('Failed to create post:', error);
}
};
};
loadStats();
}, [posts.length, media.length]);
// Media stats
const totalMediaSize = media.reduce((sum, m) => sum + (m.size || 0), 0);
const imageCount = media.filter(m => m.mimeType?.startsWith('image/')).length;
// Recent posts (last 5 updated)
const recentPosts = useMemo(() =>
[...posts].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()).slice(0, 5),
[posts]
);
// Timeline chart - last 12 months that have posts
const timelineEntries = useMemo(() => {
const sorted = [...yearMonthData].sort((a, b) => a.year === b.year ? a.month - b.month : a.year - b.year);
return sorted.slice(-12);
}, [yearMonthData]);
const maxCount = Math.max(1, ...timelineEntries.map(e => e.count));
// Tag cloud font sizing
const tagCloudItems = useMemo(() => {
if (tagCounts.length === 0) return [];
const items = tagCounts.slice(0, 40);
const maxTagCount = Math.max(1, ...items.map(t => t.count));
const minTagCount = Math.min(...items.map(t => t.count));
const range = Math.max(1, maxTagCount - minTagCount);
// Font sizes from 11px to 22px
return items.map(t => ({
...t,
fontSize: 11 + ((t.count - minTagCount) / range) * 11,
})).sort((a, b) => a.tag.localeCompare(b.tag)); // alphabetical for cloud layout
}, [tagCounts]);
const displayTotalPosts = stats?.totalPosts ?? posts.length;
const displayDraftCount = stats?.draftCount ?? 0;
const displayPublishedCount = stats?.publishedCount ?? 0;
const displayArchivedCount = stats?.archivedCount ?? 0;
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={handleNewPost}>
New Post
</button>
<div className="dashboard-content">
<h1>Dashboard</h1>
<p className="text-muted">Overview of your blog database</p>
<div className="dashboard-stats">
<div className="stat-card">
<div className="stat-number">{displayTotalPosts}</div>
<div className="stat-label">Total Posts</div>
<div className="stat-breakdown">
<span className="stat-tag stat-published">{displayPublishedCount} published</span>
<span className="stat-tag stat-draft">{displayDraftCount} drafts</span>
{displayArchivedCount > 0 && <span className="stat-tag stat-archived">{displayArchivedCount} archived</span>}
</div>
</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 className="stat-card">
<div className="stat-number">{media.length}</div>
<div className="stat-label">Media Files</div>
<div className="stat-breakdown">
<span className="stat-tag">{imageCount} images</span>
<span className="stat-tag">{formatBytes(totalMediaSize)}</span>
</div>
</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 className="stat-card">
<div className="stat-number">{tagCounts.length}</div>
<div className="stat-label">Tags</div>
<div className="stat-breakdown">
<span className="stat-tag">{categoryCounts.length} categories</span>
</div>
</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>
{timelineEntries.length > 0 && (
<div className="dashboard-section">
<h4>Posts Over Time</h4>
<div className="timeline-chart">
{timelineEntries.map((entry) => (
<div key={`${entry.year}-${entry.month}`} className="timeline-bar-container">
<div className="timeline-bar" style={{ height: `${(entry.count / maxCount) * 100}%` }}>
<span className="timeline-bar-count">{entry.count}</span>
</div>
<div className="timeline-bar-label">{MONTH_NAMES[entry.month]}</div>
</div>
))}
</div>
</div>
</div>
)}
{tagCloudItems.length > 0 && (
<div className="dashboard-section">
<h4>Tags</h4>
<div className="tag-cloud">
{tagCloudItems.map(item => (
<span
key={item.tag}
className="dashboard-tag"
style={{ fontSize: `${item.fontSize}px` }}
title={`${item.count} post${item.count !== 1 ? 's' : ''}`}
>
{item.tag}
</span>
))}
{tagCounts.length > 40 && <span className="text-muted tag-cloud-more">+{tagCounts.length - 40} more</span>}
</div>
</div>
)}
{categoryCounts.length > 0 && (
<div className="dashboard-section">
<h4>Categories</h4>
<div className="tag-cloud">
{categoryCounts.map(cat => (
<span
key={cat.category}
className="dashboard-tag dashboard-category"
title={`${cat.count} post${cat.count !== 1 ? 's' : ''}`}
>
{cat.category} <span className="tag-count">{cat.count}</span>
</span>
))}
</div>
</div>
)}
{recentPosts.length > 0 && (
<div className="dashboard-section">
<h4>Recently Updated</h4>
<div className="recent-posts-list">
{recentPosts.map(post => (
<div
key={post.id}
className="recent-post-item"
onClick={() => {
useAppStore.getState().setActiveView('posts');
useAppStore.getState().setSelectedPost(post.id);
}}
>
<span className="recent-post-title">{post.title || 'Untitled'}</span>
<span className={`recent-post-status status-${post.status}`}>{post.status}</span>
<span className="recent-post-date">{new Date(post.updatedAt).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
@@ -889,7 +1015,7 @@ export const Editor: React.FC = () => {
return (
<>
<WelcomeScreen />
<Dashboard />
{renderErrorModal()}
</>
);

View File

@@ -4,14 +4,15 @@ import { ProjectSelector } from '../ProjectSelector';
import './StatusBar.css';
export const StatusBar: React.FC = () => {
const {
syncStatus,
syncConfigured,
pendingChanges,
posts,
const {
syncStatus,
syncConfigured,
pendingChanges,
posts,
media,
tasks,
selectedPostId,
totalPosts,
} = useAppStore();
const runningTasks = tasks.filter(t => t.status === 'running');
@@ -61,7 +62,7 @@ export const StatusBar: React.FC = () => {
{/* Stats */}
<div className="status-bar-item">
<span>{posts.length} posts</span>
<span>{totalPosts || posts.length} posts</span>
</div>
<div className="status-bar-item">
<span>{media.length} media</span>