feat: WebDAV file access and category color sync (v0.2.0)
Major Changes: - Switch from Nextcloud Notes API to direct WebDAV file access - Notes stored as .txt files with filename-based IDs for reliability - Implement safer sync strategy without clearNotes() to prevent data loss - Add ETag-based conflict detection for concurrent edits - Add category color sync to .category-colors.json on server - Show neutral gray badges for categories without assigned colors Technical Improvements: - Replace numeric IDs with filename-based string IDs - Update Note type to support both number and string IDs - Implement WebDAV methods: fetchNotesWebDAV, createNoteWebDAV, updateNoteWebDAV, deleteNoteWebDAV - Add CategoryColorsSync service for server synchronization - Remove hash-based color fallback (only show colors when explicitly set) Bug Fixes: - Fix category badge rendering to show all categories - Prevent note loss during sync operations - Improve offline-first functionality with better merge strategy
This commit is contained in:
@@ -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<string | null>(null);
|
||||
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 [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 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(() => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, i
|
||||
const [processedContent, setProcessedContent] = useState('');
|
||||
const [isLoadingImages, setIsLoadingImages] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const previousNoteIdRef = useRef<number | null>(null);
|
||||
const previousNoteIdRef = useRef<number | string | null>(null);
|
||||
const previousNoteContentRef = useRef<string>('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Note } from '../types';
|
||||
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||
import { SyncStatus } from '../services/syncManager';
|
||||
import { categoryColorsSync } from '../services/categoryColorsSync';
|
||||
|
||||
interface NotesListProps {
|
||||
notes: Note[];
|
||||
selectedNoteId: number | null;
|
||||
onSelectNote: (id: number) => void;
|
||||
selectedNoteId: number | string | null;
|
||||
onSelectNote: (id: number | string) => void;
|
||||
onCreateNote: () => void;
|
||||
onDeleteNote: (note: Note) => void;
|
||||
onSync: () => void;
|
||||
@@ -36,7 +38,7 @@ export function NotesList({
|
||||
isOnline,
|
||||
}: NotesListProps) {
|
||||
const [isSyncing, setIsSyncing] = React.useState(false);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | null>(null);
|
||||
const [deleteClickedId, setDeleteClickedId] = React.useState<number | string | null>(null);
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
const saved = localStorage.getItem('notesListWidth');
|
||||
return saved ? parseInt(saved, 10) : 320;
|
||||
@@ -47,20 +49,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 +148,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,8 +297,16 @@ export function NotesList({
|
||||
<span>{formatDate(note.modified)}</span>
|
||||
{note.category && (() => {
|
||||
const colors = getCategoryColor(note.category);
|
||||
if (colors) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 ${colors.bg} ${colors.text} rounded-full text-xs font-medium`}>
|
||||
{note.category}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Show neutral badge when no color is set
|
||||
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 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded-full text-xs font-medium">
|
||||
{note.category}
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user