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:
253
src/components/notes/NotesList.tsx
Normal file
253
src/components/notes/NotesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user