Files
towercontrol/src/app/notes/NotesPageClient.tsx

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