feat: pages shortcut feature
This commit is contained in:
@@ -10,6 +10,12 @@ const PostsIcon = () => (
|
|||||||
</svg>
|
</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 = () => (
|
const MediaIcon = () => (
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
|
<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"/>
|
<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;
|
const isImportActive = activeView === 'import' && sidebarVisible;
|
||||||
|
|
||||||
// Handle view click - toggle sidebar if clicking on active view, otherwise switch view
|
// 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) {
|
if (activeView === view && sidebarVisible) {
|
||||||
// Clicking on active view toggles sidebar off
|
// Clicking on active view toggles sidebar off
|
||||||
toggleSidebar();
|
toggleSidebar();
|
||||||
@@ -118,6 +124,13 @@ export const ActivityBar: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<PostsIcon />
|
<PostsIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`activity-bar-item ${activeView === 'pages' && sidebarVisible ? 'active' : ''}`}
|
||||||
|
onClick={() => handleViewClick('pages')}
|
||||||
|
title="Pages (click again to toggle sidebar)"
|
||||||
|
>
|
||||||
|
<PagesIcon />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
|
className={`activity-bar-item ${activeView === 'media' && sidebarVisible ? 'active' : ''}`}
|
||||||
onClick={() => handleViewClick('media')}
|
onClick={() => handleViewClick('media')}
|
||||||
|
|||||||
@@ -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 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 {
|
interface CalendarViewProps {
|
||||||
onDateSelect: (year: number, month?: number) => void;
|
onDateSelect: (year: number, month?: number) => void;
|
||||||
selectedYear?: number;
|
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 { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
|
||||||
|
const isPagesMode = mode === 'pages';
|
||||||
|
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -484,7 +513,14 @@ const PostsList: React.FC = () => {
|
|||||||
window.electronAPI?.tags.getAll(),
|
window.electronAPI?.tags.getAll(),
|
||||||
]);
|
]);
|
||||||
if (tags) setAvailableTags(tags as string[]);
|
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) {
|
if (allTagsData) {
|
||||||
const colorMap = new Map<string, string>();
|
const colorMap = new Map<string, string>();
|
||||||
for (const tag of allTagsData as TagData[]) {
|
for (const tag of allTagsData as TagData[]) {
|
||||||
@@ -516,7 +552,7 @@ const PostsList: React.FC = () => {
|
|||||||
fullPosts.push(post as PostData);
|
fullPosts.push(post as PostData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSearchResults(fullPosts);
|
setSearchResults(applyPageFilter(fullPosts, isPagesMode));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@@ -536,15 +572,17 @@ const PostsList: React.FC = () => {
|
|||||||
setSelectedYear(year);
|
setSelectedYear(year);
|
||||||
setSelectedMonth(month);
|
setSelectedMonth(month);
|
||||||
|
|
||||||
|
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await window.electronAPI?.posts.filter({
|
const results = await window.electronAPI?.posts.filter({
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
|
||||||
});
|
});
|
||||||
if (results) {
|
if (results) {
|
||||||
setFilteredPosts(results as PostData[]);
|
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Filter failed:', error);
|
console.error('Filter failed:', error);
|
||||||
@@ -559,22 +597,24 @@ const PostsList: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const results = await window.electronAPI?.posts.filter({
|
const results = await window.electronAPI?.posts.filter({
|
||||||
year: selectedYear,
|
year: selectedYear,
|
||||||
month: selectedMonth,
|
month: selectedMonth,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
|
||||||
});
|
});
|
||||||
if (results) {
|
if (results) {
|
||||||
setFilteredPosts(results as PostData[]);
|
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Filter failed:', error);
|
console.error('Filter failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}, [selectedTags, selectedCategories]);
|
}, [selectedTags, selectedCategories, selectedYear, selectedMonth, isPagesMode]);
|
||||||
|
|
||||||
// Track previous post statuses to detect changes
|
// Track previous post statuses to detect changes
|
||||||
const prevPostStatusMapRef = useRef<Map<string, string>>(new Map());
|
const prevPostStatusMapRef = useRef<Map<string, string>>(new Map());
|
||||||
@@ -613,7 +653,7 @@ const PostsList: React.FC = () => {
|
|||||||
fullPosts.push(post as PostData);
|
fullPosts.push(post as PostData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSearchResults(fullPosts);
|
setSearchResults(applyPageFilter(fullPosts, isPagesMode));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search refresh failed:', error);
|
console.error('Search refresh failed:', error);
|
||||||
@@ -623,15 +663,16 @@ const PostsList: React.FC = () => {
|
|||||||
} else if (selectedYear || selectedTags.length > 0 || selectedCategories.length > 0) {
|
} else if (selectedYear || selectedTags.length > 0 || selectedCategories.length > 0) {
|
||||||
// Re-run filter
|
// Re-run filter
|
||||||
const refetchFilters = async () => {
|
const refetchFilters = async () => {
|
||||||
|
const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode);
|
||||||
try {
|
try {
|
||||||
const results = await window.electronAPI?.posts.filter({
|
const results = await window.electronAPI?.posts.filter({
|
||||||
year: selectedYear,
|
year: selectedYear,
|
||||||
month: selectedMonth,
|
month: selectedMonth,
|
||||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||||
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
|
categories: mergedCategories.length > 0 ? mergedCategories : undefined,
|
||||||
});
|
});
|
||||||
if (results) {
|
if (results) {
|
||||||
setFilteredPosts(results as PostData[]);
|
setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Filter refresh failed:', error);
|
console.error('Filter refresh failed:', error);
|
||||||
@@ -644,7 +685,7 @@ const PostsList: React.FC = () => {
|
|||||||
setFilteredPosts(null);
|
setFilteredPosts(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories]);
|
}, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories, isPagesMode]);
|
||||||
|
|
||||||
const handleCreatePost = async () => {
|
const handleCreatePost = async () => {
|
||||||
// Create a real post immediately in the database with default empty content
|
// 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
|
// Memoized grouping that freshens cached filter results with current store data
|
||||||
// This ensures status changes are reflected even when filters are active
|
// This ensures status changes are reflected even when filters are active
|
||||||
const groupedPosts = useMemo(
|
const groupedPosts = useMemo(
|
||||||
() => groupPostsByStatus(posts, filteredDisplayPosts),
|
() => groupPostsByStatus(postSubset, filteredDisplayPosts),
|
||||||
[posts, filteredDisplayPosts]
|
[postSubset, filteredDisplayPosts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearAllFilters = () => {
|
const clearAllFilters = () => {
|
||||||
@@ -718,7 +759,7 @@ const PostsList: React.FC = () => {
|
|||||||
<div className="sidebar-content">
|
<div className="sidebar-content">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-header">
|
<div className="sidebar-section-header">
|
||||||
<span>POSTS</span>
|
<span>{isPagesMode ? 'PAGES' : 'POSTS'}</span>
|
||||||
<div className="sidebar-actions">
|
<div className="sidebar-actions">
|
||||||
<button
|
<button
|
||||||
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||||
@@ -855,9 +896,9 @@ const PostsList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{posts.length === 0 && !isFiltered && (
|
{postSubset.length === 0 && !isFiltered && (
|
||||||
<div className="sidebar-empty">
|
<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>
|
<button onClick={handleCreatePost}>Create your first post</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1541,7 +1582,12 @@ export const Sidebar: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar">
|
<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 === 'media' && <MediaList />}
|
||||||
{activeView === 'settings' && <SettingsNav />}
|
{activeView === 'settings' && <SettingsNav />}
|
||||||
{activeView === 'tags' && <TagsNav />}
|
{activeView === 'tags' && <TagsNav />}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ interface AppState {
|
|||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
activeView: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
activeView: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import';
|
||||||
sidebarVisible: boolean;
|
sidebarVisible: boolean;
|
||||||
panelVisible: boolean;
|
panelVisible: boolean;
|
||||||
selectedPostId: string | null;
|
selectedPostId: string | null;
|
||||||
@@ -96,7 +96,7 @@ interface AppState {
|
|||||||
restoreTabState: (state: TabState) => void;
|
restoreTabState: (state: TabState) => void;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setActiveView: (view: 'posts' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
setActiveView: (view: 'posts' | 'pages' | 'media' | 'settings' | 'tags' | 'chat' | 'import') => void;
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
togglePanel: () => void;
|
togglePanel: () => void;
|
||||||
setSelectedPost: (id: string | null) => void;
|
setSelectedPost: (id: string | null) => void;
|
||||||
|
|||||||
90
tests/renderer/components/PagesShortcut.test.tsx
Normal file
90
tests/renderer/components/PagesShortcut.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user