571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { Note } from '@/services/notes';
|
|
import { Folder } from '@/services/folders';
|
|
import { Task } from '@/lib/types';
|
|
import { notesClient } from '@/clients/notes';
|
|
import { NotesList } from '@/components/notes/NotesList';
|
|
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
|
import { FoldersSidebar } from '@/components/notes/FoldersSidebar';
|
|
import { Header } from '@/components/ui/Header';
|
|
import { Card } from '@/components/ui';
|
|
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
|
import { Tag } from '@/lib/types';
|
|
import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
|
import { getFolders } from '@/actions/folders';
|
|
import { reorderNotes } from '@/actions/notes';
|
|
|
|
interface NotesPageClientProps {
|
|
initialNotes: Note[];
|
|
initialTags: (Tag & { usage: number })[];
|
|
initialFolders: Folder[];
|
|
}
|
|
|
|
function NotesPageContent({
|
|
initialNotes,
|
|
initialFolders,
|
|
}: {
|
|
initialNotes: Note[];
|
|
initialFolders: Folder[];
|
|
}) {
|
|
const [notes, setNotes] = useState<Note[]>(initialNotes);
|
|
const [folders, setFolders] = useState<Folder[]>(initialFolders);
|
|
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
|
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
|
const [isNewNote, setIsNewNote] = useState(false);
|
|
const { tags: availableTags } = useTasksContext();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const glassSurface =
|
|
'bg-[var(--card)]/40 backdrop-blur-md relative before:absolute before:inset-0 before:bg-gradient-to-r before:from-[color-mix(in_srgb,var(--primary)_8%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_4%,transparent)] before:to-transparent before:opacity-80 before:pointer-events-none';
|
|
const glassDivider = `${glassSurface} border-b border-[var(--border)]/60`;
|
|
|
|
// Select first note if none selected
|
|
useEffect(() => {
|
|
if (notes.length > 0 && !selectedNote) {
|
|
setSelectedNote(notes[0]);
|
|
}
|
|
}, [notes, selectedNote]);
|
|
|
|
const handleSelectNote = useCallback(
|
|
(note: Note) => {
|
|
// Check for unsaved changes before switching
|
|
if (hasUnsavedChanges && selectedNote) {
|
|
const shouldSwitch = window.confirm(
|
|
'Vous avez des modifications non sauvegardées. Voulez-vous continuer sans sauvegarder ?'
|
|
);
|
|
if (!shouldSwitch) return;
|
|
}
|
|
|
|
setSelectedNote(note);
|
|
setIsNewNote(false);
|
|
setHasUnsavedChanges(false);
|
|
},
|
|
[hasUnsavedChanges, selectedNote]
|
|
);
|
|
|
|
const handleCreateNote = useCallback(async () => {
|
|
try {
|
|
// Déterminer le folderId : null si "Toutes les notes" ou "Non classées", sinon le dossier actif
|
|
const targetFolderId =
|
|
!selectedFolderId || selectedFolderId === '__uncategorized__'
|
|
? undefined
|
|
: selectedFolderId;
|
|
|
|
console.log(
|
|
'[handleCreateNote] selectedFolderId:',
|
|
selectedFolderId,
|
|
'targetFolderId:',
|
|
targetFolderId
|
|
);
|
|
|
|
const newNote = await notesClient.createNote({
|
|
title: 'Nouvelle note',
|
|
content: '# Nouvelle note\n\nCommencez à écrire...',
|
|
folderId: targetFolderId,
|
|
});
|
|
|
|
setNotes((prev) => [newNote, ...prev]);
|
|
setSelectedNote(newNote);
|
|
setIsNewNote(true);
|
|
setHasUnsavedChanges(false);
|
|
} catch (err) {
|
|
setError('Erreur lors de la création de la note');
|
|
console.error('Error creating note:', err);
|
|
}
|
|
}, [selectedFolderId]);
|
|
|
|
const handleDeleteNote = useCallback(
|
|
async (noteId: string) => {
|
|
try {
|
|
await notesClient.deleteNote(noteId);
|
|
setNotes((prev) => prev.filter((note) => note.id !== noteId));
|
|
|
|
// If deleted note was selected, select another one
|
|
if (selectedNote?.id === noteId) {
|
|
const remainingNotes = notes.filter((note) => note.id !== noteId);
|
|
setSelectedNote(remainingNotes.length > 0 ? remainingNotes[0] : null);
|
|
}
|
|
} catch (err) {
|
|
setError('Erreur lors de la suppression de la note');
|
|
console.error('Error deleting note:', err);
|
|
}
|
|
},
|
|
[selectedNote, notes]
|
|
);
|
|
|
|
const reloadNotes = useCallback(async () => {
|
|
try {
|
|
const updatedNotes = await notesClient.getNotes();
|
|
setNotes(updatedNotes);
|
|
} catch (err) {
|
|
console.error('Error reloading notes:', err);
|
|
}
|
|
}, []);
|
|
|
|
const handleReorderNotes = useCallback(
|
|
async (noteOrders: Array<{ id: string; order: number }>) => {
|
|
try {
|
|
// Optimistic update
|
|
const notesMap = new Map(notes.map((n) => [n.id, n]));
|
|
const reorderedNotes = noteOrders
|
|
.map((no) => notesMap.get(no.id))
|
|
.filter((n): n is Note => n !== undefined);
|
|
setNotes(reorderedNotes);
|
|
|
|
// Server update
|
|
const result = await reorderNotes(noteOrders);
|
|
if (!result.success) {
|
|
throw new Error(result.error);
|
|
}
|
|
} catch (err) {
|
|
setError('Erreur lors du réordonnancement des notes');
|
|
console.error('Error reordering notes:', err);
|
|
// Reload notes on error
|
|
reloadNotes();
|
|
}
|
|
},
|
|
[notes, reloadNotes]
|
|
);
|
|
|
|
const getNoteTitle = (content: string): string => {
|
|
// Extract title from first line, removing markdown headers
|
|
const firstLine = content.split('\n')[0] || '';
|
|
const title = firstLine
|
|
.replace(/^#{1,6}\s+/, '') // Remove markdown headers
|
|
.replace(/\*\*(.*?)\*\*/g, '$1') // Remove bold
|
|
.replace(/\*(.*?)\*/g, '$1') // Remove italic
|
|
.replace(/`(.*?)`/g, '$1') // Remove inline code
|
|
.trim();
|
|
|
|
return title || 'Note sans titre';
|
|
};
|
|
|
|
const handleContentChange = useCallback(
|
|
(content: string) => {
|
|
if (selectedNote) {
|
|
setSelectedNote((prev) => (prev ? { ...prev, content } : null));
|
|
setHasUnsavedChanges(true);
|
|
}
|
|
},
|
|
[selectedNote]
|
|
);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!selectedNote) return;
|
|
|
|
try {
|
|
// setIsSaving(true);
|
|
setError(null);
|
|
|
|
// Construire l'objet de mise à jour en incluant explicitement les champs
|
|
const updateData: {
|
|
content: string;
|
|
tags?: string[];
|
|
taskId?: string;
|
|
folderId?: string | null;
|
|
} = {
|
|
content: selectedNote.content,
|
|
tags: selectedNote.tags,
|
|
};
|
|
|
|
// Ajouter taskId et folderId seulement s'ils sont définis
|
|
if (selectedNote.taskId !== undefined) {
|
|
updateData.taskId = selectedNote.taskId;
|
|
}
|
|
if (selectedNote.folderId !== undefined) {
|
|
updateData.folderId = selectedNote.folderId;
|
|
}
|
|
|
|
const updatedNote = await notesClient.updateNote(
|
|
selectedNote.id,
|
|
updateData
|
|
);
|
|
|
|
// Mettre à jour la liste des notes mais pas selectedNote pour éviter la perte de focus
|
|
setNotes((prev) =>
|
|
prev.map((note) => (note.id === selectedNote.id ? updatedNote : note))
|
|
);
|
|
|
|
setHasUnsavedChanges(false);
|
|
setIsNewNote(false);
|
|
} catch (err) {
|
|
setError('Erreur lors de la sauvegarde');
|
|
console.error('Error saving note:', err);
|
|
} finally {
|
|
// setIsSaving(false);
|
|
}
|
|
}, [selectedNote]);
|
|
|
|
const handleTagsChange = useCallback(
|
|
(tags: string[]) => {
|
|
if (!selectedNote) return;
|
|
|
|
setSelectedNote((prev) => (prev ? { ...prev, tags } : null));
|
|
setHasUnsavedChanges(true);
|
|
},
|
|
[selectedNote]
|
|
);
|
|
|
|
const handleTaskChange = useCallback(
|
|
(task: Task | null) => {
|
|
if (!selectedNote) return;
|
|
|
|
setSelectedNote((prev) =>
|
|
prev ? { ...prev, taskId: task?.id, task } : null
|
|
);
|
|
setHasUnsavedChanges(true);
|
|
},
|
|
[selectedNote]
|
|
);
|
|
|
|
const handleFolderChange = useCallback(
|
|
async (folderId: string | null) => {
|
|
if (!selectedNote) return;
|
|
|
|
try {
|
|
// Sauvegarder immédiatement
|
|
const updateData: { folderId: string | null } = {
|
|
folderId: folderId,
|
|
};
|
|
const updatedNote = await notesClient.updateNote(
|
|
selectedNote.id,
|
|
updateData
|
|
);
|
|
|
|
// Mettre à jour la liste des notes
|
|
setNotes((prev) =>
|
|
prev.map((note) => (note.id === selectedNote.id ? updatedNote : note))
|
|
);
|
|
|
|
// Mettre à jour la note sélectionnée
|
|
setSelectedNote(updatedNote);
|
|
} catch (err) {
|
|
console.error('Error changing folder:', err);
|
|
setError('Erreur lors du changement de dossier');
|
|
}
|
|
},
|
|
[selectedNote]
|
|
);
|
|
|
|
// Auto-save quand les tags changent
|
|
useEffect(() => {
|
|
if (hasUnsavedChanges && selectedNote) {
|
|
const timeoutId = setTimeout(() => {
|
|
handleSave();
|
|
}, 1000); // Sauvegarder après 1 seconde d'inactivité
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}
|
|
}, [selectedNote, hasUnsavedChanges, handleSave]);
|
|
|
|
// Filtrer les notes par dossier
|
|
const filteredNotes =
|
|
selectedFolderId === '__uncategorized__'
|
|
? notes.filter((note) => !note.folderId) // Notes sans dossier
|
|
: selectedFolderId
|
|
? notes.filter((note) => note.folderId === selectedFolderId)
|
|
: notes; // Toutes les notes
|
|
|
|
// Gérer le changement de dossier
|
|
const handleSelectFolder = useCallback(
|
|
(folderId: string | null) => {
|
|
setSelectedFolderId(folderId);
|
|
|
|
// Sélectionner automatiquement la première note du dossier
|
|
const folderNotes =
|
|
folderId === '__uncategorized__'
|
|
? notes.filter((note) => !note.folderId)
|
|
: folderId
|
|
? notes.filter((note) => note.folderId === folderId)
|
|
: notes;
|
|
|
|
if (folderNotes.length > 0) {
|
|
setSelectedNote(folderNotes[0]);
|
|
} else {
|
|
setSelectedNote(null);
|
|
}
|
|
},
|
|
[notes]
|
|
);
|
|
|
|
// Recharger les dossiers
|
|
const handleFoldersChange = useCallback(async () => {
|
|
try {
|
|
const result = await getFolders();
|
|
if (result.success && result.data) {
|
|
setFolders(result.data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error fetching folders:', err);
|
|
}
|
|
}, []);
|
|
|
|
// Gérer le drop d'une note sur un dossier
|
|
const handleNoteDrop = useCallback(
|
|
async (noteId: string, folderId: string | null) => {
|
|
try {
|
|
const updateData: { folderId: string | null } = {
|
|
folderId: folderId,
|
|
};
|
|
const updatedNote = await notesClient.updateNote(noteId, updateData);
|
|
|
|
// Mettre à jour la liste des notes
|
|
setNotes((prev) =>
|
|
prev.map((note) => (note.id === noteId ? updatedNote : note))
|
|
);
|
|
|
|
// Si la note sélectionnée est celle qu'on déplace, la mettre à jour aussi
|
|
if (selectedNote?.id === noteId) {
|
|
setSelectedNote(updatedNote);
|
|
}
|
|
|
|
// Recharger les dossiers pour mettre à jour les compteurs
|
|
handleFoldersChange();
|
|
} catch (err) {
|
|
console.error('Error moving note:', err);
|
|
setError('Erreur lors du déplacement de la note');
|
|
}
|
|
},
|
|
[selectedNote, handleFoldersChange]
|
|
);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[var(--background)] flex flex-col">
|
|
<Header title="Notes" subtitle="Gestionnaire de notes markdown" />
|
|
|
|
<div className="flex-1 container mx-auto px-4 py-6">
|
|
<Card
|
|
variant="glass"
|
|
className="flex h-full rounded-2xl overflow-hidden"
|
|
>
|
|
{/* Combined Sidebar: Folders + Notes List */}
|
|
<div
|
|
className={`${sidebarCollapsed ? 'w-12' : 'w-80'} flex-shrink-0 border-r border-[var(--border)] transition-all duration-300 ease-in-out flex flex-col`}
|
|
>
|
|
{sidebarCollapsed ? (
|
|
<div className="h-full flex flex-col items-center py-4">
|
|
<button
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
className="p-2 rounded-lg bg-[var(--card)]/60 hover:bg-[var(--card)]/80 backdrop-blur-sm text-[var(--foreground)] border border-[var(--border)]/40 hover:border-[var(--border)]/60 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
<div className="mt-4 text-xs text-[var(--muted-foreground)] font-mono writing-mode-vertical-rl text-orientation-mixed">
|
|
NOTES
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Header */}
|
|
<div
|
|
className={`${glassDivider} flex items-center justify-between p-3`}
|
|
>
|
|
<h2 className="text-sm font-mono font-semibold text-[var(--foreground)] uppercase tracking-wider">
|
|
Notes
|
|
</h2>
|
|
<button
|
|
onClick={() => setSidebarCollapsed(true)}
|
|
className="p-1 rounded-lg bg-[var(--card)]/60 hover:bg-[var(--card)]/80 backdrop-blur-sm text-[var(--foreground)] border border-[var(--border)]/40 hover:border-[var(--border)]/60 transition-all duration-200 shadow-sm hover:shadow-md"
|
|
>
|
|
<ChevronLeft className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Folders Section */}
|
|
<div className="flex-shrink-0 border-b border-[var(--border)]">
|
|
<FoldersSidebar
|
|
folders={folders}
|
|
selectedFolderId={selectedFolderId || undefined}
|
|
onSelectFolder={handleSelectFolder}
|
|
onFoldersChange={handleFoldersChange}
|
|
availableTags={availableTags}
|
|
onNoteDrop={handleNoteDrop}
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes List */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<NotesList
|
|
notes={filteredNotes}
|
|
onSelectNote={handleSelectNote}
|
|
onCreateNote={handleCreateNote}
|
|
onDeleteNote={handleDeleteNote}
|
|
onReorderNotes={handleReorderNotes}
|
|
selectedNoteId={selectedNote?.id}
|
|
isLoading={false}
|
|
availableTags={availableTags}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Editor Area */}
|
|
<div className="flex-1 flex flex-col min-h-0">
|
|
{error && (
|
|
<div className="flex items-center gap-2 p-3 bg-[var(--destructive)]/10 border-b border-[var(--destructive)]/20 text-[var(--destructive)]">
|
|
<AlertCircle className="w-4 h-4" />
|
|
<span className="text-sm">{error}</span>
|
|
<button
|
|
onClick={() => setError(null)}
|
|
className="ml-auto text-xs underline hover:no-underline"
|
|
>
|
|
Fermer
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{selectedNote ? (
|
|
<>
|
|
{/* Note Header */}
|
|
<div className={`${glassDivider} flex items-center gap-4 p-4`}>
|
|
<FileText className="w-5 h-5 text-[var(--muted-foreground)]" />
|
|
<div className="flex-1">
|
|
<h2 className="text-lg font-semibold text-[var(--foreground)] truncate">
|
|
{getNoteTitle(selectedNote.content)}
|
|
</h2>
|
|
{selectedNote.folderId &&
|
|
(() => {
|
|
const findFolder = (
|
|
foldersList: typeof folders
|
|
): (typeof folders)[0] | null => {
|
|
for (const folder of foldersList) {
|
|
if (folder.id === selectedNote.folderId)
|
|
return folder;
|
|
if (folder.children) {
|
|
const found = findFolder(folder.children);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
const currentFolder = findFolder(folders);
|
|
const folderTag = currentFolder?.tagId
|
|
? availableTags.find(
|
|
(t) => t.id === currentFolder.tagId
|
|
)
|
|
: null;
|
|
|
|
return currentFolder ? (
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="text-xs text-[var(--muted-foreground)]">
|
|
📁 {currentFolder.name}
|
|
</span>
|
|
{folderTag && (
|
|
<div
|
|
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs"
|
|
style={{
|
|
backgroundColor: `${folderTag.color}20`,
|
|
color: folderTag.color,
|
|
}}
|
|
>
|
|
<span>{folderTag.name}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
|
|
{hasUnsavedChanges && (
|
|
<span className="text-[var(--accent)]">
|
|
Non sauvegardé
|
|
</span>
|
|
)}
|
|
<span>
|
|
Modifié{' '}
|
|
{new Date(selectedNote.updatedAt).toLocaleDateString(
|
|
'fr-FR'
|
|
)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Editor */}
|
|
<div className="flex-1 min-h-0">
|
|
<MarkdownEditor
|
|
value={selectedNote.content}
|
|
onChange={handleContentChange}
|
|
autoSave={true}
|
|
onSave={handleSave}
|
|
tags={selectedNote.tags}
|
|
onTagsChange={handleTagsChange}
|
|
availableTags={availableTags}
|
|
selectedTaskId={selectedNote.taskId}
|
|
selectedTask={selectedNote.task}
|
|
onTaskChange={handleTaskChange}
|
|
selectedFolderId={selectedNote.folderId}
|
|
availableFolders={folders}
|
|
onFolderChange={handleFolderChange}
|
|
onCreateNote={handleCreateNote}
|
|
onToggleSidebar={() =>
|
|
setSidebarCollapsed(!sidebarCollapsed)
|
|
}
|
|
initialIsEditing={isNewNote}
|
|
onEditingChange={(isEditing) => {
|
|
if (!isEditing) {
|
|
setIsNewNote(false);
|
|
}
|
|
}}
|
|
className="h-full"
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-[var(--muted-foreground)]">
|
|
<div className="text-center">
|
|
<FileText className="w-16 h-16 mx-auto mb-4 opacity-50" />
|
|
<h3 className="text-lg font-medium mb-2">
|
|
Aucune note sélectionnée
|
|
</h3>
|
|
<p className="text-sm">
|
|
Sélectionnez une note dans la liste ou créez-en une nouvelle
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function NotesPageClient({
|
|
initialNotes,
|
|
initialTags,
|
|
initialFolders,
|
|
}: NotesPageClientProps) {
|
|
return (
|
|
<TasksProvider initialTasks={[]} initialTags={initialTags}>
|
|
<NotesPageContent
|
|
initialNotes={initialNotes}
|
|
initialFolders={initialFolders}
|
|
/>
|
|
</TasksProvider>
|
|
);
|
|
}
|