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
|
||||
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
|
||||
|
||||
@@ -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 }>();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user