feat: tag clouds use colors now

This commit is contained in:
2026-02-13 10:34:15 +01:00
parent f904f42f88
commit deb0f3ae3b
4 changed files with 189 additions and 45 deletions

View File

@@ -554,6 +554,14 @@
opacity: 0.75; opacity: 0.75;
} }
.dashboard-tag.has-color {
border-radius: 12px;
}
.dashboard-tag.has-color:hover {
opacity: 0.85;
}
.tag-cloud-more { .tag-cloud-more {
font-size: 11px; font-size: 11px;
} }

View File

@@ -1278,26 +1278,60 @@ interface CategoryCount {
count: number; count: number;
} }
interface TagDataWithColor {
id: string;
name: string;
color?: string;
}
// Get contrasting text color for background
const getContrastColor = (hex: string): string => {
const color = hex.replace('#', '');
let r: number, g: number, b: number;
if (color.length === 3) {
r = parseInt(color[0] + color[0], 16);
g = parseInt(color[1] + color[1], 16);
b = parseInt(color[2] + color[2], 16);
} else {
r = parseInt(color.substring(0, 2), 16);
g = parseInt(color.substring(2, 4), 16);
b = parseInt(color.substring(4, 6), 16);
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#ffffff';
};
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const { posts, media } = useAppStore(); const { posts, media } = useAppStore();
const [stats, setStats] = useState<DashboardStats | null>(null); const [stats, setStats] = useState<DashboardStats | null>(null);
const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]); const [yearMonthData, setYearMonthData] = useState<{ year: number; month: number; count: number }[]>([]);
const [tagCounts, setTagCounts] = useState<TagCount[]>([]); const [tagCounts, setTagCounts] = useState<TagCount[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]); const [categoryCounts, setCategoryCounts] = useState<CategoryCount[]>([]);
useEffect(() => { useEffect(() => {
const loadStats = async () => { const loadStats = async () => {
try { try {
const [ds, ym, tc, cc] = await Promise.all([ const [ds, ym, tc, cc, allTagsData] = await Promise.all([
window.electronAPI?.posts.getDashboardStats(), window.electronAPI?.posts.getDashboardStats(),
window.electronAPI?.posts.getByYearMonth(), window.electronAPI?.posts.getByYearMonth(),
window.electronAPI?.posts.getTagsWithCounts(), window.electronAPI?.posts.getTagsWithCounts(),
window.electronAPI?.posts.getCategoriesWithCounts(), window.electronAPI?.posts.getCategoriesWithCounts(),
window.electronAPI?.tags.getAll(),
]); ]);
if (ds) setStats(ds); if (ds) setStats(ds);
if (ym) setYearMonthData(ym); if (ym) setYearMonthData(ym);
if (tc) setTagCounts(tc); if (tc) setTagCounts(tc);
if (cc) setCategoryCounts(cc); if (cc) setCategoryCounts(cc);
if (allTagsData) {
const colorMap = new Map<string, string>();
for (const tag of allTagsData as TagDataWithColor[]) {
if (tag.color) {
colorMap.set(tag.name, tag.color);
}
}
setTagColors(colorMap);
}
} catch (e) { } catch (e) {
console.error('Failed to load dashboard stats:', e); console.error('Failed to load dashboard stats:', e);
} }
@@ -1333,8 +1367,9 @@ const Dashboard: React.FC = () => {
return items.map(t => ({ return items.map(t => ({
...t, ...t,
fontSize: 11 + ((t.count - minTagCount) / range) * 11, fontSize: 11 + ((t.count - minTagCount) / range) * 11,
color: tagColors.get(t.tag),
})).sort((a, b) => a.tag.localeCompare(b.tag)); // alphabetical for cloud layout })).sort((a, b) => a.tag.localeCompare(b.tag)); // alphabetical for cloud layout
}, [tagCounts]); }, [tagCounts, tagColors]);
const displayTotalPosts = stats?.totalPosts ?? posts.length; const displayTotalPosts = stats?.totalPosts ?? posts.length;
const displayDraftCount = stats?.draftCount ?? 0; const displayDraftCount = stats?.draftCount ?? 0;
@@ -1394,16 +1429,26 @@ const Dashboard: React.FC = () => {
<div className="dashboard-section"> <div className="dashboard-section">
<h4>Tags</h4> <h4>Tags</h4>
<div className="tag-cloud"> <div className="tag-cloud">
{tagCloudItems.map(item => ( {tagCloudItems.map(item => {
<span const hasColor = !!item.color;
key={item.tag} const style: React.CSSProperties = hasColor
className="dashboard-tag" ? {
style={{ fontSize: `${item.fontSize}px` }} fontSize: `${item.fontSize}px`,
title={`${item.count} post${item.count !== 1 ? 's' : ''}`} backgroundColor: item.color,
> color: getContrastColor(item.color!),
{item.tag} }
</span> : { fontSize: `${item.fontSize}px` };
))} return (
<span
key={item.tag}
className={`dashboard-tag ${hasColor ? 'has-color' : ''}`}
style={style}
title={`${item.count} post${item.count !== 1 ? 's' : ''}`}
>
{item.tag}
</span>
);
})}
{tagCounts.length > 40 && <span className="text-muted tag-cloud-more">+{tagCounts.length - 40} more</span>} {tagCounts.length > 40 && <span className="text-muted tag-cloud-more">+{tagCounts.length - 40} more</span>}
</div> </div>
</div> </div>

View File

@@ -545,7 +545,7 @@
font-size: 11px; font-size: 11px;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: background-color 0.15s; transition: background-color 0.15s, opacity 0.15s;
} }
.filter-chip:hover { .filter-chip:hover {
@@ -557,6 +557,19 @@
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground);
} }
/* Colored filter chips (tags with assigned colors) */
.filter-chip.has-color {
border: 1px solid transparent;
}
.filter-chip.has-color:hover {
opacity: 0.85;
}
.filter-chip.has-color.active {
box-shadow: 0 0 0 2px var(--vscode-focusBorder, #007fd4);
}
/* Filter Status */ /* Filter Status */
.filter-status { .filter-status {
display: flex; display: flex;

View File

@@ -4,6 +4,30 @@ import { showToast } from '../Toast';
import type { ChatConversation } from '../../types/electron'; import type { ChatConversation } from '../../types/electron';
import './Sidebar.css'; import './Sidebar.css';
// Tag data with color information
interface TagData {
id: string;
name: string;
color?: string;
}
// Get contrasting text color for background
const getContrastColor = (hex: string): string => {
const color = hex.replace('#', '');
let r: number, g: number, b: number;
if (color.length === 3) {
r = parseInt(color[0] + color[0], 16);
g = parseInt(color[1] + color[1], 16);
b = parseInt(color[2] + color[2], 16);
} else {
r = parseInt(color.substring(0, 2), 16);
g = parseInt(color.substring(2, 4), 16);
b = parseInt(color.substring(4, 6), 16);
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#ffffff';
};
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
@@ -123,6 +147,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ onDateSelect, selectedYear,
interface FilterPanelProps { interface FilterPanelProps {
tags: string[]; tags: string[];
tagColors: Map<string, string>;
categories: string[]; categories: string[];
selectedTags: string[]; selectedTags: string[];
selectedCategories: string[]; selectedCategories: string[];
@@ -132,6 +157,7 @@ interface FilterPanelProps {
const FilterPanel: React.FC<FilterPanelProps> = ({ const FilterPanel: React.FC<FilterPanelProps> = ({
tags, tags,
tagColors,
categories, categories,
selectedTags, selectedTags,
selectedCategories, selectedCategories,
@@ -144,21 +170,33 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
<div className="filter-section"> <div className="filter-section">
<div className="filter-header">TAGS</div> <div className="filter-header">TAGS</div>
<div className="filter-chips"> <div className="filter-chips">
{tags.map(tag => ( {tags.map(tag => {
<button const color = tagColors.get(tag);
key={tag} const hasColor = !!color;
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`} const style: React.CSSProperties = hasColor
onClick={() => { ? {
if (selectedTags.includes(tag)) { backgroundColor: color,
onTagSelect(selectedTags.filter(t => t !== tag)); color: getContrastColor(color!),
} else { borderColor: color,
onTagSelect([...selectedTags, tag]);
} }
}} : {};
> return (
{tag} <button
</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> </div>
)} )}
@@ -273,12 +311,14 @@ const MediaCalendarView: React.FC<MediaCalendarViewProps> = ({ onDateSelect, sel
// Media-specific filter panel // Media-specific filter panel
interface MediaFilterPanelProps { interface MediaFilterPanelProps {
tags: string[]; tags: string[];
tagColors: Map<string, string>;
selectedTags: string[]; selectedTags: string[];
onTagSelect: (tags: string[]) => void; onTagSelect: (tags: string[]) => void;
} }
const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
tags, tags,
tagColors,
selectedTags, selectedTags,
onTagSelect, onTagSelect,
}) => { }) => {
@@ -288,21 +328,33 @@ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
<div className="filter-section"> <div className="filter-section">
<div className="filter-header">TAGS</div> <div className="filter-header">TAGS</div>
<div className="filter-chips"> <div className="filter-chips">
{tags.map(tag => ( {tags.map(tag => {
<button const color = tagColors.get(tag);
key={tag} const hasColor = !!color;
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`} const style: React.CSSProperties = hasColor
onClick={() => { ? {
if (selectedTags.includes(tag)) { backgroundColor: color,
onTagSelect(selectedTags.filter(t => t !== tag)); color: getContrastColor(color!),
} else { borderColor: color,
onTagSelect([...selectedTags, tag]);
} }
}} : {};
> return (
{tag} <button
</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> </div>
)} )}
@@ -355,20 +407,31 @@ const PostsList: React.FC = () => {
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedCategories, setSelectedCategories] = useState<string[]>([]); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]); const [availableTags, setAvailableTags] = useState<string[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [availableCategories, setAvailableCategories] = useState<string[]>([]); const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null); const [filteredPosts, setFilteredPosts] = useState<PostData[] | null>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false);
// Load available tags and categories // Load available tags with colors and categories
useEffect(() => { useEffect(() => {
const loadFilters = async () => { const loadFilters = async () => {
const [tags, categories] = await Promise.all([ const [tags, categories, allTagsData] = await Promise.all([
window.electronAPI?.posts.getTags(), window.electronAPI?.posts.getTags(),
window.electronAPI?.posts.getCategories(), window.electronAPI?.posts.getCategories(),
window.electronAPI?.tags.getAll(),
]); ]);
if (tags) setAvailableTags(tags as string[]); if (tags) setAvailableTags(tags as string[]);
if (categories) setAvailableCategories(categories as string[]); if (categories) setAvailableCategories(categories 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);
}
}; };
loadFilters(); loadFilters();
}, [posts]); }, [posts]);
@@ -553,6 +616,7 @@ const PostsList: React.FC = () => {
/> />
<FilterPanel <FilterPanel
tags={availableTags} tags={availableTags}
tagColors={tagColors}
categories={availableCategories} categories={availableCategories}
selectedTags={selectedTags} selectedTags={selectedTags}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
@@ -698,14 +762,27 @@ const MediaList: React.FC = () => {
const [selectedMonth, setSelectedMonth] = useState<number | undefined>(); const [selectedMonth, setSelectedMonth] = useState<number | undefined>();
const [selectedTags, setSelectedTags] = useState<string[]>([]); const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]); const [availableTags, setAvailableTags] = useState<string[]>([]);
const [tagColors, setTagColors] = useState<Map<string, string>>(new Map());
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [filteredMedia, setFilteredMedia] = useState<MediaData[] | null>(null); const [filteredMedia, setFilteredMedia] = useState<MediaData[] | null>(null);
// Load available tags // Load available tags with colors
useEffect(() => { useEffect(() => {
const loadTags = async () => { const loadTags = async () => {
const tags = await window.electronAPI?.media.getTags(); const [tags, allTagsData] = await Promise.all([
window.electronAPI?.media.getTags(),
window.electronAPI?.tags.getAll(),
]);
if (tags) setAvailableTags(tags as string[]); 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(); loadTags();
}, [media]); }, [media]);
@@ -843,6 +920,7 @@ const MediaList: React.FC = () => {
/> />
<MediaFilterPanel <MediaFilterPanel
tags={availableTags} tags={availableTags}
tagColors={tagColors}
selectedTags={selectedTags} selectedTags={selectedTags}
onTagSelect={setSelectedTags} onTagSelect={setSelectedTags}
/> />