426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
'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,
|
|
} from 'lucide-react';
|
|
import { createFolder, updateFolder, deleteFolder } from '@/actions/folders';
|
|
import { extractEmojis } from '@/lib/task-emoji';
|
|
|
|
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 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" />
|
|
)}
|
|
|
|
{folder.tagId &&
|
|
(() => {
|
|
const tag = availableTags.find((t) => t.id === folder.tagId);
|
|
if (tag) {
|
|
const emojis = extractEmojis(tag.name);
|
|
if (emojis.length > 0) {
|
|
return (
|
|
<span
|
|
className="text-base mr-1"
|
|
style={{
|
|
fontFamily:
|
|
'system-ui, -apple-system, "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"',
|
|
}}
|
|
>
|
|
{emojis[0]}
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
return null;
|
|
})()}
|
|
<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>
|
|
);
|
|
}
|