feat: dashboard on start
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 }>();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user