import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useAppStore, PostData, MediaData } from '../../store'; import { showToast } from '../Toast'; import { getContrastColor, groupPostsByStatus } from '../../utils'; import type { ChatConversation, ImportDefinitionData } from '../../types/electron'; import { GitSidebar } from '../GitSidebar/GitSidebar'; import { scrollToSettingsSection, SettingsCategory } from '../SettingsView/SettingsView'; import { scrollToTagsSection, TagsCategory } from '../TagsView'; import { activateSidebarSection } from '../../navigation/sectionActivation'; import { getPersistedSidebarSection, setPersistedSidebarSection } from '../../navigation/sidebarUiPersistence'; import { openChatTab, openEntityTab, openImportTab, openScriptTab, openSingletonToolTab } from '../../navigation/tabPolicy'; import { createAndFocusPost } from '../../navigation/postCreation'; import type { SidebarView } from '../../navigation/sidebarViewRegistry'; import { useI18n } from '../../i18n'; import { useProjectScopedSidebarData } from './useProjectScopedSidebarData'; import { SidebarEntityList } from './SidebarEntityList'; import { formatSidebarRelativeDate } from './sidebarDateFormatting'; import './Sidebar.css'; /** Get display name for media: title (truncated to 60 chars) or fallback to filename */ function getMediaDisplayName(media: MediaData): string { if (media.title) { return media.title.length > 60 ? media.title.substring(0, 60) + '...' : media.title; } return media.originalName; } // Tag data with color information interface TagData { id: string; name: string; color?: string; } const UI_DATE_LOCALE: Record = { en: 'en-US', de: 'de-DE', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', }; const formatDate = (dateString: string, locale: string = 'en-US') => { const date = new Date(dateString); return date.toLocaleDateString(locale, { 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']; 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; selectedMonth?: number; } const CalendarView: React.FC = ({ onDateSelect, selectedYear, selectedMonth }) => { const { t } = useI18n(); const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); const [expandedYear, setExpandedYear] = useState(null); const [isCollapsed, setIsCollapsed] = useState(true); 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 (
setIsCollapsed(!isCollapsed)} > {isCollapsed ? '▶' : '▼'} {t('sidebar.archive')} {(selectedYear || selectedMonth !== undefined) && ( )}
{!isCollapsed &&
{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 && (
{t('sidebar.noPostsYet')}
)}
}
); }; interface FilterPanelProps { tags: string[]; tagColors: Map; categories: string[]; selectedTags: string[]; selectedCategories: string[]; onTagSelect: (tags: string[]) => void; onCategorySelect: (categories: string[]) => void; } const FilterPanel: React.FC = ({ tags, tagColors, categories, selectedTags, selectedCategories, onTagSelect, onCategorySelect, }) => { const { t } = useI18n(); const [tagsCollapsed, setTagsCollapsed] = useState(true); const [categoriesCollapsed, setCategoriesCollapsed] = useState(true); return (
{tags.length > 0 && (
setTagsCollapsed(!tagsCollapsed)} > {tagsCollapsed ? '▶' : '▼'} {t('sidebar.tags')} {selectedTags.length > 0 && ( )}
{!tagsCollapsed &&
{tags.map(tag => { const color = tagColors.get(tag); const hasColor = !!color; const style: React.CSSProperties = hasColor ? { backgroundColor: color, color: getContrastColor(color!), borderColor: color, } : {}; return ( ); })}
}
)} {categories.length > 0 && (
setCategoriesCollapsed(!categoriesCollapsed)} > {categoriesCollapsed ? '▶' : '▼'} {t('sidebar.categories')} {selectedCategories.length > 0 && ( )}
{!categoriesCollapsed &&
{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 { t } = useI18n(); const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); const [expandedYear, setExpandedYear] = useState(null); const [isCollapsed, setIsCollapsed] = useState(true); 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 (
setIsCollapsed(!isCollapsed)} > {isCollapsed ? '▶' : '▼'} {t('sidebar.archive')} {(selectedYear || selectedMonth !== undefined) && ( )}
{!isCollapsed &&
{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 && (
{t('sidebar.noMediaYet')}
)}
}
); }; // Media-specific filter panel interface MediaFilterPanelProps { tags: string[]; tagColors: Map; selectedTags: string[]; onTagSelect: (tags: string[]) => void; } const MediaFilterPanel: React.FC = ({ tags, tagColors, selectedTags, onTagSelect, }) => { const { t } = useI18n(); const [tagsCollapsed, setTagsCollapsed] = useState(true); return (
{tags.length > 0 && (
setTagsCollapsed(!tagsCollapsed)} > {tagsCollapsed ? '▶' : '▼'} {t('sidebar.tags')} {selectedTags.length > 0 && ( )}
{!tagsCollapsed &&
{tags.map(tag => { const color = tagColors.get(tag); const hasColor = !!color; const style: React.CSSProperties = hasColor ? { backgroundColor: color, color: getContrastColor(color!), borderColor: color, } : {}; return ( ); })}
}
)}
); }; interface SearchBoxProps { onSearch: (query: string) => void; placeholder: string; } const SearchBox: React.FC = ({ onSearch, placeholder }) => { const { t } = useI18n(); const [query, setQuery] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSearch(query); }; return (
setQuery(e.target.value)} /> {query && ( )}
); }; type PostsListMode = 'posts' | 'pages'; interface PostsListProps { mode: PostsListMode; isActive: boolean; } const PostsList: React.FC = ({ mode, isActive }) => { const { t, language } = useI18n(); const uiLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en; const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore(); const isPagesMode = mode === 'pages'; const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]); const [pageBasePosts, setPageBasePosts] = useState(null); // 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 [tagColors, setTagColors] = useState>(new Map()); const [availableCategories, setAvailableCategories] = useState([]); const [showFilters, setShowFilters] = useState(false); const [filteredPosts, setFilteredPosts] = useState(null); const [isLoadingMore, setIsLoadingMore] = useState(false); // Load available tags with colors and categories useEffect(() => { const loadFilters = async () => { const [tags, categories, allTagsData] = await Promise.all([ window.electronAPI?.posts.getTags(), window.electronAPI?.posts.getCategories(), window.electronAPI?.tags?.getAll?.(), ]); if (tags) setAvailableTags(tags 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(); for (const tag of allTagsData as TagData[]) { if (tag.color) { colorMap.set(tag.name, tag.color); } } setTagColors(colorMap); } }; 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); 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(applyPageFilter(fullPosts, isPagesMode)); } } catch (error) { console.error('Search failed:', error); showToast.error(t('sidebar.search')); } }; // 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); const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode); try { const results = await window.electronAPI?.posts.filter({ year, month, tags: selectedTags.length > 0 ? selectedTags : undefined, categories: mergedCategories.length > 0 ? mergedCategories : undefined, }); if (results) { setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode)); } } 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; } const mergedCategories = mergeWithPageCategory(selectedCategories, isPagesMode); try { const results = await window.electronAPI?.posts.filter({ year: selectedYear, month: selectedMonth, tags: selectedTags.length > 0 ? selectedTags : undefined, categories: mergedCategories.length > 0 ? mergedCategories : undefined, }); if (results) { setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode)); } } catch (error) { console.error('Filter failed:', error); } }; applyFilters(); }, [selectedTags, selectedCategories, selectedYear, selectedMonth, isPagesMode]); // Track previous post statuses to detect changes const prevPostStatusMapRef = useRef>(new Map()); // Re-run search/filter when a post's status changes (e.g., draft becomes published or vice versa) // This ensures the sidebar lists are always up-to-date without stale cached data useEffect(() => { const currentStatusMap = new Map(posts.map(p => [p.id, p.status])); const prevStatusMap = prevPostStatusMapRef.current; // Check if any post's status changed let statusChanged = false; for (const [id, status] of currentStatusMap) { const prevStatus = prevStatusMap.get(id); if (prevStatus !== undefined && prevStatus !== status) { statusChanged = true; break; } } // Update the ref for next comparison prevPostStatusMapRef.current = currentStatusMap; // If a status changed and we have active filters, re-run them to get fresh data if (statusChanged) { if (searchQuery) { // Re-run search inline to avoid dependency on handleSearch const refreshSearch = async () => { try { const results = await window.electronAPI?.posts.search(searchQuery); if (results) { 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(applyPageFilter(fullPosts, isPagesMode)); } } catch (error) { console.error('Search refresh failed:', error); } }; refreshSearch(); } 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: mergedCategories.length > 0 ? mergedCategories : undefined, }); if (results) { setFilteredPosts(applyPageFilter(results as PostData[], isPagesMode)); } } catch (error) { console.error('Filter refresh failed:', error); } }; refetchFilters(); } else { // No active filters, just clear any stale cached results setSearchResults(null); setFilteredPosts(null); } } }, [posts, searchQuery, selectedYear, selectedMonth, selectedTags, selectedCategories, isPagesMode]); const handleCreatePost = async () => { const { setSelectedPost: selectPost } = useAppStore.getState(); await createAndFocusPost({ createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined, setSelectedPost: selectPost, onError: (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 ?? (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(baseDisplayPosts, filteredDisplayPosts), [baseDisplayPosts, filteredDisplayPosts] ); const clearAllFilters = () => { setSearchQuery(''); setSearchResults(null); setSelectedYear(undefined); setSelectedMonth(undefined); setSelectedTags([]); setSelectedCategories([]); setFilteredPosts(null); }; // Click handlers for tabs const handlePostClick = (postId: string) => { openEntityTab(openTab, 'post', postId, 'preview'); }; const handlePostDoubleClick = (postId: string) => { openEntityTab(openTab, 'post', postId, 'pin'); }; return (
{(isPagesMode ? t('activity.pages') : t('activity.posts')).toUpperCase()}
{showFilters && ( <> )} {hasActiveFilters && (
{searchQuery ? t('sidebar.resultsFor', { count: groupedPosts.published.length + groupedPosts.archived.length, query: searchQuery, }) : t('sidebar.results', { count: groupedPosts.published.length + groupedPosts.archived.length })}
)} {groupedPosts.draft.length > 0 && (
{t('sidebar.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 || t('sidebar.untitled')}
{formatDate(post.updatedAt, uiLocale)}
); })}
)} {groupedPosts.published.length > 0 && (
{t('sidebar.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 || t('sidebar.untitled')}
{formatDate(post.publishedAt || post.updatedAt, uiLocale)}
); })}
)} {groupedPosts.archived.length > 0 && (
{t('sidebar.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 || t('sidebar.untitled')}
{formatDate(post.updatedAt, uiLocale)}
); })}
)} {baseDisplayPosts.length === 0 && !isFiltered && (

{isPagesMode ? t('sidebar.noPagesYet') : t('sidebar.noPostsYet')}

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

{t('sidebar.noMatchingPosts')}

)} {/* Load More button - only show when not filtering and has more posts */} {!isFiltered && hasMorePosts && (
)}
); }; const MediaList: React.FC = () => { const { t } = useI18n(); 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 [tagColors, setTagColors] = useState>(new Map()); const [showFilters, setShowFilters] = useState(false); const [filteredMedia, setFilteredMedia] = useState(null); // Load available tags with colors useEffect(() => { const loadTags = async () => { const [tags, allTagsData] = await Promise.all([ window.electronAPI?.media.getTags(), window.electronAPI?.tags?.getAll?.(), ]); if (tags) setAvailableTags(tags as string[]); if (allTagsData) { const colorMap = new Map(); for (const tag of allTagsData as TagData[]) { if (tag.color) { colorMap.set(tag.name, tag.color); } } setTagColors(colorMap); } }; 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(t('sidebar.search')); } }; // 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) => { openEntityTab(openTab, 'media', mediaId, 'preview'); }; const handleMediaDoubleClick = (mediaId: string) => { openEntityTab(openTab, 'media', mediaId, 'pin'); }; // 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 (
{t('activity.media').toUpperCase()}
{showFilters && ( <> )} {hasActiveFilters && (
{searchQuery ? t('sidebar.resultsFor', { count: filteredDisplayMedia.length, query: searchQuery }) : t('sidebar.results', { count: filteredDisplayMedia.length })}
)}
{filteredDisplayMedia.map(item => (
handleMediaClick(item.id)} onDoubleClick={() => handleMediaDoubleClick(item.id)} title={item.caption || item.originalName} > {item.mimeType.startsWith('image/') ? (
{item.alt { const target = e.target as HTMLImageElement; target.style.display = 'none'; }} />
) : (
)}
{getMediaDisplayName(item)}
{formatFileSize(item.size)}
))}
{filteredDisplayMedia.length === 0 && (

{t('sidebar.noMediaFiles')}

)}
); }; const TagsNav: React.FC = () => { const { t } = useI18n(); const { tabs, activeTabId, openTab } = useAppStore(); const [activeSection, setActiveSection] = useState(() => { const persisted = getPersistedSidebarSection('tags'); if (persisted === 'cloud' || persisted === 'manage' || persisted === 'merge') { return persisted; } return null; }); const isTagsTabActive = tabs.some(t => t.type === 'tags' && t.id === activeTabId); const handleNavClick = (category: TagsCategory) => { setActiveSection(category); setPersistedSidebarSection('tags', category); activateSidebarSection({ isEditorTabActive: isTagsTabActive, ensureEditorTabActive: () => openSingletonToolTab(openTab, 'tags'), activateSection: () => scrollToTagsSection(category), }); }; return (
{t('sidebar.tagsHeader').toUpperCase()}
); }; const SettingsNav: React.FC = () => { const { t } = useI18n(); const { tabs, activeTabId, openTab } = useAppStore(); const [activeSection, setActiveSection] = useState(() => { const persisted = getPersistedSidebarSection('settings'); if (persisted === 'project' || persisted === 'editor' || persisted === 'content' || persisted === 'ai' || persisted === 'publishing' || persisted === 'data') { return persisted; } return null; }); // Check if settings panel is currently active const isSettingsTabActive = tabs.some(t => t.type === 'settings' && t.id === activeTabId); const isStyleTabActive = tabs.some(t => t.type === 'style' && t.id === activeTabId); const handleNavClick = (category: SettingsCategory) => { setActiveSection(category); setPersistedSidebarSection('settings', category); activateSidebarSection({ isEditorTabActive: isSettingsTabActive, ensureEditorTabActive: () => openSingletonToolTab(openTab, 'settings'), activateSection: () => scrollToSettingsSection(category), }); }; const handleStyleClick = () => { openSingletonToolTab(openTab, 'style'); }; return (
{t('sidebar.settingsHeader').toUpperCase()}
); }; // Chat conversations list const ChatList: React.FC = () => { const { t, language } = useI18n(); const { openTab, closeTab, activeProject } = useAppStore(); const activeProjectId = activeProject?.id; const [isReady, setIsReady] = useState(false); const loadConversations = useCallback(async (): Promise => { try { const convs = await window.electronAPI?.chat.getConversations(); return convs ?? []; } catch (error) { console.error('Failed to load conversations:', error); return []; } }, []); const { items: conversations, setItems: setConversations, isLoading, } = useProjectScopedSidebarData({ load: loadConversations, activeProjectId, }); // 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(() => { void checkReady(); // 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?.(); }; }, [checkReady, setConversations]); const handleNewChat = async () => { try { const conversation = await window.electronAPI?.chat.createConversation(); if (conversation) { setConversations(prev => [conversation, ...prev]); openChatTab(openTab, conversation.id); } } catch (error) { console.error('Failed to create conversation:', error); showToast.error(t('sidebar.chat.createFailed')); } }; const handleOpenChat = (conversationId: string) => { openChatTab(openTab, conversationId); }; 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(t('sidebar.chat.deleteFailed')); } }; return ( conversation.id} topContent={ !isReady ? (

{t('sidebar.chat.apiKeyNeeded')}

) : null } renderItem={(conversation) => (
handleOpenChat(conversation.id)} >
{conversation.title}
{formatSidebarRelativeDate({ dateString: conversation.updatedAt, language, t })}
)} /> ); }; const ImportList: React.FC = () => { const { t, language } = useI18n(); const { openTab, closeTab, activeProject } = useAppStore(); const activeProjectId = activeProject?.id; const loadDefinitions = useCallback(async (): Promise => { try { const defs = await window.electronAPI?.importDefinitions.getAll(); return defs ?? []; } catch (error) { console.error('Failed to load import definitions:', error); return []; } }, []); const { items: definitions, setItems: setDefinitions, isLoading, } = useProjectScopedSidebarData({ load: loadDefinitions, activeProjectId, }); // Listen for import definition name updates useEffect(() => { const unsub = window.electronAPI?.importDefinitions.onNameUpdated((data) => { setDefinitions(prev => prev.map(def => def.id === data.definitionId ? { ...def, name: data.name } : def ) ); }); return () => { unsub?.(); }; }, []); const handleNewDefinition = async () => { try { const def = await window.electronAPI?.importDefinitions.create(); if (def) { setDefinitions(prev => [def, ...prev]); openImportTab(openTab, def.id); } } catch (error) { console.error('Failed to create import definition:', error); showToast.error(t('sidebar.import.createFailed')); } }; const handleOpenDefinition = (definitionId: string) => { openImportTab(openTab, definitionId); }; const handleDeleteDefinition = async (e: React.MouseEvent, definitionId: string) => { e.stopPropagation(); try { await window.electronAPI?.importDefinitions.delete(definitionId); setDefinitions(prev => prev.filter(d => d.id !== definitionId)); closeTab(definitionId); } catch (error) { console.error('Failed to delete import definition:', error); showToast.error(t('sidebar.import.deleteFailed')); } }; return ( definition.id} renderItem={(definition) => (
handleOpenDefinition(definition.id)} >
{definition.name}
{formatSidebarRelativeDate({ dateString: definition.updatedAt, language, t })}
)} /> ); }; const ScriptsList: React.FC = () => { const { t, language } = useI18n(); const { openTab, activeTabId, closeTab } = useAppStore(); const activeProjectId = useAppStore((state) => state.activeProject?.id); const loadScripts = useCallback(async (): Promise> => { const items = await window.electronAPI?.scripts.getAll(); return (items ?? []).map((item) => ({ id: item.id, title: item.title, updatedAt: item.updatedAt })); }, []); const { items: scripts, setItems: setScripts, isLoading, reload: reloadScripts, } = useProjectScopedSidebarData[number]>({ load: loadScripts, activeProjectId, refreshEventName: 'bds:scripts-changed', }); const handleCreateScript = async () => { try { const created = await window.electronAPI?.scripts.create({ title: t('sidebar.scripts.newScript'), kind: 'utility', content: 'print("new script")', entrypoint: 'render', enabled: true, }); if (!created) { return; } setScripts((prev) => [ { id: created.id, title: created.title, updatedAt: created.updatedAt }, ...prev.filter((script) => script.id !== created.id), ]); if (typeof window.dispatchEvent === 'function') { window.dispatchEvent(new CustomEvent('bds:scripts-changed')); } openScriptTab(openTab, created.id, 'pin'); void reloadScripts(); } catch (error) { console.error('Failed to create script:', error); showToast.error(t('sidebar.scripts.createFailed')); } }; const handleDeleteScript = async (event: React.MouseEvent, scriptId: string) => { event.stopPropagation(); try { const deleted = await window.electronAPI?.scripts.delete(scriptId); if (!deleted) { showToast.error(t('sidebar.scripts.deleteFailed')); return; } setScripts((prev) => prev.filter((script) => script.id !== scriptId)); closeTab(scriptId); if (typeof window.dispatchEvent === 'function') { window.dispatchEvent(new CustomEvent('bds:scripts-changed')); } } catch (error) { console.error('Failed to delete script:', error); showToast.error(t('sidebar.scripts.deleteFailed')); } }; return ( script.id} renderItem={(script) => (
openScriptTab(openTab, script.id, 'preview')} onDoubleClick={() => openScriptTab(openTab, script.id, 'pin')} onKeyDown={(event) => { if (event.key === 'Enter') { openScriptTab(openTab, script.id, 'pin'); return; } if (event.key === ' ') { event.preventDefault(); openScriptTab(openTab, script.id, 'preview'); } }} >
{script.title}
{formatSidebarRelativeDate({ dateString: script.updatedAt, language, t })}
)} /> ); }; export const Sidebar: React.FC = () => { const { activeView, sidebarVisible } = useAppStore(); if (!sidebarVisible) { return null; } const sidebarViewMap: Record = { posts: , pages: , media: , scripts: , settings: , tags: , chat: , import: , git: , }; return (
{sidebarViewMap[activeView]}
); };