From 948873a971095807750820316f99fdf0d36d8958 Mon Sep 17 00:00:00 2001 From: hugo Date: Wed, 11 Feb 2026 06:23:55 +0100 Subject: [PATCH] feat: dashboard on start --- VISION.md | 6 +- src/main/engine/PostEngine.ts | 75 +++++ src/main/ipc/handlers.ts | 15 + src/main/preload.ts | 6 + src/renderer/components/Editor/Editor.css | 282 +++++++++++++----- src/renderer/components/Editor/Editor.tsx | 244 +++++++++++---- .../components/StatusBar/StatusBar.tsx | 13 +- 7 files changed, 501 insertions(+), 140 deletions(-) diff --git a/VISION.md b/VISION.md index a1e283b..4a81e6c 100644 --- a/VISION.md +++ b/VISION.md @@ -258,10 +258,10 @@ edit in the application itself. Template editing should provide proper syntax hi monaco is important. Choose a good solid template engine for node-js based tools that is especialy targeted to easy template creation. -For the styling I want the system to be based on bootstrap templates, so that the look can be easily swapped +For the styling I want the system to be based on css templates, so that the look can be easily swapped to the wish of the user. There should be a selection of light and dark themes bundled with the application, -so that starting is simple. New bootstrap css templates must be easily integrateable into the application, -maybe even with easy importing from a central bootstrap site or something like that. +so that starting is simple. New css templates must be easily integrateable into the application, +maybe even with easy importing from a central repository site or something like that. Check the site https://hugo.rfc1437.de/ for its structure, this is the structure of blog I want to be capable of building with this tooling. So we need templates for overview pages and ways to manage menues diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 47c2f2f..8c98c12 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -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(); + 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(); + 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(); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6209375..b87b075 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -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); diff --git a/src/main/preload.ts b/src/main/preload.ts index 774b213..0d6afd6 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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; getCategories: () => Promise; 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; diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 5cc95ae..801585a 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -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; } diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 076d470..ecaabf1 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -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(null); + const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); + const [tagCounts, setTagCounts] = useState([]); + const [categoryCounts, setCategoryCounts] = useState([]); + + 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 (
-
-

Blogging Desktop Server

-

bDS - Your offline-first blogging platform

- -
-
-

Create a New Post

-

Start writing your next blog post with Markdown support.

- +
+

Dashboard

+

Overview of your blog database

+ +
+
+
{displayTotalPosts}
+
Total Posts
+
+ {displayPublishedCount} published + {displayDraftCount} drafts + {displayArchivedCount > 0 && {displayArchivedCount} archived} +
-
-

Import Media

-

Add images and files to use in your posts.

- +
+
{media.length}
+
Media Files
+
+ {imageCount} images + {formatBytes(totalMediaSize)} +
-
-

Configure Sync

-

Connect to Turso for cloud synchronization.

- +
+
{tagCounts.length}
+
Tags
+
+ {categoryCounts.length} categories +
-
-

Keyboard Shortcuts

-
-
- Ctrl + N - New Post -
-
- Ctrl + S - Save -
-
- Ctrl + B - Toggle Sidebar -
-
- Ctrl + Shift + P - Publish + {timelineEntries.length > 0 && ( +
+

Posts Over Time

+
+ {timelineEntries.map((entry) => ( +
+
+ {entry.count} +
+
{MONTH_NAMES[entry.month]}
+
+ ))}
-
+ )} + + {tagCloudItems.length > 0 && ( +
+

Tags

+
+ {tagCloudItems.map(item => ( + + {item.tag} + + ))} + {tagCounts.length > 40 && +{tagCounts.length - 40} more} +
+
+ )} + + {categoryCounts.length > 0 && ( +
+

Categories

+
+ {categoryCounts.map(cat => ( + + {cat.category} {cat.count} + + ))} +
+
+ )} + + {recentPosts.length > 0 && ( +
+

Recently Updated

+
+ {recentPosts.map(post => ( +
{ + useAppStore.getState().setActiveView('posts'); + useAppStore.getState().setSelectedPost(post.id); + }} + > + {post.title || 'Untitled'} + {post.status} + {new Date(post.updatedAt).toLocaleDateString()} +
+ ))} +
+
+ )}
); @@ -889,7 +1015,7 @@ export const Editor: React.FC = () => { return ( <> - + {renderErrorModal()} ); diff --git a/src/renderer/components/StatusBar/StatusBar.tsx b/src/renderer/components/StatusBar/StatusBar.tsx index 69a51bc..8804b64 100644 --- a/src/renderer/components/StatusBar/StatusBar.tsx +++ b/src/renderer/components/StatusBar/StatusBar.tsx @@ -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 */}
- {posts.length} posts + {totalPosts || posts.length} posts
{media.length} media