feat(Notes): add folder management to notes, allowing notes to be categorized into folders, and update related components for folder selection and display

This commit is contained in:
Julien Froidefond
2026-01-06 09:05:27 +01:00
parent 7ce8057c6b
commit 6c4c6992a9
15 changed files with 1314 additions and 21 deletions

View File

@@ -0,0 +1,405 @@
'use client';
import { useState, useTransition } from 'react';
import { Folder } from '@/services/folders';
import { Tag } from '@/lib/types';
import {
Folder as FolderIcon,
FolderOpen,
Plus,
Edit2,
Trash2,
ChevronRight,
ChevronDown,
Tag as TagIcon,
} from 'lucide-react';
import { createFolder, updateFolder, deleteFolder } from '@/actions/folders';
interface FoldersSidebarProps {
folders: Folder[];
selectedFolderId?: string;
onSelectFolder: (folderId: string | null) => void;
onFoldersChange: () => void;
availableTags: Tag[];
onNoteDrop?: (noteId: string, folderId: string | null) => void;
}
interface FolderItemProps {
folder: Folder;
level: number;
isSelected: boolean;
onSelect: (folderId: string) => void;
onEdit: (folder: Folder) => void;
onDelete: (folderId: string) => void;
availableTags: Tag[];
onNoteDrop?: (noteId: string, folderId: string) => void;
}
function FolderItem({
folder,
level,
isSelected,
onSelect,
onEdit,
onDelete,
availableTags,
onNoteDrop,
}: FolderItemProps) {
const [isExpanded, setIsExpanded] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
const hasChildren = folder.children && folder.children.length > 0;
const tag = availableTags.find((t) => t.id === folder.tagId);
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation();
onEdit(folder);
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete(folder.id);
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const noteId = e.dataTransfer.getData('noteId');
if (noteId && onNoteDrop) {
onNoteDrop(noteId, folder.id);
}
};
return (
<div>
<div
className={`group flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 ${
isSelected
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-l-2 border-[var(--primary)]'
: isDragOver
? 'bg-[var(--primary)]/20 border-l-2 border-[var(--primary)] border-dashed'
: 'hover:bg-[var(--card)]/60 text-[var(--foreground)]'
}`}
style={{ paddingLeft: `${level * 16 + 12}px` }}
onClick={() => onSelect(folder.id)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
className="p-0.5 hover:bg-[var(--card)]/80 rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
{isExpanded ? (
<FolderOpen className="w-4 h-4 flex-shrink-0" />
) : (
<FolderIcon className="w-4 h-4 flex-shrink-0" />
)}
<span className="flex-1 text-sm truncate">{folder.name}</span>
{folder.notesCount !== undefined && folder.notesCount > 0 && (
<span className="text-xs text-[var(--muted-foreground)]">
{folder.notesCount}
</span>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
<button
onClick={handleEdit}
className="p-1 hover:bg-[var(--primary)]/10 text-[var(--muted-foreground)] hover:text-[var(--primary)] rounded transition-colors"
title="Renommer"
>
<Edit2 className="w-3 h-3" />
</button>
<button
onClick={handleDelete}
className="p-1 hover:bg-[var(--destructive)]/10 text-[var(--muted-foreground)] hover:text-[var(--destructive)] rounded transition-colors"
title="Supprimer"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{hasChildren && isExpanded && (
<div>
{folder.children!.map((child) => (
<FolderItem
key={child.id}
folder={child}
level={level + 1}
isSelected={isSelected}
onSelect={onSelect}
onEdit={onEdit}
onDelete={onDelete}
availableTags={availableTags}
onNoteDrop={onNoteDrop}
/>
))}
</div>
)}
</div>
);
}
export function FoldersSidebar({
folders,
selectedFolderId,
onSelectFolder,
onFoldersChange,
availableTags,
onNoteDrop,
}: FoldersSidebarProps) {
const [isPending, startTransition] = useTransition();
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
const [folderName, setFolderName] = useState('');
const [selectedTagId, setSelectedTagId] = useState<string>('');
const [isDragOverAll, setIsDragOverAll] = useState(false);
const handleCreateFolder = () => {
setFolderName('');
setSelectedTagId('');
setShowCreateDialog(true);
};
const handleEditFolder = (folder: Folder) => {
setEditingFolder(folder);
setFolderName(folder.name);
setSelectedTagId(folder.tagId || '');
setShowCreateDialog(true);
};
const handleSaveFolder = () => {
if (!folderName.trim()) return;
startTransition(async () => {
if (editingFolder) {
// Update existing folder
const result = await updateFolder(editingFolder.id, {
name: folderName,
tagId: selectedTagId || undefined,
});
if (result.success) {
onFoldersChange();
setShowCreateDialog(false);
setEditingFolder(null);
}
} else {
// Create new folder
const result = await createFolder({
name: folderName,
tagId: selectedTagId || undefined,
});
if (result.success) {
onFoldersChange();
setShowCreateDialog(false);
}
}
});
};
const handleDeleteFolder = (folderId: string) => {
if (!confirm('Êtes-vous sûr de vouloir supprimer ce dossier ?')) return;
startTransition(async () => {
const result = await deleteFolder(folderId);
if (result.success) {
if (selectedFolderId === folderId) {
onSelectFolder(null);
}
onFoldersChange();
}
});
};
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2">
<h3 className="text-xs font-mono font-semibold text-[var(--muted-foreground)] uppercase tracking-wider">
Dossiers
</h3>
<button
onClick={handleCreateFolder}
disabled={isPending}
className="p-1 rounded-lg bg-[var(--primary)]/10 hover:bg-[var(--primary)]/20 text-[var(--primary)] transition-colors disabled:opacity-50"
title="Nouveau dossier"
>
<Plus className="w-3 h-3" />
</button>
</div>
{/* Folders List */}
<div className="overflow-y-auto overflow-x-visible px-2 pb-2 max-h-[40vh]">
{/* All Notes */}
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-1 ${
selectedFolderId === null
? 'bg-[var(--primary)]/10 text-[var(--primary)] border-l-2 border-[var(--primary)]'
: 'hover:bg-[var(--card)]/60 text-[var(--foreground)]'
}`}
onClick={() => onSelectFolder(null)}
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-sm">Toutes les notes</span>
</div>
{/* Uncategorized Notes */}
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-2 ${
selectedFolderId === '__uncategorized__'
? 'bg-[var(--muted)]/10 text-[var(--muted-foreground)] border-l-2 border-[var(--muted)]'
: isDragOverAll
? 'bg-[var(--primary)]/20 border-l-2 border-[var(--primary)] border-dashed'
: 'hover:bg-[var(--card)]/60 text-[var(--muted-foreground)]'
}`}
onClick={() => onSelectFolder('__uncategorized__')}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverAll(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverAll(false);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverAll(false);
const noteId = e.dataTransfer.getData('noteId');
if (noteId && onNoteDrop) {
onNoteDrop(noteId, null); // null = retirer du dossier
}
}}
>
<FolderIcon className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 text-sm">Notes non classées</span>
</div>
{/* Folders Tree */}
{folders.map((folder) => (
<FolderItem
key={folder.id}
folder={folder}
level={0}
isSelected={selectedFolderId === folder.id}
onSelect={onSelectFolder}
onEdit={handleEditFolder}
onDelete={handleDeleteFolder}
availableTags={availableTags}
onNoteDrop={onNoteDrop}
/>
))}
</div>
{/* Create/Edit Dialog */}
{showCreateDialog && (
<>
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
onClick={() => {
setShowCreateDialog(false);
setEditingFolder(null);
}}
/>
<div className="fixed inset-x-0 top-0 flex justify-center z-50 p-4 pt-20">
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-2xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingFolder ? 'Modifier le dossier' : 'Nouveau dossier'}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
Nom du dossier
</label>
<input
type="text"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder="Ex: Projets, Idées..."
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)] rounded-lg text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
Tag associé (optionnel)
</label>
<select
value={selectedTagId}
onChange={(e) => setSelectedTagId(e.target.value)}
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)] rounded-lg text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"
>
<option value="">Aucun tag</option>
{availableTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => {
setShowCreateDialog(false);
setEditingFolder(null);
}}
className="flex-1 px-4 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)] hover:bg-[var(--card-hover)] transition-colors"
>
Annuler
</button>
<button
onClick={handleSaveFolder}
disabled={!folderName.trim() || isPending}
className="flex-1 px-4 py-2 bg-[var(--primary)] text-white rounded-lg hover:bg-[var(--primary)]/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isPending
? 'Enregistrement...'
: editingFolder
? 'Modifier'
: 'Créer'}
</button>
</div>
</div>
</div>
</>
)}
</div>
);
}