feat: media sidebar with filters
This commit is contained in:
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||||
import { app } from 'electron';
|
import { app } from 'electron';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { media, Media, NewMedia } from '../database/schema';
|
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)
|
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 {
|
export class MediaEngine extends EventEmitter {
|
||||||
private currentProjectId: string = 'default';
|
private currentProjectId: string = 'default';
|
||||||
private dataDir: string | null = null; // For media files (may be external)
|
private dataDir: string | null = null; // For media files (may be external)
|
||||||
@@ -546,8 +561,13 @@ export class MediaEngine extends EventEmitter {
|
|||||||
|
|
||||||
async getAllMedia(): Promise<MediaData[]> {
|
async getAllMedia(): Promise<MediaData[]> {
|
||||||
const db = getDatabase().getLocal();
|
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 => ({
|
return dbMediaList.map(dbMedia => ({
|
||||||
id: dbMedia.id,
|
id: dbMedia.id,
|
||||||
filename: dbMedia.filename,
|
filename: dbMedia.filename,
|
||||||
@@ -564,6 +584,154 @@ export class MediaEngine extends EventEmitter {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMediaFiltered(filter: MediaFilter): Promise<MediaData[]> {
|
||||||
|
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<MediaSearchResult[]> {
|
||||||
|
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<string, { year: number; month: number; count: number }>();
|
||||||
|
|
||||||
|
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<string[]> {
|
||||||
|
const allMedia = await this.getAllMedia();
|
||||||
|
const tags = new Set<string>();
|
||||||
|
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<string, number>();
|
||||||
|
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 {
|
getMediaPath(id: string): string {
|
||||||
return path.join(this.getMediaDir(), id);
|
return path.join(this.getMediaDir(), id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,31 @@ export function registerIpcHandlers(): void {
|
|||||||
return engine.getAllMedia();
|
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 () => {
|
safeHandle('media:rebuildFromFiles', async () => {
|
||||||
// Ensure project context is current before rebuilding
|
// Ensure project context is current before rebuilding
|
||||||
const projectEngine = getProjectEngine();
|
const projectEngine = getProjectEngine();
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),
|
getUrl: (id: string) => ipcRenderer.invoke('media:getUrl', id),
|
||||||
getFilePath: (id: string) => ipcRenderer.invoke('media:getFilePath', id),
|
getFilePath: (id: string) => ipcRenderer.invoke('media:getFilePath', id),
|
||||||
getAll: () => ipcRenderer.invoke('media:getAll'),
|
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'),
|
rebuildFromFiles: () => ipcRenderer.invoke('media:rebuildFromFiles'),
|
||||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||||
|
|||||||
@@ -188,6 +188,128 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
|
||||||
|
const [expandedYear, setExpandedYear] = useState<number | null>(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 (
|
||||||
|
<div className="calendar-view">
|
||||||
|
<div className="calendar-header">
|
||||||
|
<span>ARCHIVE</span>
|
||||||
|
{(selectedYear || selectedMonth !== undefined) && (
|
||||||
|
<button className="clear-filter" onClick={() => onDateSelect(0)} title="Clear filter">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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">No media yet</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Media-specific filter panel
|
||||||
|
interface MediaFilterPanelProps {
|
||||||
|
tags: string[];
|
||||||
|
selectedTags: string[];
|
||||||
|
onTagSelect: (tags: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
|
||||||
|
tags,
|
||||||
|
selectedTags,
|
||||||
|
onTagSelect,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="filter-panel">
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div className="filter-section">
|
||||||
|
<div className="filter-header">TAGS</div>
|
||||||
|
<div className="filter-chips">
|
||||||
|
{tags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedTags.includes(tag)) {
|
||||||
|
onTagSelect(selectedTags.filter(t => t !== tag));
|
||||||
|
} else {
|
||||||
|
onTagSelect([...selectedTags, tag]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface SearchBoxProps {
|
interface SearchBoxProps {
|
||||||
onSearch: (query: string) => void;
|
onSearch: (query: string) => void;
|
||||||
}
|
}
|
||||||
@@ -563,6 +685,94 @@ const PostsList: React.FC = () => {
|
|||||||
const MediaList: React.FC = () => {
|
const MediaList: React.FC = () => {
|
||||||
const { media, openTab, activeTabId } = useAppStore();
|
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 [showFilters, setShowFilters] = useState(false);
|
||||||
|
const [filteredMedia, setFilteredMedia] = useState<MediaData[] | null>(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 () => {
|
const handleImportMedia = async () => {
|
||||||
try {
|
try {
|
||||||
await window.electronAPI?.media.importDialog();
|
await window.electronAPI?.media.importDialog();
|
||||||
@@ -579,21 +789,74 @@ const MediaList: React.FC = () => {
|
|||||||
openTab({ type: 'media', id: mediaId, isTransient: false });
|
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 (
|
return (
|
||||||
<div className="sidebar-content">
|
<div className="sidebar-content">
|
||||||
<div className="sidebar-section">
|
<div className="sidebar-section">
|
||||||
<div className="sidebar-section-header">
|
<div className="sidebar-section-header">
|
||||||
<span>MEDIA</span>
|
<span>MEDIA</span>
|
||||||
<button className="sidebar-action" onClick={handleImportMedia} title="Import Media">
|
<div className="sidebar-actions">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<button
|
||||||
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
className={`sidebar-action ${showFilters ? 'active' : ''}`}
|
||||||
</svg>
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
</button>
|
title="Toggle Filters"
|
||||||
|
>
|
||||||
|
<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="Import Media">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M14 7v1H8v6H7V8H1V7h6V1h1v6h6z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SearchBox onSearch={handleSearch} />
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<>
|
||||||
|
<MediaCalendarView
|
||||||
|
onDateSelect={handleDateSelect}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
<MediaFilterPanel
|
||||||
|
tags={availableTags}
|
||||||
|
selectedTags={selectedTags}
|
||||||
|
onTagSelect={setSelectedTags}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="filter-status">
|
||||||
|
<span>
|
||||||
|
{filteredDisplayMedia.length} result{filteredDisplayMedia.length !== 1 ? 's' : ''}
|
||||||
|
{searchQuery && ` for "${searchQuery}"`}
|
||||||
|
</span>
|
||||||
|
<button onClick={clearAllFilters} title="Clear all filters">
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="sidebar-list media-grid">
|
<div className="sidebar-list media-grid">
|
||||||
{media.map(item => (
|
{filteredDisplayMedia.map(item => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
|
className={`media-item ${activeTabId === item.id ? 'selected' : ''}`}
|
||||||
@@ -603,7 +866,7 @@ const MediaList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{item.mimeType.startsWith('image/') ? (
|
{item.mimeType.startsWith('image/') ? (
|
||||||
<div className="media-thumbnail">
|
<div className="media-thumbnail">
|
||||||
<img
|
<img
|
||||||
src={`bds-thumb://${item.id}`}
|
src={`bds-thumb://${item.id}`}
|
||||||
alt={item.alt || item.originalName}
|
alt={item.alt || item.originalName}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
@@ -627,7 +890,7 @@ const MediaList: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{media.length === 0 && (
|
{filteredDisplayMedia.length === 0 && (
|
||||||
<div className="sidebar-empty">
|
<div className="sidebar-empty">
|
||||||
<p>No media files</p>
|
<p>No media files</p>
|
||||||
<button onClick={handleImportMedia}>Import media</button>
|
<button onClick={handleImportMedia}>Import media</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user