feat: add custom category color picker with visual improvements

- Add custom color picker for categories (10 pastel colors)
- Store category colors in localStorage
- Add real-time color updates across components using custom events
- Change folder icons to filled/solid style for better visibility
- Use vibrant darker shades for folder icon colors
- Add 'Remove Color' option to reset category to default
- Add color indicator dots (replaced with filled icons)
- Improve hash distribution using FNV-1a algorithm for auto-assigned colors
- Expand auto-assigned color palette from 10 to 20 colors
This commit is contained in:
drelich
2026-03-23 16:08:26 +01:00
parent 4ef0814ccd
commit 0b13a2df5b
2 changed files with 155 additions and 37 deletions

View File

@@ -37,6 +37,19 @@ interface CategoriesSidebarProps {
onPreviewFontSizeChange: (size: number) => void;
}
const CATEGORY_COLORS = [
{ name: 'Blue', bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300', preview: '#dbeafe', dot: '#3b82f6' },
{ name: 'Green', bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300', preview: '#dcfce7', dot: '#22c55e' },
{ name: 'Purple', bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300', preview: '#f3e8ff', dot: '#a855f7' },
{ name: 'Pink', bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300', preview: '#fce7f3', dot: '#ec4899' },
{ name: 'Yellow', bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300', preview: '#fef9c3', dot: '#eab308' },
{ name: 'Red', bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', preview: '#fee2e2', dot: '#ef4444' },
{ name: 'Orange', bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300', preview: '#ffedd5', dot: '#f97316' },
{ name: 'Teal', bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300', preview: '#ccfbf1', dot: '#14b8a6' },
{ name: 'Indigo', bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300', preview: '#e0e7ff', dot: '#6366f1' },
{ name: 'Cyan', bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300', preview: '#cffafe', dot: '#06b6d4' },
];
export function CategoriesSidebar({
categories,
selectedCategory,
@@ -62,10 +75,34 @@ export function CategoriesSidebar({
const [newCategoryName, setNewCategoryName] = useState('');
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
const [renameCategoryValue, setRenameCategoryValue] = useState('');
const [categoryColors, setCategoryColors] = useState<Record<string, number>>({});
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
const inputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
// Load category colors from localStorage
useEffect(() => {
const saved = localStorage.getItem('categoryColors');
if (saved) {
setCategoryColors(JSON.parse(saved));
}
}, []);
const setCategoryColor = (category: string, colorIndex: number | null) => {
const updated = { ...categoryColors };
if (colorIndex === null) {
delete updated[category];
} else {
updated[category] = colorIndex;
}
setCategoryColors(updated);
localStorage.setItem('categoryColors', JSON.stringify(updated));
setColorPickerCategory(null);
// Dispatch event to notify other components
window.dispatchEvent(new Event('categoryColorChanged'));
};
useEffect(() => {
if (isCreating && inputRef.current) {
inputRef.current.focus();
@@ -198,8 +235,8 @@ export function CategoriesSidebar({
</div>
</div>
) : (
<div key={category} className="relative">
<div
key={category}
className={`group w-full px-3 py-2 rounded-lg transition-colors flex items-center ${
selectedCategory === category
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
@@ -210,17 +247,40 @@ export function CategoriesSidebar({
onClick={() => onSelectCategory(category)}
className="flex items-center flex-1 min-w-0 text-left"
>
<svg className="w-4 h-4 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
{(() => {
const colorIndex = categoryColors[category];
const color = colorIndex !== undefined ? CATEGORY_COLORS[colorIndex] : null;
return (
<svg
className="w-4 h-4 mr-2 flex-shrink-0"
fill={color ? color.dot : "currentColor"}
viewBox="0 0 24 24"
>
<path d="M3 7c0-1.1.9-2 2-2h4l2 2h6c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2V7z" />
</svg>
);
})()}
<span className="text-sm truncate">{category}</span>
</button>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
setColorPickerCategory(colorPickerCategory === category ? null : category);
}}
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
title="Change color"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
startRenaming(category);
}}
className="ml-2 p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-300 dark:hover:bg-gray-600 rounded transition-opacity flex-shrink-0"
className="p-1 hover:bg-gray-300 dark:hover:bg-gray-600 rounded flex-shrink-0"
title="Rename category"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -228,6 +288,29 @@ export function CategoriesSidebar({
</svg>
</button>
</div>
</div>
{colorPickerCategory === category && (
<div className="absolute left-0 right-0 top-full mt-1 bg-white dark:bg-gray-700 rounded-lg shadow-lg border border-gray-200 dark:border-gray-600 p-2 z-10">
<div className="grid grid-cols-5 gap-1.5 mb-2">
{CATEGORY_COLORS.map((color, idx) => (
<button
key={idx}
onClick={() => setCategoryColor(category, idx)}
className="w-7 h-7 rounded hover:scale-110 transition-transform border-2 border-gray-300 dark:border-gray-600"
style={{ backgroundColor: color.preview }}
title={color.name}
/>
))}
</div>
<button
onClick={() => setCategoryColor(category, null)}
className="w-full text-xs py-1.5 px-2 bg-gray-100 dark:bg-gray-600 hover:bg-gray-200 dark:hover:bg-gray-500 rounded text-gray-700 dark:text-gray-200 transition-colors"
>
Remove Color
</button>
</div>
)}
</div>
)
))}

View File

@@ -42,8 +42,29 @@ export function NotesList({
return saved ? parseInt(saved, 10) : 320;
});
const [isResizing, setIsResizing] = React.useState(false);
const [, forceUpdate] = React.useReducer(x => x + 1, 0);
const containerRef = React.useRef<HTMLDivElement>(null);
// Listen for category color changes
React.useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'categoryColors') {
forceUpdate();
}
};
window.addEventListener('storage', handleStorageChange);
// Also listen for changes in the same tab
const handleCustomEvent = () => forceUpdate();
window.addEventListener('categoryColorChanged', handleCustomEvent);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('categoryColorChanged', handleCustomEvent);
};
}, []);
const handleSync = async () => {
setIsSyncing(true);
await onSync();
@@ -121,26 +142,40 @@ export function NotesList({
};
const getCategoryColor = (category: string) => {
// Generate consistent pastel color based on category name
let hash = 0;
for (let i = 0; i < category.length; i++) {
hash = category.charCodeAt(i) + ((hash << 5) - hash);
}
// Pastel color palette (light, subtle tones)
// Color palette matching CategoriesSidebar
const colors = [
{ bg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300' },
{ bg: 'bg-green-100 dark:bg-green-900/30', text: 'text-green-700 dark:text-green-300' },
{ bg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300' },
{ bg: 'bg-pink-100 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300' },
{ bg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-300' },
{ bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300' },
{ bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300' },
{ bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300' },
{ bg: 'bg-orange-100 dark:bg-orange-900/30', text: 'text-orange-700 dark:text-orange-300' },
{ bg: 'bg-teal-100 dark:bg-teal-900/30', text: 'text-teal-700 dark:text-teal-300' },
{ bg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-700 dark:text-indigo-300' },
{ bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-300' },
];
// Check for custom color in localStorage first
const savedColors = localStorage.getItem('categoryColors');
if (savedColors) {
try {
const customColors = JSON.parse(savedColors);
if (customColors[category] !== undefined) {
return colors[customColors[category]];
}
} catch (e) {
// Fall through to hash-based color
}
}
// Fall back to hash-based color assignment
let hash = 2166136261; // FNV offset basis
for (let i = 0; i < category.length; i++) {
hash ^= category.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};