import React, { useState, useEffect, useCallback } from 'react'; import { useAppStore, PostData } from '../../store'; import { showToast } from '../Toast'; import type { ChatConversation } from '../../types/electron'; import './Sidebar.css'; const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); }; const formatFileSize = (bytes: number) => { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; }; // Get post type icon based on categories const getPostTypeIcon = (categories: string[]): { icon: string; type: string } => { const lowerCategories = categories.map(c => c.toLowerCase()); if (lowerCategories.includes('picture') || lowerCategories.includes('photo') || lowerCategories.includes('image')) { return { icon: '🖼️', type: 'picture' }; } if (lowerCategories.includes('aside') || lowerCategories.includes('note') || lowerCategories.includes('quick')) { return { icon: '📝', type: 'aside' }; } if (lowerCategories.includes('link') || lowerCategories.includes('bookmark')) { return { icon: '🔗', type: 'link' }; } if (lowerCategories.includes('video')) { return { icon: '🎬', type: 'video' }; } if (lowerCategories.includes('quote')) { return { icon: '💬', type: 'quote' }; } // Default to article return { icon: '📄', type: 'article' }; }; 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 = ({ onDateSelect, selectedYear, selectedMonth }) => { const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); const [expandedYear, setExpandedYear] = useState(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 (
ARCHIVE {(selectedYear || selectedMonth !== undefined) && ( )}
{years.map(year => (
{ setExpandedYear(expandedYear === year ? null : year); onDateSelect(year); }} > {expandedYear === year ? '▼' : '▶'} {year} {getYearCount(year)}
{expandedYear === year && (
{getMonthsForYear(year).map(({ month, count }) => (
{ e.stopPropagation(); onDateSelect(year, month); }} > {MONTH_NAMES[month]} {count}
))}
)}
))} {years.length === 0 && (
No posts yet
)}
); }; interface FilterPanelProps { tags: string[]; categories: string[]; selectedTags: string[]; selectedCategories: string[]; onTagSelect: (tags: string[]) => void; onCategorySelect: (categories: string[]) => void; } const FilterPanel: React.FC = ({ tags, categories, selectedTags, selectedCategories, onTagSelect, onCategorySelect, }) => { return (
{tags.length > 0 && (
TAGS
{tags.map(tag => ( ))}
)} {categories.length > 0 && (
CATEGORIES
{categories.map(cat => ( ))}
)}
); }; // Media-specific calendar view interface MediaCalendarViewProps { onDateSelect: (year: number, month?: number) => void; selectedYear?: number; selectedMonth?: number; } const MediaCalendarView: React.FC = ({ onDateSelect, selectedYear, selectedMonth }) => { const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); const [expandedYear, setExpandedYear] = useState(null); useEffect(() => { const loadData = async () => { const data = await window.electronAPI?.media.getByYearMonth(); if (data) { setYearMonthData(data as { year: number; month: number; count: number }[]); } }; loadData(); }, []); 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 (
ARCHIVE {(selectedYear || selectedMonth !== undefined) && ( )}
{years.map(year => (
{ setExpandedYear(expandedYear === year ? null : year); onDateSelect(year); }} > {expandedYear === year ? '▼' : '▶'} {year} {getYearCount(year)}
{expandedYear === year && (
{getMonthsForYear(year).map(({ month, count }) => (
{ e.stopPropagation(); onDateSelect(year, month); }} > {MONTH_NAMES[month]} {count}
))}
)}
))} {years.length === 0 && (
No media yet
)}
); }; // Media-specific filter panel interface MediaFilterPanelProps { tags: string[]; selectedTags: string[]; onTagSelect: (tags: string[]) => void; } const MediaFilterPanel: React.FC = ({ tags, selectedTags, onTagSelect, }) => { return (
{tags.length > 0 && (
TAGS
{tags.map(tag => ( ))}
)}
); }; interface SearchBoxProps { onSearch: (query: string) => void; } const SearchBox: React.FC = ({ onSearch }) => { const [query, setQuery] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSearch(query); }; return (
setQuery(e.target.value)} /> {query && ( )}
); }; const PostsList: React.FC = () => { const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore(); // Filter state const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [selectedYear, setSelectedYear] = useState(); const [selectedMonth, setSelectedMonth] = useState(); const [selectedTags, setSelectedTags] = useState([]); const [selectedCategories, setSelectedCategories] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [availableCategories, setAvailableCategories] = useState([]); const [showFilters, setShowFilters] = useState(false); const [filteredPosts, setFilteredPosts] = useState(null); const [isLoadingMore, setIsLoadingMore] = useState(false); // 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) { // Fetch full PostData for each search result const fullPosts: PostData[] = []; for (const result of results as { id: string }[]) { const post = await window.electronAPI?.posts.get(result.id); if (post) { fullPosts.push(post as PostData); } } setSearchResults(fullPosts); } } 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 () => { // Create a real post immediately in the database with default empty content try { const { setSelectedPost: selectPost } = useAppStore.getState(); const newPost = await window.electronAPI?.posts.create({ title: '', content: '', tags: [], categories: [], }); if (newPost) { selectPost(newPost.id); } } catch (error) { console.error('Failed to create post:', error); } }; const handleLoadMore = async () => { if (isLoadingMore || !hasMorePosts) return; setIsLoadingMore(true); try { const postsResult = await window.electronAPI?.posts.getAll({ limit: 500, offset: posts.length }); if (postsResult) { const { items, hasMore } = postsResult as { items: PostData[]; hasMore: boolean }; appendPosts(items, hasMore); } } catch (error) { console.error('Failed to load more posts:', error); showToast.error('Failed to load more posts'); } finally { setIsLoadingMore(false); } }; // Determine which posts to display // Filters only apply to published/archived posts — drafts are always shown unfiltered const filteredDisplayPosts = searchResults ?? filteredPosts ?? null; const isFiltered = filteredDisplayPosts !== null; const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0; const groupedPosts = { draft: posts.filter(p => p.status === 'draft'), published: (filteredDisplayPosts ?? posts).filter(p => p.status === 'published'), archived: (filteredDisplayPosts ?? posts).filter(p => p.status === 'archived'), }; const clearAllFilters = () => { setSearchQuery(''); setSearchResults(null); setSelectedYear(undefined); setSelectedMonth(undefined); setSelectedTags([]); setSelectedCategories([]); setFilteredPosts(null); }; // Click handlers for tabs const handlePostClick = (postId: string) => { openTab({ type: 'post', id: postId, isTransient: true }); }; const handlePostDoubleClick = (postId: string) => { openTab({ type: 'post', id: postId, isTransient: false }); }; return (
POSTS
{showFilters && ( <> )} {hasActiveFilters && (
{groupedPosts.published.length + groupedPosts.archived.length} result{groupedPosts.published.length + groupedPosts.archived.length !== 1 ? 's' : ''} {searchQuery && ` for "${searchQuery}"`}
)} {groupedPosts.draft.length > 0 && (
Drafts ({groupedPosts.draft.length})
{groupedPosts.draft.map(post => { const postType = getPostTypeIcon(post.categories); return (
handlePostClick(post.id)} onDoubleClick={() => handlePostDoubleClick(post.id)} > {postType.icon}
{post.title || 'Untitled'}
{formatDate(post.updatedAt)}
); })}
)} {groupedPosts.published.length > 0 && (
Published ({groupedPosts.published.length})
{groupedPosts.published.map(post => { const postType = getPostTypeIcon(post.categories); return (
handlePostClick(post.id)} onDoubleClick={() => handlePostDoubleClick(post.id)} > {postType.icon}
{post.title || 'Untitled'}
{formatDate(post.publishedAt || post.updatedAt)}
); })}
)} {groupedPosts.archived.length > 0 && (
Archived ({groupedPosts.archived.length})
{groupedPosts.archived.map(post => { const postType = getPostTypeIcon(post.categories); return (
handlePostClick(post.id)} onDoubleClick={() => handlePostDoubleClick(post.id)} > {postType.icon}
{post.title || 'Untitled'}
{formatDate(post.updatedAt)}
); })}
)} {posts.length === 0 && !isFiltered && (

No posts yet

)} {groupedPosts.published.length === 0 && groupedPosts.archived.length === 0 && isFiltered && (

No matching posts

)} {/* Load More button - only show when not filtering and has more posts */} {!isFiltered && hasMorePosts && (
)}
); }; const MediaList: React.FC = () => { const { media, openTab, activeTabId } = useAppStore(); // Filter state const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const [selectedYear, setSelectedYear] = useState(); const [selectedMonth, setSelectedMonth] = useState(); const [selectedTags, setSelectedTags] = useState([]); const [availableTags, setAvailableTags] = useState([]); const [showFilters, setShowFilters] = useState(false); const [filteredMedia, setFilteredMedia] = useState(null); // Load available tags useEffect(() => { const loadTags = async () => { const tags = await window.electronAPI?.media.getTags(); if (tags) setAvailableTags(tags as string[]); }; loadTags(); }, [media]); // Handle search const handleSearch = async (query: string) => { setSearchQuery(query); if (!query.trim()) { setSearchResults(null); return; } try { const results = await window.electronAPI?.media.search(query); if (results) { const mediaIds = (results as { id: string }[]).map(r => r.id); setSearchResults(media.filter(m => mediaIds.includes(m.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); setFilteredMedia(null); return; } setSelectedYear(year); setSelectedMonth(month); try { const results = await window.electronAPI?.media.filter({ year, month, tags: selectedTags.length > 0 ? selectedTags : undefined, }); if (results) { setFilteredMedia(results as MediaData[]); } } catch (error) { console.error('Filter failed:', error); } }; // Handle tag filter changes useEffect(() => { const applyFilters = async () => { if (!selectedYear && selectedTags.length === 0) { setFilteredMedia(null); return; } try { const results = await window.electronAPI?.media.filter({ year: selectedYear, month: selectedMonth, tags: selectedTags.length > 0 ? selectedTags : undefined, }); if (results) { setFilteredMedia(results as MediaData[]); } } catch (error) { console.error('Filter failed:', error); } }; applyFilters(); }, [selectedTags]); const handleImportMedia = async () => { try { await window.electronAPI?.media.importDialog(); } catch (error) { console.error('Failed to import media:', error); } }; const handleMediaClick = (mediaId: string) => { openTab({ type: 'media', id: mediaId, isTransient: true }); }; const handleMediaDoubleClick = (mediaId: string) => { openTab({ type: 'media', id: mediaId, isTransient: false }); }; // Determine which media to display const filteredDisplayMedia = searchResults ?? filteredMedia ?? media; const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0; const clearAllFilters = () => { setSearchQuery(''); setSearchResults(null); setSelectedYear(undefined); setSelectedMonth(undefined); setSelectedTags([]); setFilteredMedia(null); }; return (
MEDIA
{showFilters && ( <> )} {hasActiveFilters && (
{filteredDisplayMedia.length} result{filteredDisplayMedia.length !== 1 ? 's' : ''} {searchQuery && ` for "${searchQuery}"`}
)}
{filteredDisplayMedia.map(item => (
handleMediaClick(item.id)} onDoubleClick={() => handleMediaDoubleClick(item.id)} title={item.originalName} > {item.mimeType.startsWith('image/') ? (
{item.alt { const target = e.target as HTMLImageElement; target.style.display = 'none'; }} />
) : (
)}
{item.originalName}
{formatFileSize(item.size)}
))}
{filteredDisplayMedia.length === 0 && (

No media files

)}
); }; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; import { scrollToTagsSection, TagsCategory } from '../TagsView'; const TagsNav: React.FC = () => { const [activeSection, setActiveSection] = useState(null); const handleNavClick = (category: TagsCategory) => { setActiveSection(category); scrollToTagsSection(category); }; return (
TAGS
); }; const SettingsNav: React.FC = () => { const { syncConfigured, tabs, activeTabId, openTab } = useAppStore(); const [activeSection, setActiveSection] = useState(null); // Check if settings panel is currently active const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); const handleNavClick = (category: SettingsCategory) => { // If settings panel is not open or not active, open it first if (!isSettingsTabActive) { openTab({ type: 'settings', id: 'settings', isTransient: false }); } setActiveSection(category); // Use setTimeout to allow panel to open before scrolling setTimeout(() => { scrollToSettingsSection(category); }, isSettingsTabActive ? 0 : 100); }; return (
SETTINGS
); }; // Chat conversations list const ChatList: React.FC = () => { const { openTab, closeTab } = useAppStore(); const [conversations, setConversations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isReady, setIsReady] = useState(false); // Load conversations const loadConversations = useCallback(async () => { try { const convs = await window.electronAPI?.chat.getConversations(); if (convs) { setConversations(convs); } } catch (error) { console.error('Failed to load conversations:', error); } }, []); // Check if service is ready const checkReady = useCallback(async () => { try { const status = await window.electronAPI?.chat.checkReady(); setIsReady(status?.ready ?? false); } catch { setIsReady(false); } }, []); useEffect(() => { const init = async () => { setIsLoading(true); await checkReady(); await loadConversations(); setIsLoading(false); }; init(); // Subscribe to title updates const unsubTitle = window.electronAPI?.chat.onTitleUpdated((data) => { setConversations(prev => prev.map(c => c.id === data.conversationId ? { ...c, title: data.title } : c) ); }); return () => { unsubTitle?.(); }; }, [loadConversations, checkReady]); const handleNewChat = async () => { try { const conversation = await window.electronAPI?.chat.createConversation(); if (conversation) { setConversations(prev => [conversation, ...prev]); openTab({ type: 'chat', id: conversation.id, isTransient: false }); } } catch (error) { console.error('Failed to create conversation:', error); showToast.error('Failed to create new chat'); } }; const handleOpenChat = (conversationId: string) => { openTab({ type: 'chat', id: conversationId, isTransient: false }); }; const handleDeleteChat = async (conversationId: string) => { try { await window.electronAPI?.chat.deleteConversation(conversationId); setConversations(prev => prev.filter(c => c.id !== conversationId)); // Close the tab for the deleted chat closeTab(conversationId); } catch (error) { console.error('Failed to delete conversation:', error); showToast.error('Failed to delete chat'); } }; const formatChatDate = (dateString: string) => { const date = new Date(dateString); const now = new Date(); const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); if (diffDays === 0) { return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } else if (diffDays === 1) { return 'Yesterday'; } else if (diffDays < 7) { return date.toLocaleDateString('en-US', { weekday: 'short' }); } return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); }; if (isLoading) { return (
AI ASSISTANT
Loading...
); } return (
AI ASSISTANT
{!isReady && (

API key needed. Open a chat to configure.

)}
{conversations.length === 0 ? (

No conversations yet

) : ( conversations.map(conv => (
handleOpenChat(conv.id)} >
{conv.title}
{formatChatDate(conv.updatedAt)}
)) )}
); }; export const Sidebar: React.FC = () => { const { activeView, sidebarVisible } = useAppStore(); if (!sidebarVisible) { return null; } return (
{activeView === 'posts' && } {activeView === 'media' && } {activeView === 'settings' && } {activeView === 'tags' && } {activeView === 'chat' && }
); };