feat: pages shortcut feature

This commit is contained in:
2026-02-16 08:01:33 +01:00
parent f67247f89e
commit 9440c5e543
4 changed files with 170 additions and 21 deletions

View File

@@ -10,6 +10,12 @@ const PostsIcon = () => (
</svg>
);
const PagesIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"/>
</svg>
);
const MediaIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
@@ -62,7 +68,7 @@ export const ActivityBar: React.FC = () => {
const isImportActive = activeView === 'import' && sidebarVisible;
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
const handleViewClick = (view: 'posts' | 'media' | 'chat') => {
const handleViewClick = (view: 'posts' | 'pages' | 'media' | 'chat') => {
if (activeView === view && sidebarVisible) {
// Clicking on active view toggles sidebar off
toggleSidebar();
@@ -118,6 +124,13 @@ export const ActivityBar: React.FC = () => {
>
<PostsIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
onClick={() => handleViewClick('pages')}
title="Pages (click again to toggle sidebar)"
>
<PagesIcon />
</button>
<button
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
onClick={() => handleViewClick('media')}

View File

@@ -57,6 +57,27 @@ const getPostTypeIcon = (categories: string[]): { icon: string; type: string } =
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const PAGE_CATEGORY = 'page';
const hasPageCategory = (post: PostData): boolean =>
post.categories.some((category) => category.toLowerCase() === PAGE_CATEGORY);
const applyPageFilter = (posts: PostData[], isPagesMode: boolean): PostData[] =>
isPagesMode ? posts.filter(hasPageCategory) : posts;
const mergeWithPageCategory = (categories: string[], isPagesMode: boolean): string[] => {
if (!isPagesMode) {
return categories;
}
const normalized = new Set(categories.map((category) => category.toLowerCase()));
if (normalized.has(PAGE_CATEGORY)) {
return categories;
}
return [...categories, PAGE_CATEGORY];
};
interface CalendarViewProps {
onDateSelect: (year: number, month?: number) => void;
selectedYear?: number;
@@ -458,8 +479,16 @@ const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
);
};
const PostsList: React.FC = () => {
type PostsListMode = 'posts' | 'pages';
interface PostsListProps {
mode: PostsListMode;
}
const PostsList: React.FC<PostsListProps> = ({ mode }) => {
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
const isPagesMode = mode === 'pages';
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
@@ -484,7 +513,14 @@ const PostsList: React.FC = () => {
window.electronAPI?.tags.getAll(),
]);
if (tags) setAvailableTags(tags as string[]);
if (categories) setAvailableCategories(categories as string[]);
if (categories) {
const allCategories = categories as string[];
setAvailableCategories(
isPagesMode
? allCategories.filter((category) => category.toLowerCase() !== PAGE_CATEGORY)
: allCategories
);
}
if (allTagsData) {
const colorMap = new Map<string, string>();
for (const tag of allTagsData as TagData[]) {
@@ -516,7 +552,7 @@ const PostsList: React.FC = () => {
fullPosts.push(post as PostData);
}
}
setSearchResults(fullPosts);
setSearchResults(applyPageFilter(fullPosts, isPagesMode));
}
} catch (error) {
console.error('Search failed:', error);
@@ -536,15 +572,17 @@ const PostsList: React.FC = () => {
setSelectedYear(year);
setSelectedMonth(month);
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
try {
const results = await window.electronAPI?.posts.filter({
year,
month,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
}
} catch (error) {
console.error('Filter failed:', error);
@@ -559,22 +597,24 @@ const PostsList: React.FC = () => {
return;
}
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
try {
const results = await window.electronAPI?.posts.filter({
year: selectedYear,
month: selectedMonth,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
}
} catch (error) {
console.error('Filter failed:', error);
}
};
applyFilters();
}, [selectedTags, selectedCategories]);
}, [selectedTags, selectedCategories, selectedYear, selectedMonth, isPagesMode]);
// Track previous post statuses to detect changes
const prevPostStatusMapRef = useRef<Map<string, string>>(new Map());
@@ -613,7 +653,7 @@ const PostsList: React.FC = () => {
fullPosts.push(post as PostData);
}
}
setSearchResults(fullPosts);
setSearchResults(applyPageFilter(fullPosts, isPagesMode));
}
} catch (error) {
console.error('Search refresh failed:', error);
@@ -623,15 +663,16 @@ const PostsList: React.FC = () => {
} else if (selectedYear || selectedTags.length > 0 || selectedCategories.length > 0) {
// Re-run filter
const refetchFilters = async () => {
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
try {
const results = await window.electronAPI?.posts.filter({
year: selectedYear,
month: selectedMonth,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
}
} catch (error) {
console.error('Filter refresh failed:', error);
@@ -644,7 +685,7 @@ const PostsList: React.FC = () => {
setFilteredPosts(null);
}
}
}, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories]);
}, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories, isPagesMode]);
const handleCreatePost = async () => {
// Create a real post immediately in the database with default empty content
@@ -691,8 +732,8 @@ const PostsList: React.FC = () => {
// Memoized grouping that freshens cached filter results with current store data
// This ensures status changes are reflected even when filters are active
const groupedPosts = useMemo(
() => groupPostsByStatus(posts, filteredDisplayPosts),
[posts, filteredDisplayPosts]
() => groupPostsByStatus(postSubset, filteredDisplayPosts),
[postSubset, filteredDisplayPosts]
);
const clearAllFilters = () => {
@@ -718,7 +759,7 @@ const PostsList: React.FC = () => {
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>POSTS</span>
<span>{isPagesMode ? 'PAGES' : 'POSTS'}</span>
<div className="sidebar-actions">
<button
className={`sidebar-action ${showFilters ? 'active' : ''}`}
@@ -855,9 +896,9 @@ const PostsList: React.FC = () => {
</div>
)}
{posts.length === 0 && !isFiltered && (
{postSubset.length === 0 && !isFiltered && (
<div className="sidebar-empty">
<p>No posts yet</p>
<p>{isPagesMode ? 'No pages yet' : 'No posts yet'}</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
@@ -1541,7 +1582,12 @@ export const Sidebar: React.FC = () => {
return (
<div className="sidebar">
{activeView === 'posts' && <PostsList />}
<div style={{ display: activeView === 'posts' ? 'block' : 'none' }}>
<PostsList mode="posts" />
</div>
<div style={{ display: activeView === 'pages' ? 'block' : 'none' }}>
<PostsList mode="pages" />
</div>
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsNav />}
{activeView === 'tags' && <TagsNav />}

View File

@@ -50,7 +50,7 @@ interface AppState {
activeTabId: string | null;
// UI State
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
sidebarVisible: boolean;
panelVisible: boolean;
selectedPostId: string | null;
@@ -96,7 +96,7 @@ interface AppState {
restoreTabState: (state: TabState) => void;
// Actions
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
toggleSidebar: () => void;
togglePanel: () => void;
setSelectedPost: (id: string | null) => void;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, within } from '@testing-library/react';
import { ActivityBar } from '../../../src/renderer/components/ActivityBar/ActivityBar';
import { Sidebar } from '../../../src/renderer/components/Sidebar/Sidebar';
import { useAppStore, type PostData } from '../../../src/renderer/store';
const createMockPost = (overrides: Partial<PostData> = {}): PostData => ({
id: `post-${Math.random().toString(36).slice(2)}`,
projectId: 'project-1',
title: 'Test Post',
slug: 'test-post',
content: 'content',
status: 'published',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
tags: [],
categories: [],
...overrides,
});
describe('Pages shortcut UI', () => {
beforeEach(() => {
const now = new Date().toISOString();
useAppStore.setState({
activeView: 'posts',
sidebarVisible: true,
tabs: [],
activeTabId: null,
posts: [
createMockPost({
id: 'post-page',
title: 'About Page',
categories: ['page'],
updatedAt: now,
}),
createMockPost({
id: 'post-article',
title: 'Regular Article',
categories: ['article'],
updatedAt: now,
}),
],
hasMorePosts: false,
totalPosts: 2,
});
window.electronAPI.posts.getTags = vi.fn().mockResolvedValue([]);
window.electronAPI.posts.getCategories = vi.fn().mockResolvedValue(['page', 'article']);
window.electronAPI.posts.getByYearMonth = vi.fn().mockResolvedValue([]);
(window.electronAPI as any).tags = {
getAll: vi.fn().mockResolvedValue([]),
};
window.electronAPI.posts.search = vi.fn().mockResolvedValue([]);
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([]);
window.electronAPI.posts.get = vi.fn().mockResolvedValue(null);
});
it('shows a pages button in the activity bar', () => {
render(<ActivityBar />);
expect(screen.getByTitle('Pages (click again to toggle sidebar)')).toBeInTheDocument();
});
it('uses a distinct pages icon shape', () => {
render(<ActivityBar />);
const pagesButton = screen.getByTitle('Pages (click again to toggle sidebar)');
const pagesSvg = pagesButton.querySelector('svg');
expect(pagesSvg).not.toBeNull();
expect(pagesSvg?.querySelector('path')?.getAttribute('d')).toBe(
'M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z'
);
});
it('shows only page-category posts when pages view is active', async () => {
useAppStore.setState({ activeView: 'pages', sidebarVisible: true });
render(<Sidebar />);
const pagesHeader = await screen.findByText('PAGES');
const pagesPanel = pagesHeader.closest('.sidebar-content');
expect(pagesPanel).not.toBeNull();
expect(within(pagesPanel as HTMLElement).getByText('About Page')).toBeInTheDocument();
expect(within(pagesPanel as HTMLElement).queryByText('Regular Article')).not.toBeInTheDocument();
});
});