From 91111c7572a3bd0a8fc968d95a8ffa1dd94fff9a Mon Sep 17 00:00:00 2001 From: hugo Date: Thu, 12 Feb 2026 21:36:47 +0100 Subject: [PATCH] feat: media sidebar with filters --- src/main/engine/MediaEngine.ts | 174 +++++++++++- src/main/ipc/handlers.ts | 25 ++ src/main/preload.ts | 5 + src/renderer/components/Sidebar/Sidebar.tsx | 279 +++++++++++++++++++- 4 files changed, 472 insertions(+), 11 deletions(-) diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index ae48321..1958c49 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; -import { eq } from 'drizzle-orm'; +import { eq, and, gte, lte, desc } from 'drizzle-orm'; import { app } from 'electron'; import { getDatabase } from '../database'; import { media, Media, NewMedia } from '../database/schema'; @@ -49,6 +49,21 @@ export interface MediaMetadata { linkedPostIds?: string[]; // Posts this media is linked to (persisted in sidecar) } +export interface MediaFilter { + tags?: string[]; + startDate?: Date; + endDate?: Date; + year?: number; + month?: number; +} + +export interface MediaSearchResult { + id: string; + originalName: string; + mimeType: string; + createdAt: Date; +} + export class MediaEngine extends EventEmitter { private currentProjectId: string = 'default'; private dataDir: string | null = null; // For media files (may be external) @@ -546,8 +561,13 @@ export class MediaEngine extends EventEmitter { async getAllMedia(): Promise { const db = getDatabase().getLocal(); - const dbMediaList = await db.select().from(media).all(); - + const dbMediaList = await db + .select() + .from(media) + .where(eq(media.projectId, this.currentProjectId)) + .orderBy(desc(media.createdAt)) + .all(); + return dbMediaList.map(dbMedia => ({ id: dbMedia.id, filename: dbMedia.filename, @@ -564,6 +584,154 @@ export class MediaEngine extends EventEmitter { })); } + async getMediaFiltered(filter: MediaFilter): Promise { + const db = getDatabase().getLocal(); + const conditions = [eq(media.projectId, this.currentProjectId)]; + + if (filter.startDate) { + conditions.push(gte(media.createdAt, filter.startDate)); + } + + if (filter.endDate) { + conditions.push(lte(media.createdAt, filter.endDate)); + } + + if (filter.year !== undefined) { + const startOfYear = new Date(filter.year, 0, 1); + const endOfYear = new Date(filter.year + 1, 0, 1); + conditions.push(gte(media.createdAt, startOfYear)); + conditions.push(lte(media.createdAt, endOfYear)); + } + + if (filter.month !== undefined && filter.year !== undefined) { + const startOfMonth = new Date(filter.year, filter.month, 1); + const endOfMonth = new Date(filter.year, filter.month + 1, 1); + conditions.push(gte(media.createdAt, startOfMonth)); + conditions.push(lte(media.createdAt, endOfMonth)); + } + + const dbMediaList = await db + .select() + .from(media) + .where(and(...conditions)) + .orderBy(desc(media.createdAt)) + .all(); + + let result: MediaData[] = []; + + for (const dbMedia of dbMediaList) { + const mediaData: MediaData = { + id: dbMedia.id, + filename: dbMedia.filename, + originalName: dbMedia.originalName, + mimeType: dbMedia.mimeType, + size: dbMedia.size, + width: dbMedia.width || undefined, + height: dbMedia.height || undefined, + alt: dbMedia.alt || undefined, + caption: dbMedia.caption || undefined, + createdAt: dbMedia.createdAt, + updatedAt: dbMedia.updatedAt, + tags: JSON.parse(dbMedia.tags || '[]'), + }; + + // Client-side filtering for tags (JSON array) + if (filter.tags && filter.tags.length > 0) { + const hasAllTags = filter.tags.every(tag => mediaData.tags.includes(tag)); + if (!hasAllTags) continue; + } + + result.push(mediaData); + } + + return result; + } + + async searchMedia(query: string): Promise { + const db = getDatabase().getLocal(); + const allMedia = await db + .select() + .from(media) + .where(eq(media.projectId, this.currentProjectId)) + .orderBy(desc(media.createdAt)) + .all(); + + const lowerQuery = query.toLowerCase(); + const searchResults: MediaSearchResult[] = []; + + for (const item of allMedia) { + // Search in originalName, alt, caption, and tags + const searchableText = [ + item.originalName, + item.alt || '', + item.caption || '', + ...(JSON.parse(item.tags || '[]') as string[]), + ].join(' ').toLowerCase(); + + if (searchableText.includes(lowerQuery)) { + searchResults.push({ + id: item.id, + originalName: item.originalName, + mimeType: item.mimeType, + createdAt: item.createdAt, + }); + } + } + + return searchResults; + } + + async getMediaByYearMonth(): Promise<{ year: number; month: number; count: number }[]> { + const allMedia = await this.getAllMedia(); + const counts = new Map(); + + for (const item of allMedia) { + const year = item.createdAt.getFullYear(); + const month = item.createdAt.getMonth(); + const key = `${year}-${month}`; + const current = counts.get(key) || { year, month, count: 0 }; + current.count++; + counts.set(key, current); + } + + return Array.from(counts.values()).sort((a, b) => { + if (a.year !== b.year) return b.year - a.year; + return b.month - a.month; + }); + } + + async getAvailableTags(): Promise { + const allMedia = await this.getAllMedia(); + const tags = new Set(); + for (const item of allMedia) { + for (const tag of item.tags) { + tags.add(tag); + } + } + return Array.from(tags).sort(); + } + + async getTagsWithCounts(): Promise<{ tag: string; count: number }[]> { + const db = getDatabase().getLocal(); + const dbMediaList = await db + .select({ tags: media.tags }) + .from(media) + .where(eq(media.projectId, this.currentProjectId)) + .all(); + + const tagCounts = new Map(); + for (const row of dbMediaList) { + const parsed: string[] = JSON.parse(row.tags || '[]'); + for (const tag of parsed) { + tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1); + } + } + + return Array.from(tagCounts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + } + getMediaPath(id: string): string { return path.join(this.getMediaDir(), id); } diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 91bde79..02be88a 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -332,6 +332,31 @@ export function registerIpcHandlers(): void { return engine.getAllMedia(); }); + safeHandle('media:filter', async (_, filter: import('../engine/MediaEngine').MediaFilter) => { + const engine = getMediaEngine(); + return engine.getMediaFiltered(filter); + }); + + safeHandle('media:search', async (_, query: string) => { + const engine = getMediaEngine(); + return engine.searchMedia(query); + }); + + safeHandle('media:getByYearMonth', async () => { + const engine = getMediaEngine(); + return engine.getMediaByYearMonth(); + }); + + safeHandle('media:getTags', async () => { + const engine = getMediaEngine(); + return engine.getAvailableTags(); + }); + + safeHandle('media:getTagsWithCounts', async () => { + const engine = getMediaEngine(); + return engine.getTagsWithCounts(); + }); + safeHandle('media:rebuildFromFiles', async () => { // Ensure project context is current before rebuilding const projectEngine = getProjectEngine(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 98f3c39..8487000 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -53,6 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', { getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id), getFilePath: (id: string) => ipcRenderer.invoke('media:getFilePath', id), getAll: () => ipcRenderer.invoke('media:getAll'), + filter: (filter: unknown) => ipcRenderer.invoke('media:filter', filter), + search: (query: string) => ipcRenderer.invoke('media:search', query), + getByYearMonth: () => ipcRenderer.invoke('media:getByYearMonth'), + getTags: () => ipcRenderer.invoke('media:getTags'), + getTagsWithCounts: () => ipcRenderer.invoke('media:getTagsWithCounts'), rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'), getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size), regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id), diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 38700c2..6fcd16f 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -188,6 +188,128 @@ const FilterPanel: React.FC = ({ ); }; +// Media-specific calendar view +interface MediaCalendarViewProps { + onDateSelect: (year: number, month?: number) => void; + selectedYear?: number; + selectedMonth?: number; +} + +const MediaCalendarView: React.FC = ({ onDateSelect, selectedYear, selectedMonth }) => { + const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); + const [expandedYear, setExpandedYear] = useState(null); + + useEffect(() => { + const loadData = async () => { + const data = await window.electronAPI?.media.getByYearMonth(); + if (data) { + setYearMonthData(data as { year: number; month: number; count: number }[]); + } + }; + loadData(); + }, []); + + const years = [...new Set(yearMonthData.map(d => d.year))].sort((a, b) => b - a); + + const getYearCount = (year: number) => { + return yearMonthData.filter(d => d.year === year).reduce((sum, d) => sum + d.count, 0); + }; + + const getMonthsForYear = (year: number) => { + return yearMonthData.filter(d => d.year === year).sort((a, b) => b.month - a.month); + }; + + return ( +
+
+ ARCHIVE + {(selectedYear || selectedMonth !== undefined) && ( + + )} +
+
+ {years.map(year => ( +
+
{ + setExpandedYear(expandedYear === year ? null : year); + onDateSelect(year); + }} + > + {expandedYear === year ? '▼' : '▶'} + {year} + {getYearCount(year)} +
+ {expandedYear === year && ( +
+ {getMonthsForYear(year).map(({ month, count }) => ( +
{ + e.stopPropagation(); + onDateSelect(year, month); + }} + > + {MONTH_NAMES[month]} + {count} +
+ ))} +
+ )} +
+ ))} + {years.length === 0 && ( +
No media yet
+ )} +
+
+ ); +}; + +// Media-specific filter panel +interface MediaFilterPanelProps { + tags: string[]; + selectedTags: string[]; + onTagSelect: (tags: string[]) => void; +} + +const MediaFilterPanel: React.FC = ({ + tags, + selectedTags, + onTagSelect, +}) => { + return ( +
+ {tags.length > 0 && ( +
+
TAGS
+
+ {tags.map(tag => ( + + ))} +
+
+ )} +
+ ); +}; + interface SearchBoxProps { onSearch: (query: string) => void; } @@ -563,6 +685,94 @@ const PostsList: React.FC = () => { const MediaList: React.FC = () => { const { media, openTab, activeTabId } = useAppStore(); + // Filter state + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [selectedYear, setSelectedYear] = useState(); + const [selectedMonth, setSelectedMonth] = useState(); + const [selectedTags, setSelectedTags] = useState([]); + const [availableTags, setAvailableTags] = useState([]); + const [showFilters, setShowFilters] = useState(false); + const [filteredMedia, setFilteredMedia] = useState(null); + + // Load available tags + useEffect(() => { + const loadTags = async () => { + const tags = await window.electronAPI?.media.getTags(); + if (tags) setAvailableTags(tags as string[]); + }; + loadTags(); + }, [media]); + + // Handle search + const handleSearch = async (query: string) => { + setSearchQuery(query); + if (!query.trim()) { + setSearchResults(null); + return; + } + try { + const results = await window.electronAPI?.media.search(query); + if (results) { + const mediaIds = (results as { id: string }[]).map(r => r.id); + setSearchResults(media.filter(m => mediaIds.includes(m.id))); + } + } catch (error) { + console.error('Search failed:', error); + showToast.error('Search failed'); + } + }; + + // Handle date selection + const handleDateSelect = async (year: number, month?: number) => { + if (year === 0) { + // Clear filter + setSelectedYear(undefined); + setSelectedMonth(undefined); + setFilteredMedia(null); + return; + } + setSelectedYear(year); + setSelectedMonth(month); + + try { + const results = await window.electronAPI?.media.filter({ + year, + month, + tags: selectedTags.length > 0 ? selectedTags : undefined, + }); + if (results) { + setFilteredMedia(results as MediaData[]); + } + } catch (error) { + console.error('Filter failed:', error); + } + }; + + // Handle tag filter changes + useEffect(() => { + const applyFilters = async () => { + if (!selectedYear && selectedTags.length === 0) { + setFilteredMedia(null); + return; + } + + try { + const results = await window.electronAPI?.media.filter({ + year: selectedYear, + month: selectedMonth, + tags: selectedTags.length > 0 ? selectedTags : undefined, + }); + if (results) { + setFilteredMedia(results as MediaData[]); + } + } catch (error) { + console.error('Filter failed:', error); + } + }; + applyFilters(); + }, [selectedTags]); + const handleImportMedia = async () => { try { await window.electronAPI?.media.importDialog(); @@ -579,21 +789,74 @@ const MediaList: React.FC = () => { openTab({ type: 'media', id: mediaId, isTransient: false }); }; + // Determine which media to display + const filteredDisplayMedia = searchResults ?? filteredMedia ?? media; + const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0; + + const clearAllFilters = () => { + setSearchQuery(''); + setSearchResults(null); + setSelectedYear(undefined); + setSelectedMonth(undefined); + setSelectedTags([]); + setFilteredMedia(null); + }; + return (
MEDIA - +
+ + +
+ + + {showFilters && ( + <> + + + + )} + + {hasActiveFilters && ( +
+ + {filteredDisplayMedia.length} result{filteredDisplayMedia.length !== 1 ? 's' : ''} + {searchQuery && ` for "${searchQuery}"`} + + +
+ )} +
- {media.map(item => ( + {filteredDisplayMedia.map(item => (
{ > {item.mimeType.startsWith('image/') ? (
- {item.alt { @@ -627,7 +890,7 @@ const MediaList: React.FC = () => { ))}
- {media.length === 0 && ( + {filteredDisplayMedia.length === 0 && (

No media files