feat: tag clouds use colors now
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
const hasColor = !!item.color;
|
||||||
|
const style: React.CSSProperties = hasColor
|
||||||
|
? {
|
||||||
|
fontSize: `${item.fontSize}px`,
|
||||||
|
backgroundColor: item.color,
|
||||||
|
color: getContrastColor(item.color!),
|
||||||
|
}
|
||||||
|
: { fontSize: `${item.fontSize}px` };
|
||||||
|
return (
|
||||||
<span
|
<span
|
||||||
key={item.tag}
|
key={item.tag}
|
||||||
className="dashboard-tag"
|
className={`dashboard-tag ${hasColor ? 'has-color' : ''}`}
|
||||||
style={{ fontSize: `${item.fontSize}px` }}
|
style={style}
|
||||||
title={`${item.count} post${item.count !== 1 ? 's' : ''}`}
|
title={`${item.count} post${item.count !== 1 ? 's' : ''}`}
|
||||||
>
|
>
|
||||||
{item.tag}
|
{item.tag}
|
||||||
</span>
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,10 +170,21 @@ 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 => {
|
||||||
|
const color = tagColors.get(tag);
|
||||||
|
const hasColor = !!color;
|
||||||
|
const style: React.CSSProperties = hasColor
|
||||||
|
? {
|
||||||
|
backgroundColor: color,
|
||||||
|
color: getContrastColor(color!),
|
||||||
|
borderColor: color,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
|
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''} ${hasColor ? 'has-color' : ''}`}
|
||||||
|
style={style}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedTags.includes(tag)) {
|
if (selectedTags.includes(tag)) {
|
||||||
onTagSelect(selectedTags.filter(t => t !== tag));
|
onTagSelect(selectedTags.filter(t => t !== tag));
|
||||||
@@ -158,7 +195,8 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</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,10 +328,21 @@ 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 => {
|
||||||
|
const color = tagColors.get(tag);
|
||||||
|
const hasColor = !!color;
|
||||||
|
const style: React.CSSProperties = hasColor
|
||||||
|
? {
|
||||||
|
backgroundColor: color,
|
||||||
|
color: getContrastColor(color!),
|
||||||
|
borderColor: color,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={tag}
|
key={tag}
|
||||||
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''}`}
|
className={`filter-chip ${selectedTags.includes(tag) ? 'active' : ''} ${hasColor ? 'has-color' : ''}`}
|
||||||
|
style={style}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (selectedTags.includes(tag)) {
|
if (selectedTags.includes(tag)) {
|
||||||
onTagSelect(selectedTags.filter(t => t !== tag));
|
onTagSelect(selectedTags.filter(t => t !== tag));
|
||||||
@@ -302,7 +353,8 @@ const MediaFilterPanel: React.FC<MediaFilterPanelProps> = ({
|
|||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
</button>
|
</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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user