258 lines
11 KiB
TypeScript
258 lines
11 KiB
TypeScript
'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="space-y-3">
|
|
<button
|
|
onClick={onCreateNote}
|
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-[var(--primary)]/85 hover:bg-[var(--primary)] 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 className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
{/* Search */}
|
|
<div className="relative flex-1">
|
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted-foreground)]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Rechercher une note ou un tag"
|
|
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>
|
|
|
|
<button
|
|
onClick={() => setGroupByTags(!groupByTags)}
|
|
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all duration-200 border border-[var(--border)]/50 bg-[var(--card)]/40 backdrop-blur-sm sm:w-auto ${
|
|
groupByTags
|
|
? 'ring-1 ring-[var(--primary)]/30 bg-[var(--primary)]/15 text-[var(--primary)]'
|
|
: 'text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:bg-[var(--card)]/60'
|
|
}`}
|
|
title={groupByTags ? 'Vue par liste' : 'Vue par tags'}
|
|
>
|
|
{groupByTags ? (
|
|
<Tags className="w-3 h-3" />
|
|
) : (
|
|
<List className="w-3 h-3" />
|
|
)}
|
|
<span className="tracking-wide uppercase">
|
|
{groupByTags ? 'Tags' : 'Liste'}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</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 suppressHydrationWarning>
|
|
{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>
|
|
);
|
|
}
|