feat: next phase of basic work

This commit is contained in:
2026-02-10 11:33:19 +01:00
parent 5979fa3374
commit 78b2847bad
27 changed files with 2325 additions and 508 deletions

View File

@@ -1,5 +1,7 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useAppStore, PostData } from '../../store';
import { showToast } from '../Toast';
import { ProjectSelector } from '../ProjectSelector';
import './Sidebar.css';
const formatDate = (dateString: string) => {
@@ -13,8 +15,290 @@ const formatFileSize = (bytes: number) => {
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
interface CalendarViewProps {
onDateSelect: (year: number, month?: number) => void;
selectedYear?: number;
selectedMonth?: number;
}
const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [expandedYear, setExpandedYear] = useState<number | null>(null);
useEffect(() => {
const loadData = async () => {
const data = await window.electronAPI?.posts.getByYearMonth();
if (data) {
setYearMonthData(data as { year: number; month: number; count: number }[]);
}
};
loadData();
}, []);
// Group by year
const years = [...new Set(yearMonthData.map(d => d.year))].sort((a, b) => b - a);
const getYearCount = (year: number) => {
return yearMonthData.filter(d => d.year === year).reduce((sum, d) => sum + d.count, 0);
};
const getMonthsForYear = (year: number) => {
return yearMonthData.filter(d => d.year === year).sort((a, b) => b.month - a.month);
};
return (
<div className="calendar-view">
<div className="calendar-header">
<span>ARCHIVE</span>
{(selectedYear || selectedMonth !== undefined) && (
<button className="clear-filter" onClick={() => onDateSelect(0)} title="Clear filter">
</button>
)}
</div>
<div className="calendar-years">
{years.map(year => (
<div key={year} className="calendar-year">
<div
className={`calendar-year-header ${selectedYear === year && selectedMonth === undefined ? 'selected' : ''}`}
onClick={() => {
setExpandedYear(expandedYear === year ? null : year);
onDateSelect(year);
}}
>
<span className="expand-icon">{expandedYear === year ? '▼' : '▶'}</span>
<span className="year-label">{year}</span>
<span className="year-count">{getYearCount(year)}</span>
</div>
{expandedYear === year && (
<div className="calendar-months">
{getMonthsForYear(year).map(({ month, count }) => (
<div
key={month}
className={`calendar-month ${selectedYear === year && selectedMonth === month ? 'selected' : ''}`}
onClick={(e) => {
e.stopPropagation();
onDateSelect(year, month);
}}
>
<span className="month-label">{MONTH_NAMES[month]}</span>
<span className="month-count">{count}</span>
</div>
))}
</div>
)}
</div>
))}
{years.length === 0 && (
<div className="calendar-empty">No posts yet</div>
)}
</div>
</div>
);
};
interface FilterPanelProps {
tags: string[];
categories: string[];
selectedTags: string[];
selectedCategories: string[];
onTagSelect: (tags: string[]) => void;
onCategorySelect: (categories: string[]) => void;
}
const FilterPanel: React.FC<FilterPanelProps> = ({
tags,
categories,
selectedTags,
selectedCategories,
onTagSelect,
onCategorySelect,
}) => {
return (
<div className="filter-panel">
{tags.length > 0 && (
<div className="filter-section">
<div className="filter-header">TAGS</div>
<div className="filter-chips">
{tags.map(tag => (
<button
key={tag}
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
onClick={() => {
if (selectedTags.includes(tag)) {
onTagSelect(selectedTags.filter(t => t !== tag));
} else {
onTagSelect([...selectedTags, tag]);
}
}}
>
{tag}
</button>
))}
</div>
</div>
)}
{categories.length > 0 && (
<div className="filter-section">
<div className="filter-header">CATEGORIES</div>
<div className="filter-chips">
{categories.map(cat => (
<button
key={cat}
className={`filter-chip ${selectedCategories.includes(cat) ? 'active' : ''}`}
onClick={() => {
if (selectedCategories.includes(cat)) {
onCategorySelect(selectedCategories.filter(c => c !== cat));
} else {
onCategorySelect([...selectedCategories, cat]);
}
}}
>
{cat}
</button>
))}
</div>
</div>
)}
</div>
);
};
interface SearchBoxProps {
onSearch: (query: string) => void;
}
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch }) => {
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form className="search-box" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Search posts..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" title="Search">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M15.7 14.3l-4.2-4.2c-.2-.2-.5-.3-.8-.3.9-1.1 1.5-2.5 1.5-4C12.2 2.6 9.6 0 6.4 0S.6 2.6.6 5.8s2.6 5.8 5.8 5.8c1.5 0 2.9-.5 4-1.4 0 .3.1.6.3.8l4.2 4.2c.2.2.5.3.7.3s.5-.1.7-.3c.4-.4.4-1 0-1.4zm-9.3-4c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5z"/>
</svg>
</button>
{query && (
<button type="button" className="clear-search" onClick={() => { setQuery(''); onSearch(''); }} title="Clear">
</button>
)}
</form>
);
};
const PostsList: React.FC = () => {
const { posts, selectedPostId, setSelectedPost } = useAppStore();
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<PostData[] | null>(null);
const [selectedYear, setSelectedYear] = useState<number | undefined>();
const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null);
// Load available tags and categories
useEffect(() => {
const loadFilters = async () => {
const [tags, categories] = await Promise.all([
window.electronAPI?.posts.getTags(),
window.electronAPI?.posts.getCategories(),
]);
if (tags) setAvailableTags(tags as string[]);
if (categories) setAvailableCategories(categories as string[]);
};
loadFilters();
}, [posts]);
// Handle search
const handleSearch = async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults(null);
return;
}
try {
const results = await window.electronAPI?.posts.search(query);
if (results) {
// Map search results to PostData (search returns SearchResult with score)
const postIds = (results as { id: string }[]).map(r => r.id);
setSearchResults(posts.filter(p => postIds.includes(p.id)));
}
} catch (error) {
console.error('Search failed:', error);
showToast.error('Search failed');
}
};
// Handle date selection
const handleDateSelect = async (year: number, month?: number) => {
if (year === 0) {
// Clear filter
setSelectedYear(undefined);
setSelectedMonth(undefined);
setFilteredPosts(null);
return;
}
setSelectedYear(year);
setSelectedMonth(month);
try {
const results = await window.electronAPI?.posts.filter({
year,
month,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
}
} catch (error) {
console.error('Filter failed:', error);
}
};
// Handle tag/category filter changes
useEffect(() => {
const applyFilters = async () => {
if (!selectedYear && selectedTags.length === 0 && selectedCategories.length === 0) {
setFilteredPosts(null);
return;
}
try {
const results = await window.electronAPI?.posts.filter({
year: selectedYear,
month: selectedMonth,
tags: selectedTags.length > 0 ? selectedTags : undefined,
categories: selectedCategories.length > 0 ? selectedCategories : undefined,
});
if (results) {
setFilteredPosts(results as PostData[]);
}
} catch (error) {
console.error('Filter failed:', error);
}
};
applyFilters();
}, [selectedTags, selectedCategories]);
const handleCreatePost = async () => {
try {
@@ -24,16 +308,33 @@ const PostsList: React.FC = () => {
});
if (newPost) {
setSelectedPost((newPost as PostData).id);
showToast.success('Post created');
}
} catch (error) {
console.error('Failed to create post:', error);
showToast.error('Failed to create post');
}
};
// Determine which posts to display
const displayPosts = searchResults ?? filteredPosts ?? posts;
const isFiltered = searchResults !== null || filteredPosts !== null;
const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0;
const groupedPosts = {
draft: posts.filter(p => p.status === 'draft'),
published: posts.filter(p => p.status === 'published'),
archived: posts.filter(p => p.status === 'archived'),
draft: displayPosts.filter(p => p.status === 'draft'),
published: displayPosts.filter(p => p.status === 'published'),
archived: displayPosts.filter(p => p.status === 'archived'),
};
const clearAllFilters = () => {
setSearchQuery('');
setSearchResults(null);
setSelectedYear(undefined);
setSelectedMonth(undefined);
setSelectedTags([]);
setSelectedCategories([]);
setFilteredPosts(null);
};
return (
@@ -41,14 +342,57 @@ const PostsList: React.FC = () => {
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>POSTS</span>
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
<div className="sidebar-actions">
<button
className={`sidebar-action ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
title="Toggle Filters"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M6 12v-1h4v1H6zM4 8v-1h8v1H4zm-2-4v-1h12v1H2z"/>
</svg>
</button>
<button className="sidebar-action" onClick={handleCreatePost} title="New Post">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
</div>
<SearchBox onSearch={handleSearch} />
{showFilters && (
<>
<CalendarView
onDateSelect={handleDateSelect}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<FilterPanel
tags={availableTags}
categories={availableCategories}
selectedTags={selectedTags}
selectedCategories={selectedCategories}
onTagSelect={setSelectedTags}
onCategorySelect={setSelectedCategories}
/>
</>
)}
{hasActiveFilters && (
<div className="filter-status">
<span>
{displayPosts.length} result{displayPosts.length !== 1 ? 's' : ''}
{searchQuery && ` for "${searchQuery}"`}
</span>
<button onClick={clearAllFilters} title="Clear all filters">
Clear filters
</button>
</div>
)}
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
@@ -112,12 +456,19 @@ const PostsList: React.FC = () => {
</div>
)}
{posts.length === 0 && (
{displayPosts.length === 0 && !isFiltered && (
<div className="sidebar-empty">
<p>No posts yet</p>
<button onClick={handleCreatePost}>Create your first post</button>
</div>
)}
{displayPosts.length === 0 && isFiltered && (
<div className="sidebar-empty">
<p>No matching posts</p>
<button onClick={clearAllFilters}>Clear filters</button>
</div>
)}
</div>
);
};
@@ -280,6 +631,7 @@ export const Sidebar: React.FC = () => {
return (
<div className="sidebar">
<ProjectSelector />
{activeView === 'posts' && <PostsList />}
{activeView === 'media' && <MediaList />}
{activeView === 'settings' && <SettingsPanel />}