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:
Julien Froidefond
2025-10-09 13:38:09 +02:00
parent 1fe59f26e4
commit 6c86ce44f1
15 changed files with 4354 additions and 96 deletions

View File

@@ -0,0 +1,253 @@
'use client';
import { useState, useMemo } from 'react';
import { Note } from '@/services/notes';
import { Search, Plus, Calendar, Trash2, Tags, List } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { Tag } from '@/lib/types';
interface NotesListProps {
notes: Note[];
onSelectNote: (note: Note) => void;
onCreateNote: () => void;
onDeleteNote: (noteId: string) => void;
selectedNoteId?: string;
isLoading?: boolean;
availableTags?: Tag[];
}
export function NotesList({
notes,
onSelectNote,
onCreateNote,
onDeleteNote,
selectedNoteId,
isLoading = false,
availableTags = [],
}: NotesListProps) {
const [searchQuery, setSearchQuery] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
null
);
const [groupByTags, setGroupByTags] = useState(true);
// Filter notes based on search query
const filteredNotes = notes.filter(
(note) =>
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
);
// Group notes by tags
const groupedNotes = useMemo(() => {
if (!groupByTags) {
return { 'Toutes les notes': filteredNotes };
}
const groups: { [key: string]: Note[] } = {};
// Notes avec tags
filteredNotes.forEach((note) => {
if (note.tags && note.tags.length > 0) {
note.tags.forEach((tag) => {
if (!groups[tag]) {
groups[tag] = [];
}
groups[tag].push(note);
});
} else {
// Notes sans tags
if (!groups['Sans tags']) {
groups['Sans tags'] = [];
}
groups['Sans tags'].push(note);
}
});
// Trier les groupes par nom de tag
const sortedGroups: { [key: string]: Note[] } = {};
Object.keys(groups)
.sort()
.forEach((key) => {
sortedGroups[key] = groups[key];
});
return sortedGroups;
}, [filteredNotes, groupByTags]);
const handleDeleteClick = (noteId: string, e: React.MouseEvent) => {
e.stopPropagation();
setShowDeleteConfirm(noteId);
};
const handleDeleteConfirm = (noteId: string) => {
onDeleteNote(noteId);
setShowDeleteConfirm(null);
};
const handleDeleteCancel = () => {
setShowDeleteConfirm(null);
};
const getNoteTitle = (content: string): string => {
// Extract title from first line, removing markdown headers
const firstLine = content.split('\n')[0] || '';
return firstLine.replace(/^#+\s*/, '').trim() || 'Sans titre';
};
return (
<div className="flex flex-col h-full bg-[var(--card)]/40 backdrop-blur-md border-r border-[var(--border)]/60 relative before:absolute before:inset-0 before:bg-gradient-to-br 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">
{/* Header */}
<div className="p-4 border-b border-[var(--border)]">
<div className="mb-4 space-y-2">
<div className="flex justify-end">
<button
onClick={() => setGroupByTags(!groupByTags)}
className={`p-2 rounded-md transition-all duration-200 ${
groupByTags
? 'bg-[var(--primary)]/20 text-[var(--primary)]'
: 'bg-[var(--card)]/60 hover:bg-[var(--card)]/80 text-[var(--muted-foreground)]'
}`}
title={groupByTags ? 'Vue par liste' : 'Vue par tags'}
>
{groupByTags ? (
<Tags className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</button>
</div>
<button
onClick={onCreateNote}
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--primary)]/80 hover:bg-[var(--primary)]/90 backdrop-blur-sm text-[var(--primary-foreground)] rounded-md text-sm font-medium transition-all duration-200 shadow-lg shadow-[var(--primary)]/20 hover:shadow-xl hover:shadow-[var(--primary)]/30"
>
<Plus className="w-4 h-4" />
Nouvelle note
</button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)]" />
<input
type="text"
placeholder="Rechercher"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-[var(--border)]/60 rounded-md bg-[var(--card)]/40 backdrop-blur-sm text-[var(--foreground)] placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 focus:bg-[var(--card)]/60 transition-all duration-200"
/>
</div>
</div>
{/* Notes List */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<div className="p-4 text-center text-[var(--muted-foreground)]">
Chargement des notes...
</div>
) : filteredNotes.length === 0 ? (
<div className="p-4 text-center text-[var(--muted-foreground)]">
{searchQuery ? 'Aucune note trouvée' : 'Aucune note pour le moment'}
</div>
) : (
<div className="p-2 space-y-4">
{Object.entries(groupedNotes).map(([groupName, groupNotes]) => (
<div key={groupName}>
{/* Group Header */}
<div className="px-3 py-2 bg-[var(--card)]/30 backdrop-blur-sm border-b border-[var(--border)]/30 rounded-t-lg">
<h3 className="text-xs font-mono font-medium text-[var(--primary)] uppercase tracking-wider">
{groupName} ({groupNotes.length})
</h3>
</div>
{/* Group Notes */}
<div className="space-y-1">
{groupNotes.map((note) => (
<div
key={note.id}
onClick={() => onSelectNote(note)}
className={`group relative p-3 cursor-pointer transition-all duration-200 backdrop-blur-sm ${
selectedNoteId === note.id
? 'bg-[var(--primary)]/20 border border-[var(--primary)]/30 shadow-lg shadow-[var(--primary)]/10'
: 'bg-[var(--card)]/30 hover:bg-[var(--card)]/50 border border-[var(--border)]/40 hover:border-[var(--border)]/60 hover:shadow-md'
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-[var(--foreground)] truncate mb-2">
{getNoteTitle(note.content)}
</h3>
{/* Tags - seulement si pas groupé par tags */}
{!groupByTags &&
note.tags &&
note.tags.length > 0 && (
<div className="mb-2">
<TagDisplay
tags={note.tags}
availableTags={availableTags}
maxTags={2}
size="sm"
showColors={true}
showDot={false}
/>
</div>
)}
<div className="flex items-center gap-2 text-xs text-[var(--muted-foreground)]">
<Calendar className="w-3 h-3" />
<span>
{formatDistanceToNow(new Date(note.updatedAt), {
addSuffix: true,
locale: fr,
})}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleDeleteClick(note.id, e)}
className="p-1 hover:bg-[var(--destructive)]/10 rounded text-[var(--destructive)] transition-colors"
title="Supprimer"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* Delete Confirmation */}
{showDeleteConfirm === note.id && (
<div className="absolute inset-0 bg-[var(--destructive)]/5 border border-[var(--destructive)]/20 rounded-lg p-3 backdrop-blur-sm">
<div className="text-sm text-[var(--foreground)] mb-2">
Supprimer cette note ?
</div>
<div className="flex gap-2">
<button
onClick={() => handleDeleteConfirm(note.id)}
className="px-2 py-1 bg-[var(--destructive)] text-[var(--primary-foreground)] rounded text-xs hover:bg-[var(--destructive)]/90"
>
Supprimer
</button>
<button
onClick={handleDeleteCancel}
className="px-2 py-1 bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded text-xs hover:bg-[var(--card-hover)]"
>
Annuler
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}