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

@@ -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 monaco is important. Choose a good solid template engine for node-js based tools that is especialy targeted
to easy template creation. 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, 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, so that starting is simple. New css templates must be easily integrateable into the application,
maybe even with easy importing from a central bootstrap site or something like that. 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 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 capable of building with this tooling. So we need templates for overview pages and ways to manage menues

View File

@@ -668,6 +668,81 @@ export class PostEngine extends EventEmitter {
return Array.from(categories).sort(); 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 }[]> { async getPostsByYearMonth(): Promise<{ year: number; month: number; count: number }[]> {
const allPosts = await this.getAllPostsUnpaginated(); const allPosts = await this.getAllPostsUnpaginated();
const counts = new Map<string, { year: number; month: number; count: number }>(); const counts = new Map<string, { year: number; month: number; count: number }>();

View File

@@ -168,6 +168,21 @@ export function registerIpcHandlers(): void {
return engine.getPostsByYearMonth(); 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) => { ipcMain.handle('posts:getLinksTo', async (_, id: string) => {
const engine = getPostEngine(); const engine = getPostEngine();
return engine.getLinksTo(id); return engine.getLinksTo(id);

View File

@@ -32,6 +32,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
getTags: () => ipcRenderer.invoke('posts:getTags'), getTags: () => ipcRenderer.invoke('posts:getTags'),
getCategories: () => ipcRenderer.invoke('posts:getCategories'), getCategories: () => ipcRenderer.invoke('posts:getCategories'),
getByYearMonth: () => ipcRenderer.invoke('posts:getByYearMonth'), 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), getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id),
getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id), getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id),
rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'), rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'),
@@ -134,6 +137,9 @@ export interface ElectronAPI {
getTags: () => Promise<string[]>; getTags: () => Promise<string[]>;
getCategories: () => Promise<string[]>; getCategories: () => Promise<string[]>;
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>; 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 }[]>; getLinksTo: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>; getLinkedBy: (id: string) => Promise<{ id: string; title: string; slug: string }[]>;
rebuildLinks: () => Promise<void>; rebuildLinks: () => Promise<void>;

View File

@@ -370,101 +370,239 @@
resize: vertical; resize: vertical;
} }
/* Empty State / Welcome */ /* Empty State / Dashboard */
.editor-empty { .editor-empty {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: center; justify-content: center;
background-color: var(--vscode-editor-background); background-color: var(--vscode-editor-background);
overflow-y: auto;
padding: 40px 20px;
} }
.welcome-content { .dashboard-content {
max-width: 600px; max-width: 720px;
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%; width: 100%;
} }
.welcome-shortcuts { .dashboard-content h1 {
text-align: left; font-size: 24px;
background-color: var(--vscode-sideBar-background); font-weight: 400;
padding: 20px; margin-bottom: 4px;
border-radius: 8px; color: var(--vscode-editor-foreground);
} }
.welcome-shortcuts h4 { .dashboard-content > .text-muted {
font-size: 12px; 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; 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); color: var(--vscode-descriptionForeground);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
margin-bottom: 10px;
} }
.shortcut-list { .stat-breakdown {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shortcut-item {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: 8px; gap: 6px;
font-size: 12px;
} }
.shortcut-item kbd { .stat-tag {
background-color: var(--vscode-input-background); font-size: 11px;
border: 1px solid var(--vscode-input-border); padding: 2px 8px;
padding: 2px 6px;
border-radius: 3px; 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; font-size: 11px;
} }
.shortcut-item span { .tag-count {
color: var(--vscode-descriptionForeground); 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 formatBytes = (bytes: number): string => {
const { setSelectedPost } = useAppStore(); 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 () => { const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
try {
const newPost = await window.electronAPI?.posts.create({ interface DashboardStats {
title: '', totalPosts: number;
content: '', draftCount: number;
tags: [], publishedCount: number;
categories: [], archivedCount: number;
}); }
if (newPost) {
setSelectedPost(newPost.id); 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 ( return (
<div className="editor-empty"> <div className="editor-empty">
<div className="welcome-content"> <div className="dashboard-content">
<h1>Blogging Desktop Server</h1> <h1>Dashboard</h1>
<p className="text-muted">bDS - Your offline-first blogging platform</p> <p className="text-muted">Overview of your blog database</p>
<div className="welcome-actions"> <div className="dashboard-stats">
<div className="welcome-action"> <div className="stat-card">
<h3>Create a New Post</h3> <div className="stat-number">{displayTotalPosts}</div>
<p>Start writing your next blog post with Markdown support.</p> <div className="stat-label">Total Posts</div>
<button onClick={handleNewPost}> <div className="stat-breakdown">
New Post <span className="stat-tag stat-published">{displayPublishedCount} published</span>
</button> <span className="stat-tag stat-draft">{displayDraftCount} drafts</span>
{displayArchivedCount > 0 && <span className="stat-tag stat-archived">{displayArchivedCount} archived</span>}
</div>
</div> </div>
<div className="welcome-action"> <div className="stat-card">
<h3>Import Media</h3> <div className="stat-number">{media.length}</div>
<p>Add images and files to use in your posts.</p> <div className="stat-label">Media Files</div>
<button className="secondary" onClick={() => window.electronAPI?.media.importDialog()}> <div className="stat-breakdown">
Import Media <span className="stat-tag">{imageCount} images</span>
</button> <span className="stat-tag">{formatBytes(totalMediaSize)}</span>
</div>
</div> </div>
<div className="welcome-action"> <div className="stat-card">
<h3>Configure Sync</h3> <div className="stat-number">{tagCounts.length}</div>
<p>Connect to Turso for cloud synchronization.</p> <div className="stat-label">Tags</div>
<button className="secondary" onClick={() => useAppStore.getState().setActiveView('settings')}> <div className="stat-breakdown">
Open Settings <span className="stat-tag">{categoryCounts.length} categories</span>
</button> </div>
</div> </div>
</div> </div>
<div className="welcome-shortcuts"> {timelineEntries.length > 0 && (
<h4>Keyboard Shortcuts</h4> <div className="dashboard-section">
<div className="shortcut-list"> <h4>Posts Over Time</h4>
<div className="shortcut-item"> <div className="timeline-chart">
<kbd>Ctrl</kbd> + <kbd>N</kbd> {timelineEntries.map((entry) => (
<span>New Post</span> <div key={`${entry.year}-${entry.month}`} className="timeline-bar-container">
</div> <div className="timeline-bar" style={{ height: `${(entry.count / maxCount) * 100}%` }}>
<div className="shortcut-item"> <span className="timeline-bar-count">{entry.count}</span>
<kbd>Ctrl</kbd> + <kbd>S</kbd> </div>
<span>Save</span> <div className="timeline-bar-label">{MONTH_NAMES[entry.month]}</div>
</div> </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> )}
{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>
</div> </div>
); );
@@ -889,7 +1015,7 @@ export const Editor: React.FC = () => {
return ( return (
<> <>
<WelcomeScreen /> <Dashboard />
{renderErrorModal()} {renderErrorModal()}
</> </>
); );

View File

@@ -12,6 +12,7 @@ export const StatusBar: React.FC = () => {
media, media,
tasks, tasks,
selectedPostId, selectedPostId,
totalPosts,
} = useAppStore(); } = useAppStore();
const runningTasks = tasks.filter(t => t.status === 'running'); const runningTasks = tasks.filter(t => t.status === 'running');
@@ -61,7 +62,7 @@ export const StatusBar: React.FC = () => {
{/* Stats */} {/* Stats */}
<div className="status-bar-item"> <div className="status-bar-item">
<span>{posts.length} posts</span> <span>{totalPosts || posts.length} posts</span>
</div> </div>
<div className="status-bar-item"> <div className="status-bar-item">
<span>{media.length} media</span> <span>{media.length} media</span>