feat(Notes): add favorite functionality to notes, allowing users to toggle favorites and filter notes accordingly

This commit is contained in:
Julien Froidefond
2026-01-12 10:52:44 +01:00
parent 31d01c2926
commit 75d31e86ac
9 changed files with 156 additions and 22 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Note" ADD COLUMN "isFavorite" BOOLEAN NOT NULL DEFAULT 0;

View File

@@ -146,6 +146,7 @@ model Note {
taskId String? // Tâche associée à la note taskId String? // Tâche associée à la note
folderId String? // Dossier contenant la note folderId String? // Dossier contenant la note
order Int @default(0) // Ordre manuel de la note dans son dossier order Int @default(0) // Ordre manuel de la note dans son dossier
isFavorite Boolean @default(false) // Note favorite
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@@ -29,3 +29,35 @@ export async function reorderNotes(
}; };
} }
} }
/**
* Bascule l'état favori d'une note
*/
export async function toggleNoteFavorite(noteId: string) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return { success: false, error: 'Non autorisé' };
}
// Récupérer la note actuelle pour connaître son état favori
const currentNote = await notesService.getNoteById(noteId, session.user.id);
if (!currentNote) {
return { success: false, error: 'Note non trouvée' };
}
// Basculer l'état favori
const updatedNote = await notesService.updateNote(noteId, session.user.id, {
isFavorite: !currentNote.isFavorite,
});
revalidatePath('/notes');
return { success: true, note: updatedNote };
} catch (error) {
console.error('Error toggling note favorite:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Erreur inconnue',
};
}
}

View File

@@ -53,7 +53,7 @@ export async function PUT(
const resolvedParams = await params; const resolvedParams = await params;
const body = await request.json(); const body = await request.json();
const { title, content, taskId, folderId, tags } = body; const { title, content, taskId, folderId, isFavorite, tags } = body;
const note = await notesService.updateNote( const note = await notesService.updateNote(
resolvedParams.id, resolvedParams.id,
@@ -63,6 +63,7 @@ export async function PUT(
content, content,
taskId, taskId,
folderId, folderId,
isFavorite,
tags, tags,
} }
); );

View File

@@ -283,11 +283,13 @@ function NotesPageContent({
// Filtrer les notes par dossier // Filtrer les notes par dossier
const filteredNotes = const filteredNotes =
selectedFolderId === '__uncategorized__' selectedFolderId === '__favorites__'
? notes.filter((note) => !note.folderId) // Notes sans dossier ? notes.filter((note) => note.isFavorite) // Notes favorites
: selectedFolderId : selectedFolderId === '__uncategorized__'
? notes.filter((note) => note.folderId === selectedFolderId) ? notes.filter((note) => !note.folderId) // Notes sans dossier
: notes; // Toutes les notes : selectedFolderId
? notes.filter((note) => note.folderId === selectedFolderId)
: notes; // Toutes les notes
// Gérer le changement de dossier // Gérer le changement de dossier
const handleSelectFolder = useCallback( const handleSelectFolder = useCallback(
@@ -296,11 +298,13 @@ function NotesPageContent({
// Sélectionner automatiquement la première note du dossier // Sélectionner automatiquement la première note du dossier
const folderNotes = const folderNotes =
folderId === '__uncategorized__' folderId === '__favorites__'
? notes.filter((note) => !note.folderId) ? notes.filter((note) => note.isFavorite)
: folderId : folderId === '__uncategorized__'
? notes.filter((note) => note.folderId === folderId) ? notes.filter((note) => !note.folderId)
: notes; : folderId
? notes.filter((note) => note.folderId === folderId)
: notes;
if (folderNotes.length > 0) { if (folderNotes.length > 0) {
setSelectedNote(folderNotes[0]); setSelectedNote(folderNotes[0]);
@@ -323,6 +327,20 @@ function NotesPageContent({
} }
}, []); }, []);
// Gérer la mise à jour d'une note (pour les favoris)
const handleNoteUpdate = useCallback(
(updatedNote: Note) => {
setNotes((prev) =>
prev.map((note) => (note.id === updatedNote.id ? updatedNote : note))
);
// Mettre à jour aussi la note sélectionnée si c'est celle-ci
if (selectedNote?.id === updatedNote.id) {
setSelectedNote(updatedNote);
}
},
[selectedNote]
);
// Gérer le drop d'une note sur un dossier // Gérer le drop d'une note sur un dossier
const handleNoteDrop = useCallback( const handleNoteDrop = useCallback(
async (noteId: string, folderId: string | null) => { async (noteId: string, folderId: string | null) => {
@@ -403,6 +421,9 @@ function NotesPageContent({
onFoldersChange={handleFoldersChange} onFoldersChange={handleFoldersChange}
availableTags={availableTags} availableTags={availableTags}
onNoteDrop={handleNoteDrop} onNoteDrop={handleNoteDrop}
favoritesCount={
notes.filter((note) => note.isFavorite).length
}
/> />
</div> </div>
@@ -417,6 +438,7 @@ function NotesPageContent({
selectedNoteId={selectedNote?.id} selectedNoteId={selectedNote?.id}
isLoading={false} isLoading={false}
availableTags={availableTags} availableTags={availableTags}
onNoteUpdate={handleNoteUpdate}
/> />
</div> </div>
</> </>

View File

@@ -14,6 +14,7 @@ export interface UpdateNoteData {
content?: string; content?: string;
taskId?: string; // Tâche associée à la note taskId?: string; // Tâche associée à la note
folderId?: string | null; // Dossier contenant la note (null pour retirer du dossier) folderId?: string | null; // Dossier contenant la note (null pour retirer du dossier)
isFavorite?: boolean; // Note favorite
tags?: string[]; tags?: string[];
} }

View File

@@ -11,6 +11,7 @@ import {
Trash2, Trash2,
ChevronRight, ChevronRight,
ChevronDown, ChevronDown,
Star,
} from 'lucide-react'; } from 'lucide-react';
import { createFolder, updateFolder, deleteFolder } from '@/actions/folders'; import { createFolder, updateFolder, deleteFolder } from '@/actions/folders';
import { extractEmojis } from '@/lib/task-emoji'; import { extractEmojis } from '@/lib/task-emoji';
@@ -22,6 +23,7 @@ interface FoldersSidebarProps {
onFoldersChange: () => void; onFoldersChange: () => void;
availableTags: Tag[]; availableTags: Tag[];
onNoteDrop?: (noteId: string, folderId: string | null) => void; onNoteDrop?: (noteId: string, folderId: string | null) => void;
favoritesCount?: number;
} }
interface FolderItemProps { interface FolderItemProps {
@@ -195,6 +197,7 @@ export function FoldersSidebar({
onFoldersChange, onFoldersChange,
availableTags, availableTags,
onNoteDrop, onNoteDrop,
favoritesCount = 0,
}: FoldersSidebarProps) { }: FoldersSidebarProps) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
@@ -294,6 +297,24 @@ export function FoldersSidebar({
<span className="flex-1 text-sm">Toutes les notes</span> <span className="flex-1 text-sm">Toutes les notes</span>
</div> </div>
{/* Favorites */}
<div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-1 ${
selectedFolderId === '__favorites__'
? 'bg-[var(--accent)]/10 text-[var(--accent)] border-l-2 border-[var(--accent)]'
: 'hover:bg-[var(--card)]/60 text-[var(--foreground)]'
}`}
onClick={() => onSelectFolder('__favorites__')}
>
<Star className="w-4 h-4 flex-shrink-0 fill-current" />
<span className="flex-1 text-sm">Favoris</span>
{favoritesCount > 0 && (
<span className="text-xs text-[var(--muted-foreground)]">
{favoritesCount}
</span>
)}
</div>
{/* Uncategorized Notes */} {/* Uncategorized Notes */}
<div <div
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-2 ${ className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all duration-200 mb-2 ${

View File

@@ -1,8 +1,16 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useTransition } from 'react';
import { Note } from '@/services/notes'; import { Note } from '@/services/notes';
import { Search, Plus, Calendar, Trash2, GripVertical } from 'lucide-react'; import {
Search,
Plus,
Calendar,
Trash2,
GripVertical,
Star,
} from 'lucide-react';
import { toggleNoteFavorite } from '@/actions/notes';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { fr } from 'date-fns/locale'; import { fr } from 'date-fns/locale';
import { Tag } from '@/lib/types'; import { Tag } from '@/lib/types';
@@ -33,6 +41,7 @@ interface NotesListProps {
selectedNoteId?: string; selectedNoteId?: string;
isLoading?: boolean; isLoading?: boolean;
availableTags?: Tag[]; availableTags?: Tag[];
onNoteUpdate?: (note: Note) => void;
} }
// Extraire le titre du contenu markdown // Extraire le titre du contenu markdown
@@ -55,6 +64,7 @@ function SortableNoteItem({
onDelete, onDelete,
showDeleteConfirm, showDeleteConfirm,
setShowDeleteConfirm, setShowDeleteConfirm,
onNoteUpdate,
}: { }: {
note: Note; note: Note;
isSelected: boolean; isSelected: boolean;
@@ -62,7 +72,19 @@ function SortableNoteItem({
onDelete: (noteId: string, e: React.MouseEvent) => void; onDelete: (noteId: string, e: React.MouseEvent) => void;
showDeleteConfirm: string | null; showDeleteConfirm: string | null;
setShowDeleteConfirm: (id: string | null) => void; setShowDeleteConfirm: (id: string | null) => void;
onNoteUpdate?: (note: Note) => void;
}) { }) {
const [isPending, startTransition] = useTransition();
const handleToggleFavorite = (e: React.MouseEvent) => {
e.stopPropagation();
startTransition(async () => {
const result = await toggleNoteFavorite(note.id);
if (result.success && result.note && onNoteUpdate) {
onNoteUpdate(result.note);
}
});
};
const { const {
attributes, attributes,
listeners, listeners,
@@ -131,15 +153,35 @@ function SortableNoteItem({
</button> </button>
</div> </div>
) : ( ) : (
<button <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
onClick={(e) => { <button
e.stopPropagation(); onClick={handleToggleFavorite}
setShowDeleteConfirm(note.id); disabled={isPending}
}} className={`p-1 rounded-md transition-colors ${
className="opacity-0 group-hover:opacity-100 p-1 rounded-md hover:bg-[var(--destructive)]/20 text-[var(--destructive)] transition-opacity" note.isFavorite
> ? 'text-[var(--accent)] bg-[var(--accent)]/20'
<Trash2 className="w-3 h-3" /> : 'hover:bg-[var(--accent)]/20 text-[var(--muted-foreground)] hover:text-[var(--accent)]'
</button> }`}
title={
note.isFavorite
? 'Retirer des favoris'
: 'Ajouter aux favoris'
}
>
<Star
className={`w-3 h-3 ${note.isFavorite ? 'fill-current' : ''}`}
/>
</button>
<button
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(note.id);
}}
className="p-1 rounded-md hover:bg-[var(--destructive)]/20 text-[var(--destructive)]"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)} )}
</div> </div>
<div className="flex items-center justify-between gap-2 text-xs text-[var(--muted-foreground)]"> <div className="flex items-center justify-between gap-2 text-xs text-[var(--muted-foreground)]">
@@ -179,6 +221,7 @@ export function NotesList({
onReorderNotes, onReorderNotes,
selectedNoteId, selectedNoteId,
isLoading = false, isLoading = false,
onNoteUpdate,
}: NotesListProps) { }: NotesListProps) {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>( const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
@@ -282,6 +325,7 @@ export function NotesList({
onDelete={handleDeleteConfirm} onDelete={handleDeleteConfirm}
showDeleteConfirm={showDeleteConfirm} showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm} setShowDeleteConfirm={setShowDeleteConfirm}
onNoteUpdate={onNoteUpdate}
/> />
))} ))}
</SortableContext> </SortableContext>

View File

@@ -10,6 +10,7 @@ export interface Note {
userId: string; userId: string;
taskId?: string; // Tâche associée à la note taskId?: string; // Tâche associée à la note
folderId?: string; // Dossier contenant la note folderId?: string; // Dossier contenant la note
isFavorite?: boolean; // Note favorite
task?: Task | null; // Objet Task complet task?: Task | null; // Objet Task complet
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -30,6 +31,7 @@ export interface UpdateNoteData {
content?: string; content?: string;
taskId?: string; // Tâche associée à la note taskId?: string; // Tâche associée à la note
folderId?: string; // Dossier contenant la note folderId?: string; // Dossier contenant la note
isFavorite?: boolean; // Note favorite
tags?: string[]; tags?: string[];
} }
@@ -130,6 +132,7 @@ export class NotesService {
...note, ...note,
taskId: note.taskId || undefined, // Convertir null en undefined taskId: note.taskId || undefined, // Convertir null en undefined
folderId: note.folderId || undefined, // Convertir null en undefined folderId: note.folderId || undefined, // Convertir null en undefined
isFavorite: note.isFavorite || false, // Convertir null en false
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task
tags: note.noteTags.map((nt) => nt.tag.name), tags: note.noteTags.map((nt) => nt.tag.name),
})); }));
@@ -169,6 +172,7 @@ export class NotesService {
...note, ...note,
taskId: note.taskId || undefined, // Convertir null en undefined taskId: note.taskId || undefined, // Convertir null en undefined
folderId: note.folderId || undefined, // Convertir null en undefined folderId: note.folderId || undefined, // Convertir null en undefined
isFavorite: note.isFavorite || false, // Convertir null en false
task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task task: this.mapPrismaTaskToTask(note.task), // Mapper correctement l'objet Task
tags: note.noteTags.map((nt) => nt.tag.name), tags: note.noteTags.map((nt) => nt.tag.name),
}; };
@@ -272,6 +276,7 @@ export class NotesService {
content?: string; content?: string;
taskId?: string | null; taskId?: string | null;
folderId?: string | null; folderId?: string | null;
isFavorite?: boolean;
noteTags?: { noteTags?: {
deleteMany: Record<string, never>; deleteMany: Record<string, never>;
create: Array<{ create: Array<{
@@ -300,6 +305,9 @@ export class NotesService {
if (data.folderId !== undefined) { if (data.folderId !== undefined) {
updateData.folderId = data.folderId || null; updateData.folderId = data.folderId || null;
} }
if (data.isFavorite !== undefined) {
updateData.isFavorite = data.isFavorite;
}
// Gérer les tags si fournis // Gérer les tags si fournis
if (data.tags !== undefined) { if (data.tags !== undefined) {
@@ -354,6 +362,7 @@ export class NotesService {
...noteWithTags!, ...noteWithTags!,
taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined taskId: noteWithTags!.taskId || undefined, // Convertir null en undefined
folderId: noteWithTags!.folderId || undefined, // Convertir null en undefined folderId: noteWithTags!.folderId || undefined, // Convertir null en undefined
isFavorite: noteWithTags!.isFavorite || false, // Convertir null en false
task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task task: this.mapPrismaTaskToTask(noteWithTags!.task), // Mapper correctement l'objet Task
tags: noteWithTags!.noteTags.map((nt) => nt.tag.name), tags: noteWithTags!.noteTags.map((nt) => nt.tag.name),
}; };