feat: add notes feature and keyboard shortcuts
- Introduced a new Note model in the Prisma schema to support note-taking functionality. - Updated the HeaderNavigation component to include a link to the new Notes page. - Implemented keyboard shortcuts for note actions, enhancing user experience and productivity. - Added dependencies for markdown rendering and formatting tools to support note content.
This commit is contained in:
289
src/app/notes/NotesPageClient.tsx
Normal file
289
src/app/notes/NotesPageClient.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Note } from '@/services/notes';
|
||||
import { notesClient } from '@/clients/notes';
|
||||
import { NotesList } from '@/components/notes/NotesList';
|
||||
import { MarkdownEditor } from '@/components/notes/MarkdownEditor';
|
||||
import { Header } from '@/components/ui/Header';
|
||||
import { TasksProvider, useTasksContext } from '@/contexts/TasksContext';
|
||||
import { Tag } from '@/lib/types';
|
||||
import { FileText, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface NotesPageClientProps {
|
||||
initialNotes: Note[];
|
||||
initialTags: (Tag & { usage: number })[];
|
||||
}
|
||||
|
||||
function NotesPageContent({ initialNotes }: { initialNotes: Note[] }) {
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const { tags: availableTags } = useTasksContext();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// 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);
|
||||
setHasUnsavedChanges(false);
|
||||
},
|
||||
[hasUnsavedChanges, selectedNote]
|
||||
);
|
||||
|
||||
const handleCreateNote = useCallback(async () => {
|
||||
try {
|
||||
const newNote = await notesClient.createNote({
|
||||
title: 'Nouvelle note',
|
||||
content: '# Nouvelle note\n\nCommencez à écrire...',
|
||||
});
|
||||
|
||||
setNotes((prev) => [newNote, ...prev]);
|
||||
setSelectedNote(newNote);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (err) {
|
||||
setError('Erreur lors de la création de la note');
|
||||
console.error('Error creating note:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 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);
|
||||
|
||||
const updatedNote = await notesClient.updateNote(selectedNote.id, {
|
||||
content: selectedNote.content,
|
||||
tags: selectedNote.tags,
|
||||
});
|
||||
|
||||
// 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);
|
||||
} 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]
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
return (
|
||||
<div className="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-4">
|
||||
<div className="flex h-full bg-[var(--card)]/40 border border-[var(--border)]/60 backdrop-blur-md shadow-xl shadow-[var(--card-shadow-medium)] rounded-lg overflow-hidden relative before:absolute before:inset-0 before:rounded-lg before:bg-gradient-to-br before:from-[color-mix(in_srgb,var(--primary)_12%,transparent)] before:via-[color-mix(in_srgb,var(--primary)_6%,transparent)] before:to-transparent before:opacity-90 before:pointer-events-none">
|
||||
{/* Notes List Sidebar */}
|
||||
<div
|
||||
className={`${sidebarCollapsed ? 'w-12' : 'w-80'} flex-shrink-0 border-r border-[var(--border)] transition-all duration-300 ease-in-out`}
|
||||
>
|
||||
{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>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-3 border-b border-[var(--border)]/60 bg-[var(--card)]/40 backdrop-blur-md">
|
||||
<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>
|
||||
<NotesList
|
||||
notes={notes}
|
||||
onSelectNote={handleSelectNote}
|
||||
onCreateNote={handleCreateNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
selectedNoteId={selectedNote?.id}
|
||||
isLoading={false}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</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="flex items-center gap-4 p-4 border-b border-[var(--border)]/60 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">
|
||||
<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>
|
||||
</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}
|
||||
onCreateNote={handleCreateNote}
|
||||
onToggleSidebar={() =>
|
||||
setSidebarCollapsed(!sidebarCollapsed)
|
||||
}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotesPageClient({
|
||||
initialNotes,
|
||||
initialTags,
|
||||
}: NotesPageClientProps) {
|
||||
return (
|
||||
<TasksProvider initialTasks={[]} initialTags={initialTags}>
|
||||
<NotesPageContent initialNotes={initialNotes} />
|
||||
</TasksProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user