fix: pages feature now works properly

This commit is contained in:
2026-02-16 08:15:02 +01:00
parent 9440c5e543
commit 9bab8ac89c
4 changed files with 139 additions and 10 deletions

View File

@@ -747,7 +747,7 @@ export class PostEngine extends EventEmitter {
// Search the stemmed content, filtered by project_id for project isolation
const result = await client.execute({
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 50`,
sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500`,
args: [this.currentProjectId, stemmedQuery],
});

View File

@@ -483,12 +483,14 @@ type PostsListMode = 'posts' | 'pages';
interface PostsListProps {
mode: PostsListMode;
isActive: boolean;
}
const PostsList: React.FC<PostsListProps> = ({ mode }) => {
const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore();
const isPagesMode = mode === 'pages';
const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]);
const [pageBasePosts, setPageBasePosts] = useState<PostData[] | null>(null);
// Filter state
const [searchQuery, setSearchQuery] = useState('');
@@ -534,6 +536,35 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
loadFilters();
}, [posts]);
// In pages mode, load the full pages subset from backend filtering,
// independent of currently paged post list in the store.
useEffect(() => {
if (!isPagesMode || !isActive) {
return;
}
let isCancelled = false;
const loadPagesBase = async () => {
try {
const results = await window.electronAPI?.posts.filter({ categories: [PAGE_CATEGORY] });
if (!isCancelled && results) {
setPageBasePosts(results as PostData[]);
}
} catch (error) {
if (!isCancelled) {
console.error('Failed to load pages subset:', error);
}
}
};
loadPagesBase();
return () => {
isCancelled = true;
};
}, [isPagesMode, isActive, posts]);
// Handle search
const handleSearch = async (query: string) => {
setSearchQuery(query);
@@ -725,15 +756,17 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
// Determine which posts to display
// Filters only apply to published/archived posts — drafts are always shown unfiltered
const filteredDisplayPosts = searchResults ?? filteredPosts ?? null;
const filteredDisplayPosts = searchResults ?? filteredPosts ?? (isPagesMode ? pageBasePosts : null);
const isFiltered = filteredDisplayPosts !== null;
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
const baseDisplayPosts = isPagesMode ? (pageBasePosts ?? postSubset) : postSubset;
// 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(postSubset, filteredDisplayPosts),
[postSubset, filteredDisplayPosts]
() => groupPostsByStatus(baseDisplayPosts, filteredDisplayPosts),
[baseDisplayPosts, filteredDisplayPosts]
);
const clearAllFilters = () => {
@@ -896,7 +929,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode }) => {
</div>
)}
{postSubset.length === 0 && !isFiltered && (
{baseDisplayPosts.length === 0 && !isFiltered && (
<div className="sidebar-empty">
<p>{isPagesMode ? 'No pages yet' : 'No posts yet'}</p>
<button onClick={handleCreatePost}>Create your first post</button>
@@ -1582,11 +1615,25 @@ export const Sidebar: React.FC = () => {
return (
<div className="sidebar">
<div style={{ display: activeView === 'posts' ? 'block' : 'none' }}>
<PostsList mode="posts" />
<div
style={{
display: activeView === 'posts' ? 'flex' : 'none',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<PostsList mode="posts" isActive={activeView === 'posts'} />
</div>
<div style={{ display: activeView === 'pages' ? 'block' : 'none' }}>
<PostsList mode="pages" />
<div
style={{
display: activeView === 'pages' ? 'flex' : 'none',
flexDirection: 'column',
flex: 1,
minHeight: 0,
}}
>
<PostsList mode="pages" isActive={activeView === 'pages'} />
</div>
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsNav />}

View File

@@ -2175,6 +2175,19 @@ Published content`);
const result = await postEngine.searchPosts('nonexistent');
expect(result).toEqual([]);
});
it('should cap search results at 500', async () => {
mockLocalClient.execute.mockResolvedValueOnce({ rows: [] });
await postEngine.searchPosts('term');
expect(mockLocalClient.execute).toHaveBeenCalled();
const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined;
expect(call?.sql).toBeDefined();
const sql = call?.sql?.toLowerCase() ?? '';
expect(sql).toContain('limit 500');
expect(sql).not.toMatch(/\blimit\s+50\b/);
});
});
describe('getTagsWithCounts', () => {

View File

@@ -77,6 +77,9 @@ describe('Pages shortcut UI', () => {
it('shows only page-category posts when pages view is active', async () => {
useAppStore.setState({ activeView: 'pages', sidebarVisible: true });
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
createMockPost({ id: 'post-page', title: 'About Page', categories: ['page'] }),
]);
render(<Sidebar />);
@@ -87,4 +90,70 @@ describe('Pages shortcut UI', () => {
expect(within(pagesPanel as HTMLElement).getByText('About Page')).toBeInTheDocument();
expect(within(pagesPanel as HTMLElement).queryByText('Regular Article')).not.toBeInTheDocument();
});
it('loads pages subset from full table and does not require load-more pagination', async () => {
useAppStore.setState({
activeView: 'pages',
sidebarVisible: true,
posts: [
createMockPost({
id: 'post-article-only',
title: 'Loaded Article',
categories: ['article'],
}),
],
hasMorePosts: true,
totalPosts: 1200,
});
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }),
]);
render(<Sidebar />);
const pagesHeader = await screen.findByText('PAGES');
const pagesPanel = pagesHeader.closest('.sidebar-content') as HTMLElement;
expect(within(pagesPanel).getByText('Remote Page')).toBeInTheDocument();
expect(within(pagesPanel).queryByText('Loaded Article')).not.toBeInTheDocument();
expect(within(pagesPanel).queryByText(/Load more/i)).not.toBeInTheDocument();
expect(window.electronAPI.posts.filter).toHaveBeenCalledWith({ categories: ['page'] });
});
it('does not prefetch pages subset while posts view is active', async () => {
useAppStore.setState({
activeView: 'posts',
sidebarVisible: true,
posts: [
createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] }),
],
hasMorePosts: true,
totalPosts: 1200,
});
window.electronAPI.posts.filter = vi.fn().mockResolvedValue([
createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }),
]);
render(<Sidebar />);
expect(await screen.findByText('POSTS')).toBeInTheDocument();
expect(window.electronAPI.posts.filter).not.toHaveBeenCalledWith({ categories: ['page'] });
});
it('uses a flex-height wrapper for active posts/pages sidebar view', async () => {
useAppStore.setState({
activeView: 'posts',
sidebarVisible: true,
posts: [createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] })],
});
const { container } = render(<Sidebar />);
expect(await screen.findByText('POSTS')).toBeInTheDocument();
const wrappers = container.querySelectorAll('.sidebar > div');
expect(wrappers.length).toBeGreaterThanOrEqual(2);
expect((wrappers[0] as HTMLElement).style.display).toBe('flex');
});
});