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
This commit is contained in:
drelich
2026-03-25 15:45:53 +01:00
parent f8b3cc8a9d
commit 486579809f
5 changed files with 171 additions and 47 deletions

View File

@@ -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);

View File

@@ -152,4 +152,51 @@ export class NextcloudAPI {
// Return the relative path for markdown // Return the relative path for markdown
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> {
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}`);
}
}
} }

View File

@@ -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(() => {

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Note } from '../types'; import { Note } from '../types';
import { categoryColorsSync } from '../services/categoryColorsSync';
import { SyncStatus } from '../services/syncManager'; import { SyncStatus } from '../services/syncManager';
interface NotesListProps { interface NotesListProps {
@@ -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}

View 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();