Files
bDS/src/renderer/components/Sidebar/Sidebar.tsx
2026-02-21 18:10:26 +01:00

1699 lines
58 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, openSingletonToolTab } from '../../navigation/tabPolicy';
import type { SidebarView } from '../../navigation/sidebarViewRegistry';
import { useI18n } from '../../i18n';
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<string, string> = {
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<CalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
const { t } = useI18n();
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [expandedYear, setExpandedYear] = useState<number | null>(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 (
<div className="calendar-view">
<div
className={`calendar-header collapsible-header ${isCollapsed ? 'collapsed' : 'expanded'}`}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
<span>{t('sidebar.archive')}</span>
{(selectedYear || selectedMonth !== undefined) && (
<button
className="clear-filter"
onClick={(e) => { e.stopPropagation(); onDateSelect(0); }}
title={t('sidebar.clearFilter')}
>
</button>
)}
</div>
{!isCollapsed && <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">{t('sidebar.noPostsYet')}</div>
)}
</div>}
</div>
);
};
interface FilterPanelProps {
tags: string[];
tagColors: Map<string, string>;
categories: string[];
selectedTags: string[];
selectedCategories: string[];
onTagSelect: (tags: string[]) => void;
onCategorySelect: (categories: string[]) => void;
}
const FilterPanel: React.FC<FilterPanelProps> = ({
tags,
tagColors,
categories,
selectedTags,
selectedCategories,
onTagSelect,
onCategorySelect,
}) => {
const { t } = useI18n();
const [tagsCollapsed, setTagsCollapsed] = useState(true);
const [categoriesCollapsed, setCategoriesCollapsed] = useState(true);
return (
<div className="filter-panel">
{tags.length > 0 && (
<div className="filter-section">
<div
className={`filter-header collapsible-header ${tagsCollapsed ? 'collapsed' : 'expanded'}`}
onClick={() => setTagsCollapsed(!tagsCollapsed)}
>
<span className="collapse-icon">{tagsCollapsed ? '▶' : '▼'}</span>
<span>{t('sidebar.tags')}</span>
{selectedTags.length > 0 && (
<button
className="clear-filter"
onClick={(e) => { e.stopPropagation(); onTagSelect([]); }}
title={t('sidebar.clearTags')}
>
</button>
)}
</div>
{!tagsCollapsed && <div className="filter-chips">
{tags.map(tag => {
const color = tagColors.get(tag);
const hasColor = !!color;
const style: React.CSSProperties = hasColor
? {
backgroundColor: color,
color: getContrastColor(color!),
borderColor: color,
}
: {};
return (
<button
key={tag}
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''} ${hasColor ? 'has-color' : ''}`}
style={style}
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 collapsible-header ${categoriesCollapsed ? 'collapsed' : 'expanded'}`}
onClick={() => setCategoriesCollapsed(!categoriesCollapsed)}
>
<span className="collapse-icon">{categoriesCollapsed ? '▶' : '▼'}</span>
<span>{t('sidebar.categories')}</span>
{selectedCategories.length > 0 && (
<button
className="clear-filter"
onClick={(e) => { e.stopPropagation(); onCategorySelect([]); }}
title={t('sidebar.clearCategories')}
>
</button>
)}
</div>
{!categoriesCollapsed && <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>
);
};
// Media-specific calendar view
interface MediaCalendarViewProps {
onDateSelect: (year: number, month?: number) => void;
selectedYear?: number;
selectedMonth?: number;
}
const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, selectedYear, selectedMonth }) => {
const { t } = useI18n();
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [expandedYear, setExpandedYear] = useState<number | null>(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 (
<div className="calendar-view">
<div
className={`calendar-header collapsible-header ${isCollapsed ? 'collapsed' : 'expanded'}`}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
<span>{t('sidebar.archive')}</span>
{(selectedYear || selectedMonth !== undefined) && (
<button
className="clear-filter"
onClick={(e) => { e.stopPropagation(); onDateSelect(0); }}
title={t('sidebar.clearFilter')}
>
</button>
)}
</div>
{!isCollapsed && <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">{t('sidebar.noMediaYet')}</div>
)}
</div>}
</div>
);
};
// Media-specific filter panel
interface MediaFilterPanelProps {
tags: string[];
tagColors: Map<string, string>;
selectedTags: string[];
onTagSelect: (tags: string[]) => void;
}
const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
tags,
tagColors,
selectedTags,
onTagSelect,
}) => {
const { t } = useI18n();
const [tagsCollapsed, setTagsCollapsed] = useState(true);
return (
<div className="filter-panel">
{tags.length > 0 && (
<div className="filter-section">
<div
className={`filter-header collapsible-header ${tagsCollapsed ? 'collapsed' : 'expanded'}`}
onClick={() => setTagsCollapsed(!tagsCollapsed)}
>
<span className="collapse-icon">{tagsCollapsed ? '▶' : '▼'}</span>
<span>{t('sidebar.tags')}</span>
{selectedTags.length > 0 && (
<button
className="clear-filter"
onClick={(e) => { e.stopPropagation(); onTagSelect([]); }}
title={t('sidebar.clearTags')}
>
</button>
)}
</div>
{!tagsCollapsed && <div className="filter-chips">
{tags.map(tag => {
const color = tagColors.get(tag);
const hasColor = !!color;
const style: React.CSSProperties = hasColor
? {
backgroundColor: color,
color: getContrastColor(color!),
borderColor: color,
}
: {};
return (
<button
key={tag}
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''} ${hasColor ? 'has-color' : ''}`}
style={style}
onClick={() => {
if (selectedTags.includes(tag)) {
onTagSelect(selectedTags.filter(t => t !== tag));
} else {
onTagSelect([...selectedTags, tag]);
}
}}
>
{tag}
</button>
);
})}
</div>}
</div>
)}
</div>
);
};
interface SearchBoxProps {
onSearch: (query: string) => void;
placeholder: string;
}
const SearchBox: React.FC<SearchBoxProps> = ({ onSearch, placeholder }) => {
const { t } = useI18n();
const [query, setQuery] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(query);
};
return (
<form className="search-box" onSubmit={handleSubmit}>
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<button type="submit" title={t('sidebar.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={t('common.clear')}>
</button>
)}
</form>
);
};
type PostsListMode = 'posts' | 'pages';
interface PostsListProps {
mode: PostsListMode;
isActive: boolean;
}
const PostsList: React.FC<PostsListProps> = ({ 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<PostData[] | null>(null);
// 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 [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false);
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(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<string, string>();
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<Map<string, string>>(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 () => {
// 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 ?? (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 (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>{(isPagesMode ? t('activity.pages') : t('activity.posts')).toUpperCase()}</span>
<div className="sidebar-actions">
<button
className={`sidebar-action ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
title={t('sidebar.toggleFilters')}
>
<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={t('sidebar.newPost')}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
</div>
<SearchBox
onSearch={handleSearch}
placeholder={isPagesMode ? t('sidebar.searchPagesPlaceholder') : t('sidebar.searchPostsPlaceholder')}
/>
{showFilters && (
<>
<CalendarView
onDateSelect={handleDateSelect}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<FilterPanel
tags={availableTags}
tagColors={tagColors}
categories={availableCategories}
selectedTags={selectedTags}
selectedCategories={selectedCategories}
onTagSelect={setSelectedTags}
onCategorySelect={setSelectedCategories}
/>
</>
)}
{hasActiveFilters && (
<div className="filter-status">
<span>
{searchQuery
? t('sidebar.resultsFor', {
count: groupedPosts.published.length + groupedPosts.archived.length,
query: searchQuery,
})
: t('sidebar.results', { count: groupedPosts.published.length + groupedPosts.archived.length })}
</span>
<button onClick={clearAllFilters} title={t('sidebar.clearFilters')}>
{t('sidebar.clearFilters')}
</button>
</div>
)}
{groupedPosts.draft.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-draft"></span>
{t('sidebar.drafts')} ({groupedPosts.draft.length})
</div>
<div className="sidebar-list">
{groupedPosts.draft.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>
);
})}
</div>
</div>
)}
{groupedPosts.published.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-published"></span>
{t('sidebar.published')} ({groupedPosts.published.length})
</div>
<div className="sidebar-list">
{groupedPosts.published.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
</div>
</div>
);
})}
</div>
</div>
)}
{groupedPosts.archived.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">
<span className="section-icon status-archived"></span>
{t('sidebar.archived')} ({groupedPosts.archived.length})
</div>
<div className="sidebar-list">
{groupedPosts.archived.map(post => {
const postType = getPostTypeIcon(post.categories);
return (
<div
key={post.id}
className={`sidebar-item post-type-${postType.type} ${activeTabId === post.id ? 'selected' : ''}`}
onClick={() => handlePostClick(post.id)}
onDoubleClick={() => handlePostDoubleClick(post.id)}
>
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
<div className="sidebar-item-content">
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
</div>
</div>
);
})}
</div>
</div>
)}
{baseDisplayPosts.length === 0 && !isFiltered && (
<div className="sidebar-empty">
<p>{isPagesMode ? t('sidebar.noPagesYet') : t('sidebar.noPostsYet')}</p>
<button onClick={handleCreatePost}>{t('sidebar.createFirstPost')}</button>
</div>
)}
{groupedPosts.published.length === 0 && groupedPosts.archived.length === 0 && isFiltered && (
<div className="sidebar-empty">
<p>{t('sidebar.noMatchingPosts')}</p>
<button onClick={clearAllFilters}>{t('sidebar.clearFilters')}</button>
</div>
)}
{/* Load More button - only show when not filtering and has more posts */}
{!isFiltered && hasMorePosts && (
<div className="sidebar-load-more">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="load-more-button"
>
{isLoadingMore ? t('sidebar.loading') : t('sidebar.loadMore', { loaded: posts.length, total: totalPosts })}
</button>
</div>
)}
</div>
);
};
const MediaList: React.FC = () => {
const { t } = useI18n();
const { media, openTab, activeTabId } = useAppStore();
// Filter state
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<MediaData[] | null>(null);
const [selectedYear, setSelectedYear] = useState<number | undefined>();
const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [showFilters, setShowFilters] = useState(false);
const [filteredMedia, setFilteredMedia] = useState<MediaData[] | null>(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<string, string>();
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 (
<div className="sidebar-content">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>{t('activity.media').toUpperCase()}</span>
<div className="sidebar-actions">
<button
className={`sidebar-action ${showFilters ? 'active' : ''}`}
onClick={() => setShowFilters(!showFilters)}
title={t('sidebar.toggleFilters')}
>
<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={handleImportMedia} title={t('sidebar.importMedia')}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
</svg>
</button>
</div>
</div>
</div>
<SearchBox onSearch={handleSearch} placeholder={t('sidebar.searchMediaPlaceholder')} />
{showFilters && (
<>
<MediaCalendarView
onDateSelect={handleDateSelect}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<MediaFilterPanel
tags={availableTags}
tagColors={tagColors}
selectedTags={selectedTags}
onTagSelect={setSelectedTags}
/>
</>
)}
{hasActiveFilters && (
<div className="filter-status">
<span>
{searchQuery
? t('sidebar.resultsFor', { count: filteredDisplayMedia.length, query: searchQuery })
: t('sidebar.results', { count: filteredDisplayMedia.length })}
</span>
<button onClick={clearAllFilters} title={t('sidebar.clearFilters')}>
{t('sidebar.clearFilters')}
</button>
</div>
)}
<div className="sidebar-list media-grid">
{filteredDisplayMedia.map(item => (
<div
key={item.id}
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
onClick={() => handleMediaClick(item.id)}
onDoubleClick={() => handleMediaDoubleClick(item.id)}
title={item.caption || item.originalName}
>
{item.mimeType.startsWith('image/') ? (
<div className="media-thumbnail">
<img
src={`bds-thumb://${item.id}`}
alt={item.alt || item.originalName}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = 'none';
}}
/>
</div>
) : (
<div className="media-thumbnail">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" opacity="0.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
</svg>
</div>
)}
<div className="media-item-info">
<div className="media-item-name truncate">{getMediaDisplayName(item)}</div>
<div className="media-item-size">{formatFileSize(item.size)}</div>
</div>
</div>
))}
</div>
{filteredDisplayMedia.length === 0 && (
<div className="sidebar-empty">
<p>{t('sidebar.noMediaFiles')}</p>
<button onClick={handleImportMedia}>{t('sidebar.importMedia')}</button>
</div>
)}
</div>
);
};
const TagsNav: React.FC = () => {
const { t } = useI18n();
const { tabs, activeTabId, openTab } = useAppStore();
const [activeSection, setActiveSection] = useState<TagsCategory | null>(() => {
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 (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>{t('sidebar.tagsHeader').toUpperCase()}</span>
</div>
</div>
<div className="settings-nav-list">
<button
className={`settings-nav-entry ${activeSection === 'cloud' ? 'active' : ''}`}
onClick={() => handleNavClick('cloud')}
>
<span className="settings-nav-entry-icon"></span>
<span>{t('sidebar.tagCloud')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'manage' ? 'active' : ''}`}
onClick={() => handleNavClick('manage')}
>
<span className="settings-nav-entry-icon"></span>
<span>{t('sidebar.createEdit')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'merge' ? 'active' : ''}`}
onClick={() => handleNavClick('merge')}
>
<span className="settings-nav-entry-icon">🔀</span>
<span>{t('sidebar.mergeTags')}</span>
</button>
</div>
</div>
);
};
const SettingsNav: React.FC = () => {
const { t } = useI18n();
const { tabs, activeTabId, openTab } = useAppStore();
const [activeSection, setActiveSection] = useState<SettingsCategory | null>(() => {
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 (
<div className="sidebar-content settings-panel">
<div className="sidebar-section">
<div className="sidebar-section-header">
<span>{t('sidebar.settingsHeader').toUpperCase()}</span>
</div>
</div>
<div className="settings-nav-list">
<button
className={`settings-nav-entry ${activeSection === 'project' ? 'active' : ''}`}
onClick={() => handleNavClick('project')}
>
<span className="settings-nav-entry-icon">📁</span>
<span>{t('sidebar.nav.project')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'editor' ? 'active' : ''}`}
onClick={() => handleNavClick('editor')}
>
<span className="settings-nav-entry-icon">📝</span>
<span>{t('sidebar.nav.editor')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'content' ? 'active' : ''}`}
onClick={() => handleNavClick('content')}
>
<span className="settings-nav-entry-icon">📋</span>
<span>{t('sidebar.nav.content')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'ai' ? 'active' : ''}`}
onClick={() => handleNavClick('ai')}
>
<span className="settings-nav-entry-icon">🤖</span>
<span>{t('sidebar.nav.ai')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'publishing' ? 'active' : ''}`}
onClick={() => handleNavClick('publishing')}
>
<span className="settings-nav-entry-icon">🚀</span>
<span>{t('sidebar.nav.publishing')}</span>
</button>
<button
className={`settings-nav-entry ${activeSection === 'data' ? 'active' : ''}`}
onClick={() => handleNavClick('data')}
>
<span className="settings-nav-entry-icon">🗄</span>
<span>{t('sidebar.nav.data')}</span>
</button>
<button
className={`settings-nav-entry ${isStyleTabActive ? 'active' : ''}`}
onClick={handleStyleClick}
>
<span className="settings-nav-entry-icon">🎨</span>
<span>{t('sidebar.nav.style')}</span>
</button>
</div>
</div>
);
};
// Chat conversations list
const ChatList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, closeTab } = useAppStore();
const [conversations, setConversations] = useState<ChatConversation[]>([]);
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]);
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'));
}
};
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));
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
if (diffDays === 0) {
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return t('sidebar.chat.yesterday');
} else if (diffDays < 7) {
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
}
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.chat.header')}</span>
</div>
<div className="chat-loading">{t('sidebar.loading')}</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.chat.header')}</span>
<button className="chat-new-button" onClick={handleNewChat} title={t('sidebar.chat.newChat')}>
+
</button>
</div>
{!isReady && (
<div className="chat-auth-prompt">
<p>{t('sidebar.chat.apiKeyNeeded')}</p>
</div>
)}
<div className="chat-list-items">
{conversations.length === 0 ? (
<div className="chat-empty">
<p>{t('sidebar.chat.noConversations')}</p>
<button className="chat-start-button" onClick={handleNewChat}>
{t('sidebar.chat.startNew')}
</button>
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
className="chat-list-item"
onClick={() => handleOpenChat(conv.id)}
>
<div className="chat-item-content">
<div className="chat-item-title">{conv.title}</div>
<div className="chat-item-date">{formatChatDate(conv.updatedAt)}</div>
</div>
<button
className="chat-item-delete"
onClick={(e) => {
e.stopPropagation();
handleDeleteChat(conv.id);
}}
title={t('sidebar.chat.deleteConversation')}
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
const ImportList: React.FC = () => {
const { t, language } = useI18n();
const { openTab, closeTab, activeProject } = useAppStore();
const [definitions, setDefinitions] = useState<ImportDefinitionData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadDefinitions = useCallback(async () => {
try {
const defs = await window.electronAPI?.importDefinitions.getAll();
if (defs) {
setDefinitions(defs);
}
} catch (error) {
console.error('Failed to load import definitions:', error);
}
}, []);
// Reload definitions when project changes
useEffect(() => {
const init = async () => {
setIsLoading(true);
await loadDefinitions();
setIsLoading(false);
};
init();
}, [loadDefinitions, activeProject?.id]);
// 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'));
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
const uiDateLocale = UI_DATE_LOCALE[language] || UI_DATE_LOCALE.en;
if (diffDays === 0) {
return date.toLocaleTimeString(uiDateLocale, { hour: 'numeric', minute: '2-digit' });
} else if (diffDays === 1) {
return t('sidebar.chat.yesterday');
} else if (diffDays < 7) {
return date.toLocaleDateString(uiDateLocale, { weekday: 'short' });
}
return date.toLocaleDateString(uiDateLocale, { month: 'short', day: 'numeric' });
};
if (isLoading) {
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.import.header')}</span>
</div>
<div className="chat-loading">{t('sidebar.loading')}</div>
</div>
);
}
return (
<div className="chat-list">
<div className="chat-list-header">
<span>{t('sidebar.import.header')}</span>
<button className="chat-new-button" onClick={handleNewDefinition} title={t('sidebar.import.newDefinition')}>
+
</button>
</div>
<div className="chat-list-items">
{definitions.length === 0 ? (
<div className="chat-empty">
<p>{t('sidebar.import.none')}</p>
<button className="chat-start-button" onClick={handleNewDefinition}>
{t('sidebar.import.createDefinition')}
</button>
</div>
) : (
definitions.map(def => (
<div
key={def.id}
className="chat-list-item"
onClick={() => handleOpenDefinition(def.id)}
>
<div className="chat-item-content">
<div className="chat-item-title">{def.name}</div>
<div className="chat-item-date">{formatDate(def.updatedAt)}</div>
</div>
<button
className="chat-item-delete"
onClick={(e) => handleDeleteDefinition(e, def.id)}
title={t('sidebar.import.deleteDefinition')}
>
×
</button>
</div>
))
)}
</div>
</div>
);
};
export const Sidebar: React.FC = () => {
const { activeView, sidebarVisible } = useAppStore();
if (!sidebarVisible) {
return null;
}
const sidebarViewMap: Record<SidebarView, React.ReactNode> = {
posts: <PostsList mode="posts" isActive={true} />,
pages: <PostsList mode="pages" isActive={true} />,
media: <MediaList />,
settings: <SettingsNav />,
tags: <TagsNav />,
chat: <ChatList />,
import: <ImportList />,
git: <GitSidebar />,
};
return (
<div className="sidebar">
{sidebarViewMap[activeView]}
</div>
);
};