1699 lines
58 KiB
TypeScript
1699 lines
58 KiB
TypeScript
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>
|
||
);
|
||
};
|