From 486579809f6ef555ac6676a7282a326e4fce99da Mon Sep 17 00:00:00 2001 From: drelich Date: Wed, 25 Mar 2026 15:45:53 +0100 Subject: [PATCH] feat: add category colors sync to Nextcloud server - Add categoryColorsSync service to sync colors to server - Store category colors in .category-colors.json file in Notes directory - Add fetchCategoryColors() and saveCategoryColors() methods to NextcloudAPI - Initialize categoryColorsSync with API instance on login/logout - Remove automatic hash-based color assignment for categories - Only show category badges when colors are explicitly set by user - Simplify color change event handling using category --- src/App.tsx | 4 ++ src/api/nextcloud.ts | 47 +++++++++++++ src/components/CategoriesSidebar.tsx | 31 ++++----- src/components/NotesList.tsx | 38 +++-------- src/services/categoryColorsSync.ts | 98 ++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 src/services/categoryColorsSync.ts diff --git a/src/App.tsx b/src/App.tsx index f6430a2..b2d377b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { Note } from './types'; import { syncManager, SyncStatus } from './services/syncManager'; import { localDB } from './db/localDB'; import { useOnlineStatus } from './hooks/useOnlineStatus'; +import { categoryColorsSync } from './services/categoryColorsSync'; function App() { const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -69,6 +70,7 @@ function App() { }); setApi(apiInstance); syncManager.setAPI(apiInstance); + categoryColorsSync.setAPI(apiInstance); setUsername(savedUsername); setIsLoggedIn(true); @@ -152,6 +154,7 @@ function App() { const apiInstance = new NextcloudAPI({ serverURL, username, password }); setApi(apiInstance); syncManager.setAPI(apiInstance); + categoryColorsSync.setAPI(apiInstance); setUsername(username); setIsLoggedIn(true); }; @@ -164,6 +167,7 @@ function App() { await localDB.clearSyncQueue(); setApi(null); syncManager.setAPI(null); + categoryColorsSync.setAPI(null); setUsername(''); setNotes([]); setSelectedNoteId(null); diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index 8243b4b..759d533 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -152,4 +152,51 @@ export class NextcloudAPI { // Return the relative path for markdown return `${attachmentDir}/${fileName}`; } + + async fetchCategoryColors(): Promise> { + 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): Promise { + const webdavPath = `/remote.php/dav/files/${this.username}/Notes/.category-colors.json`; + const url = `${this.serverURL}${webdavPath}`; + + const content = JSON.stringify(colors, null, 2); + + const response = await tauriFetch(url, { + method: 'PUT', + headers: { + 'Authorization': this.authHeader, + 'Content-Type': 'application/json', + }, + body: content, + }); + + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`Failed to save category colors: ${response.status}`); + } + } } diff --git a/src/components/CategoriesSidebar.tsx b/src/components/CategoriesSidebar.tsx index a9a82fa..a44d9df 100644 --- a/src/components/CategoriesSidebar.tsx +++ b/src/components/CategoriesSidebar.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { categoryColorsSync } from '../services/categoryColorsSync'; const EDITOR_FONTS = [ { name: 'Source Code Pro', value: 'Source Code Pro' }, @@ -75,32 +76,28 @@ export function CategoriesSidebar({ const [newCategoryName, setNewCategoryName] = useState(''); const [renamingCategory, setRenamingCategory] = useState(null); const [renameCategoryValue, setRenameCategoryValue] = useState(''); - const [categoryColors, setCategoryColors] = useState>({}); + const [categoryColors, setCategoryColors] = useState>(() => categoryColorsSync.getAllColors()); const [colorPickerCategory, setColorPickerCategory] = useState(null); const [isSettingsCollapsed, setIsSettingsCollapsed] = useState(true); const inputRef = useRef(null); const renameInputRef = useRef(null); - // Load category colors from localStorage useEffect(() => { - const saved = localStorage.getItem('categoryColors'); - if (saved) { - setCategoryColors(JSON.parse(saved)); - } + const handleColorChange = () => { + setCategoryColors(categoryColorsSync.getAllColors()); + }; + + categoryColorsSync.setChangeCallback(handleColorChange); + window.addEventListener('categoryColorChanged', handleColorChange); + + return () => { + window.removeEventListener('categoryColorChanged', handleColorChange); + }; }, []); - 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)); + const setCategoryColor = async (category: string, colorIndex: number | null) => { + await categoryColorsSync.setColor(category, colorIndex); setColorPickerCategory(null); - // Dispatch event to notify other components - window.dispatchEvent(new Event('categoryColorChanged')); }; useEffect(() => { diff --git a/src/components/NotesList.tsx b/src/components/NotesList.tsx index 0533bfd..2bc1af5 100644 --- a/src/components/NotesList.tsx +++ b/src/components/NotesList.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Note } from '../types'; +import { categoryColorsSync } from '../services/categoryColorsSync'; import { SyncStatus } from '../services/syncManager'; interface NotesListProps { @@ -47,20 +48,10 @@ export function NotesList({ // 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); }; }, []); @@ -156,28 +147,14 @@ export function NotesList({ { 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 - } + // Only return color if explicitly set by user + const colorIndex = categoryColorsSync.getColor(category); + if (colorIndex !== undefined) { + return colors[colorIndex]; } - // 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]; + // No color set - return null to indicate no badge should be shown + return null; }; return ( @@ -319,6 +296,7 @@ export function NotesList({ {formatDate(note.modified)} {note.category && (() => { const colors = getCategoryColor(note.category); + if (!colors) return null; return ( {note.category} diff --git a/src/services/categoryColorsSync.ts b/src/services/categoryColorsSync.ts new file mode 100644 index 0000000..e569bfb --- /dev/null +++ b/src/services/categoryColorsSync.ts @@ -0,0 +1,98 @@ +import { NextcloudAPI } from '../api/nextcloud'; + +export class CategoryColorsSync { + private api: NextcloudAPI | null = null; + private colors: Record = {}; + 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 { + 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 { + 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 { + return { ...this.colors }; + } +} + +export const categoryColorsSync = new CategoryColorsSync();