fix: add category color sync and remove hash-based fallback
- Add categoryColorsSync service from dev branch - Add missing fetchCategoryColors() method to NextcloudAPI - Remove hash-based color fallback in NotesList (only show badges when color explicitly set) - Initialize categoryColorsSync in App.tsx for server sync - Category colors now sync to .category-colors.json on server
This commit is contained in:
@@ -8,6 +8,7 @@ import { Note } from './types';
|
|||||||
import { syncManager, SyncStatus } from './services/syncManager';
|
import { syncManager, SyncStatus } from './services/syncManager';
|
||||||
import { localDB } from './db/localDB';
|
import { localDB } from './db/localDB';
|
||||||
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
import { useOnlineStatus } from './hooks/useOnlineStatus';
|
||||||
|
import { categoryColorsSync } from './services/categoryColorsSync';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
@@ -69,6 +70,7 @@ function App() {
|
|||||||
});
|
});
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
syncManager.setAPI(apiInstance);
|
syncManager.setAPI(apiInstance);
|
||||||
|
categoryColorsSync.setAPI(apiInstance);
|
||||||
setUsername(savedUsername);
|
setUsername(savedUsername);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ function App() {
|
|||||||
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
const apiInstance = new NextcloudAPI({ serverURL, username, password });
|
||||||
setApi(apiInstance);
|
setApi(apiInstance);
|
||||||
syncManager.setAPI(apiInstance);
|
syncManager.setAPI(apiInstance);
|
||||||
|
categoryColorsSync.setAPI(apiInstance);
|
||||||
setUsername(username);
|
setUsername(username);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
};
|
};
|
||||||
@@ -164,6 +167,7 @@ function App() {
|
|||||||
await localDB.clearSyncQueue();
|
await localDB.clearSyncQueue();
|
||||||
setApi(null);
|
setApi(null);
|
||||||
syncManager.setAPI(null);
|
syncManager.setAPI(null);
|
||||||
|
categoryColorsSync.setAPI(null);
|
||||||
setUsername('');
|
setUsername('');
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
setSelectedNoteId(null);
|
setSelectedNoteId(null);
|
||||||
|
|||||||
@@ -153,6 +153,33 @@ export class NextcloudAPI {
|
|||||||
return `${attachmentDir}/${fileName}`;
|
return `${attachmentDir}/${fileName}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchCategoryColors(): Promise<Record<string, number>> {
|
||||||
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
|
||||||
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await tauriFetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': this.authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
// File doesn't exist yet, return empty object
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to fetch category colors: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch category colors, using empty:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async saveCategoryColors(colors: Record<string, number>): Promise<void> {
|
async saveCategoryColors(colors: Record<string, number>): Promise<void> {
|
||||||
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
|
const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`;
|
||||||
const url = `${this.serverURL}${webdavPath}`;
|
const url = `${this.serverURL}${webdavPath}`;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
|
|
||||||
const EDITOR_FONTS = [
|
const EDITOR_FONTS = [
|
||||||
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
{ name: 'Source Code Pro', value: 'Source Code Pro' },
|
||||||
@@ -75,32 +76,28 @@ export function CategoriesSidebar({
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('');
|
const [newCategoryName, setNewCategoryName] = useState('');
|
||||||
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
|
const [renamingCategory, setRenamingCategory] = useState<string | null>(null);
|
||||||
const [renameCategoryValue, setRenameCategoryValue] = useState('');
|
const [renameCategoryValue, setRenameCategoryValue] = useState('');
|
||||||
const [categoryColors, setCategoryColors] = useState<Record<string, number>>({});
|
const [categoryColors, setCategoryColors] = useState<Record<string, number>>(() => categoryColorsSync.getAllColors());
|
||||||
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
|
const [colorPickerCategory, setColorPickerCategory] = useState<string | null>(null);
|
||||||
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
|
const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Load category colors from localStorage
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('categoryColors');
|
const handleColorChange = () => {
|
||||||
if (saved) {
|
setCategoryColors(categoryColorsSync.getAllColors());
|
||||||
setCategoryColors(JSON.parse(saved));
|
};
|
||||||
}
|
|
||||||
|
categoryColorsSync.setChangeCallback(handleColorChange);
|
||||||
|
window.addEventListener('categoryColorChanged', handleColorChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('categoryColorChanged', handleColorChange);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setCategoryColor = (category: string, colorIndex: number | null) => {
|
const setCategoryColor = async (category: string, colorIndex: number | null) => {
|
||||||
const updated = { ...categoryColors };
|
await categoryColorsSync.setColor(category, colorIndex);
|
||||||
if (colorIndex === null) {
|
|
||||||
delete updated[category];
|
|
||||||
} else {
|
|
||||||
updated[category] = colorIndex;
|
|
||||||
}
|
|
||||||
setCategoryColors(updated);
|
|
||||||
localStorage.setItem('categoryColors', JSON.stringify(updated));
|
|
||||||
setColorPickerCategory(null);
|
setColorPickerCategory(null);
|
||||||
// Dispatch event to notify other components
|
|
||||||
window.dispatchEvent(new Event('categoryColorChanged'));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Note } from '../types';
|
import { Note } from '../types';
|
||||||
import { SyncStatus } from '../services/syncManager';
|
import { SyncStatus } from '../services/syncManager';
|
||||||
|
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||||
|
|
||||||
interface NotesListProps {
|
interface NotesListProps {
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
@@ -47,20 +48,10 @@ export function NotesList({
|
|||||||
|
|
||||||
// Listen for category color changes
|
// Listen for category color changes
|
||||||
React.useEffect(() => {
|
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();
|
const handleCustomEvent = () => forceUpdate();
|
||||||
window.addEventListener('categoryColorChanged', handleCustomEvent);
|
window.addEventListener('categoryColorChanged', handleCustomEvent);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('storage', handleStorageChange);
|
|
||||||
window.removeEventListener('categoryColorChanged', handleCustomEvent);
|
window.removeEventListener('categoryColorChanged', handleCustomEvent);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -156,28 +147,14 @@ export function NotesList({
|
|||||||
{ bg: 'bg-cyan-100 dark:bg-cyan-900/30', text: 'text-cyan-700 dark:text-cyan-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
|
// Only return color if explicitly set by user
|
||||||
const savedColors = localStorage.getItem('categoryColors');
|
const colorIndex = categoryColorsSync.getColor(category);
|
||||||
if (savedColors) {
|
if (colorIndex !== undefined) {
|
||||||
try {
|
return colors[colorIndex];
|
||||||
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
|
// No color set - return null to indicate no badge should be shown
|
||||||
let hash = 2166136261; // FNV offset basis
|
return null;
|
||||||
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];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -319,6 +296,7 @@ export function NotesList({
|
|||||||
<span>{formatDate(note.modified)}</span>
|
<span>{formatDate(note.modified)}</span>
|
||||||
{note.category && (() => {
|
{note.category && (() => {
|
||||||
const colors = getCategoryColor(note.category);
|
const colors = getCategoryColor(note.category);
|
||||||
|
if (!colors) return null;
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
|
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
|
||||||
{note.category}
|
{note.category}
|
||||||
|
|||||||
98
src/services/categoryColorsSync.ts
Normal file
98
src/services/categoryColorsSync.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { NextcloudAPI } from '../api/nextcloud';
|
||||||
|
|
||||||
|
export class CategoryColorsSync {
|
||||||
|
private api: NextcloudAPI | null = null;
|
||||||
|
private colors: Record<string, number> = {};
|
||||||
|
private syncInProgress: boolean = false;
|
||||||
|
private changeCallback: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
setAPI(api: NextcloudAPI | null) {
|
||||||
|
this.api = api;
|
||||||
|
if (api) {
|
||||||
|
this.syncFromServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setChangeCallback(callback: () => void) {
|
||||||
|
this.changeCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadFromLocalStorage() {
|
||||||
|
const saved = localStorage.getItem('categoryColors');
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
this.colors = JSON.parse(saved);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse category colors from localStorage:', e);
|
||||||
|
this.colors = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private saveToLocalStorage() {
|
||||||
|
localStorage.setItem('categoryColors', JSON.stringify(this.colors));
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyChange() {
|
||||||
|
if (this.changeCallback) {
|
||||||
|
this.changeCallback();
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new Event('categoryColorChanged'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFromServer(): Promise<void> {
|
||||||
|
if (!this.api || this.syncInProgress) return;
|
||||||
|
|
||||||
|
this.syncInProgress = true;
|
||||||
|
try {
|
||||||
|
const serverColors = await this.api.fetchCategoryColors();
|
||||||
|
|
||||||
|
// Merge: server wins for conflicts
|
||||||
|
const hasChanges = JSON.stringify(this.colors) !== JSON.stringify(serverColors);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
this.colors = serverColors;
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
this.notifyChange();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync category colors from server:', error);
|
||||||
|
} finally {
|
||||||
|
this.syncInProgress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setColor(category: string, colorIndex: number | null): Promise<void> {
|
||||||
|
if (colorIndex === null) {
|
||||||
|
delete this.colors[category];
|
||||||
|
} else {
|
||||||
|
this.colors[category] = colorIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveToLocalStorage();
|
||||||
|
this.notifyChange();
|
||||||
|
|
||||||
|
// Sync to server if online
|
||||||
|
if (this.api) {
|
||||||
|
try {
|
||||||
|
await this.api.saveCategoryColors(this.colors);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save category colors to server:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getColor(category: string): number | undefined {
|
||||||
|
return this.colors[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllColors(): Record<string, number> {
|
||||||
|
return { ...this.colors };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoryColorsSync = new CategoryColorsSync();
|
||||||
Reference in New Issue
Block a user