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

3
.husky/pre-commit Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
export PATH="$PWD/node_modules/.bin:$PATH"
lint-staged

2213
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,10 @@
"cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup", "cache:cleanup": "npx tsx scripts/cache-monitor.ts cleanup",
"cache:clear": "npx tsx scripts/cache-monitor.ts clear", "cache:clear": "npx tsx scripts/cache-monitor.ts clear",
"test:story-points": "npx tsx scripts/test-story-points.ts", "test:story-points": "npx tsx scripts/test-story-points.ts",
"test:jira-fields": "npx tsx scripts/test-jira-fields.ts" "test:jira-fields": "npx tsx scripts/test-jira-fields.ts",
"prettier:format": "prettier --write .",
"prettier:check": "prettier --check .",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -39,7 +42,11 @@
"prisma": "^6.16.1", "prisma": "^6.16.1",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-markdown": "^10.1.0",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"rehype-highlight": "^7.0.2",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"twemoji": "^14.0.2" "twemoji": "^14.0.2"
}, },
@@ -52,8 +59,16 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "^15.5.3", "eslint-config-next": "^15.5.3",
"husky": "^9.1.7",
"knip": "^5.64.0", "knip": "^5.64.0",
"lint-staged": "^15.5.2",
"prettier": "^3.6.2",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5" "typescript": "^5"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
"prettier --write"
]
} }
} }

View File

@@ -21,6 +21,7 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
preferences UserPreferences? preferences UserPreferences?
notes Note[]
@@map("users") @@map("users")
} }
@@ -62,6 +63,7 @@ model Tag {
isPinned Boolean @default(false) isPinned Boolean @default(false)
taskTags TaskTag[] taskTags TaskTag[]
primaryTasks Task[] @relation("PrimaryTag") primaryTasks Task[] @relation("PrimaryTag")
noteTags NoteTag[]
@@map("tags") @@map("tags")
} }
@@ -121,3 +123,24 @@ model UserPreferences {
@@map("user_preferences") @@map("user_preferences")
} }
model Note {
id String @id @default(cuid())
title String
content String // Markdown content
userId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
noteTags NoteTag[]
}
model NoteTag {
noteId String
tagId String
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
@@id([noteId, tagId])
@@map("note_tags")
}

View File

@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server';
import { notesService } from '@/services/notes';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
/**
* API route pour récupérer une note spécifique
*/
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const resolvedParams = await params;
const note = await notesService.getNoteById(
resolvedParams.id,
session.user.id
);
if (!note) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json({ note });
} catch (error) {
console.error('Error fetching note:', error);
return NextResponse.json(
{ error: 'Failed to fetch note' },
{ status: 500 }
);
}
}
/**
* API route pour mettre à jour une note
*/
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, content, tags } = body;
const resolvedParams = await params;
const note = await notesService.updateNote(
resolvedParams.id,
session.user.id,
{
title,
content,
tags,
}
);
return NextResponse.json({ note });
} catch (error) {
console.error('Error updating note:', error);
if (
error instanceof Error &&
error.message === 'Note not found or access denied'
) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json(
{ error: 'Failed to update note' },
{ status: 500 }
);
}
}
/**
* API route pour supprimer une note
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const resolvedParams = await params;
await notesService.deleteNote(resolvedParams.id, session.user.id);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting note:', error);
if (
error instanceof Error &&
error.message === 'Note not found or access denied'
) {
return NextResponse.json({ error: 'Note not found' }, { status: 404 });
}
return NextResponse.json(
{ error: 'Failed to delete note' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server';
import { notesService } from '@/services/notes';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
/**
* API route pour récupérer toutes les notes de l'utilisateur connecté
*/
export async function GET(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const search = searchParams.get('search');
let notes;
if (search) {
notes = await notesService.searchNotes(session.user.id, search);
} else {
notes = await notesService.getNotes(session.user.id);
}
return NextResponse.json({ notes });
} catch (error) {
console.error('Error fetching notes:', error);
return NextResponse.json(
{ error: 'Failed to fetch notes' },
{ status: 500 }
);
}
}
/**
* API route pour créer une nouvelle note
*/
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { title, content, tags } = body;
if (!title || !content) {
return NextResponse.json(
{ error: 'Title and content are required' },
{ status: 400 }
);
}
const note = await notesService.createNote({
title,
content,
userId: session.user.id,
tags,
});
return NextResponse.json({ note }, { status: 201 });
} catch (error) {
console.error('Error creating note:', error);
return NextResponse.json(
{ error: 'Failed to create note' },
{ status: 500 }
);
}
}

View 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>
);
}

41
src/app/notes/page.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { Metadata } from 'next';
import { NotesPageClient } from './NotesPageClient';
import { notesService } from '@/services/notes';
import { tagsService } from '@/services/task-management/tags';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
// Force dynamic rendering (no static generation)
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Notes - Tower Control',
description: 'Gestionnaire de notes markdown',
};
export default async function NotesPage() {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return (
<div className="min-h-screen bg-[var(--background)] flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-[var(--foreground)] mb-4">
Non autorisé
</h1>
<p className="text-[var(--muted-foreground)]">
Vous devez être connecté pour accéder aux notes.
</p>
</div>
</div>
);
}
// SSR - Récupération des données côté serveur
const initialNotes = await notesService.getNotes(session.user.id);
const initialTags = await tagsService.getTags();
return (
<NotesPageClient initialNotes={initialNotes} initialTags={initialTags} />
);
}

95
src/clients/notes.ts Normal file
View File

@@ -0,0 +1,95 @@
import { HttpClient } from '@/clients/base/http-client';
import { Note } from '@/services/notes';
export interface CreateNoteData {
title: string;
content: string;
tags?: string[];
}
export interface UpdateNoteData {
title?: string;
content?: string;
tags?: string[];
}
export interface NotesResponse {
notes: Note[];
}
export interface NoteResponse {
note: Note;
}
export interface NotesStatsResponse {
totalNotes: number;
totalWords: number;
lastUpdated: Date | null;
}
/**
* Client HTTP pour les notes
*/
export class NotesClient extends HttpClient {
constructor() {
super('/api/notes');
}
/**
* Récupère toutes les notes de l'utilisateur
*/
async getNotes(search?: string): Promise<Note[]> {
const params = search ? { search } : undefined;
const response = await this.get<NotesResponse>('', params);
return response.notes;
}
/**
* Récupère une note par son ID
*/
async getNoteById(id: string): Promise<Note> {
const response = await this.get<NoteResponse>(`/${id}`);
return response.note;
}
/**
* Crée une nouvelle note
*/
async createNote(data: CreateNoteData): Promise<Note> {
const response = await this.post<NoteResponse>('', data);
return response.note;
}
/**
* Met à jour une note existante
*/
async updateNote(id: string, data: UpdateNoteData): Promise<Note> {
const response = await this.put<NoteResponse>(`/${id}`, data);
return response.note;
}
/**
* Supprime une note
*/
async deleteNote(id: string): Promise<void> {
await this.delete(`/${id}`);
}
/**
* Recherche des notes
*/
async searchNotes(query: string): Promise<Note[]> {
const response = await this.get<NotesResponse>('', { search: query });
return response.notes;
}
/**
* Récupère les statistiques des notes
*/
async getNotesStats(): Promise<NotesStatsResponse> {
return await this.get<NotesStatsResponse>('/stats');
}
}
// Instance singleton
export const notesClient = new NotesClient();

View File

@@ -0,0 +1,710 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeHighlight from 'rehype-highlight';
import rehypeSanitize from 'rehype-sanitize';
import { Eye, EyeOff, Edit3, X } from 'lucide-react';
import { TagInput } from '@/components/ui/TagInput';
import { TagDisplay } from '@/components/ui/TagDisplay';
import { Tag } from '@/lib/types';
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
autoSave?: boolean;
onSave?: () => void;
tags?: string[];
onTagsChange?: (tags: string[]) => void;
availableTags?: Tag[];
onCreateNote?: () => void;
onToggleSidebar?: () => void;
}
export function MarkdownEditor({
value,
onChange,
placeholder = 'Commencez à écrire votre note en markdown...',
className = '',
autoSave = false,
onSave,
tags = [],
onTagsChange,
availableTags = [],
onCreateNote,
onToggleSidebar,
}: MarkdownEditorProps) {
const [showPreview, setShowPreview] = useState(true); // Aperçu par défaut
const [isEditing, setIsEditing] = useState(false); // Mode édition
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedValueRef = useRef(value);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Historique pour undo/redo
const [history, setHistory] = useState<string[]>([value]);
const [historyIndex, setHistoryIndex] = useState(0);
// Fonction pour ajouter à l'historique
const addToHistory = useCallback(
(newValue: string) => {
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(newValue);
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
},
[history, historyIndex]
);
// Fonction undo
const undo = useCallback(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
onChange(history[newIndex]);
}
}, [historyIndex, history, onChange]);
// Fonction redo
const redo = useCallback(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
onChange(history[newIndex]);
}
}, [historyIndex, history, onChange]);
// Fonction pour insérer du markdown
const insertMarkdown = useCallback(
(prefix: string, suffix: string, placeholder: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = value.substring(start, end);
const textToInsert = selectedText || placeholder;
const newValue =
value.substring(0, start) +
prefix +
textToInsert +
suffix +
value.substring(end);
onChange(newValue);
// Ajouter à l'historique
addToHistory(newValue);
// Restaurer la position du curseur
setTimeout(() => {
const newCursorPos = start + prefix.length + textToInsert.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}, 0);
},
[value, onChange, addToHistory]
);
// Fonction pour insérer au début de la ligne
const insertAtLineStart = useCallback(
(prefix: string) => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
value.substring(0, start).split('\n');
const currentLineStart = value.substring(0, start).lastIndexOf('\n') + 1;
const currentLine = value.substring(currentLineStart, start);
// Vérifier si la ligne commence déjà par le préfixe
const hasPrefix = currentLine.startsWith(prefix);
let newValue: string;
let newCursorPos: number;
if (hasPrefix) {
// Retirer le préfixe
newValue =
value.substring(0, currentLineStart) +
currentLine.substring(prefix.length) +
value.substring(start);
newCursorPos = start - prefix.length;
} else {
// Ajouter le préfixe
newValue =
value.substring(0, currentLineStart) +
prefix +
currentLine +
value.substring(start);
newCursorPos = start + prefix.length;
}
onChange(newValue);
// Ajouter à l'historique
addToHistory(newValue);
// Restaurer la position du curseur
setTimeout(() => {
textarea.setSelectionRange(newCursorPos, newCursorPos);
textarea.focus();
}, 0);
},
[value, onChange, addToHistory]
);
// Fonction de sauvegarde
const handleSave = useCallback(() => {
if (onSave && hasUnsavedChanges) {
onSave();
setHasUnsavedChanges(false);
lastSavedValueRef.current = value;
}
}, [onSave, hasUnsavedChanges, value]);
// Gestionnaire de raccourcis clavier
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const cmdKey = isMac ? event.metaKey : event.ctrlKey;
if (!cmdKey) return;
switch (event.key.toLowerCase()) {
case 'n':
event.preventDefault();
onCreateNote?.();
break;
case 's':
event.preventDefault();
handleSave();
break;
case 'e':
event.preventDefault();
setIsEditing(!isEditing);
break;
case 'b':
event.preventDefault();
insertMarkdown('**', '**', 'texte en gras');
break;
case 'i':
event.preventDefault();
insertMarkdown('*', '*', 'texte en italique');
break;
case 'k':
if (event.shiftKey) {
event.preventDefault();
insertMarkdown('~~', '~~', 'texte barré');
} else {
event.preventDefault();
insertMarkdown('[', '](url)', 'texte du lien');
}
break;
case 'z':
if (event.shiftKey) {
event.preventDefault();
redo();
} else {
event.preventDefault();
undo();
}
break;
case 'a':
event.preventDefault();
textareaRef.current?.select();
break;
case 'c':
case 'v':
case 'x':
// Laisser les raccourcis de copier/coller/couper fonctionner normalement
break;
case 'f':
event.preventDefault();
// Rechercher dans le navigateur
break;
case 'g':
event.preventDefault();
// Rechercher suivant
break;
default:
if (event.shiftKey) {
switch (event.key) {
case 'P':
event.preventDefault();
setShowPreview(!showPreview);
break;
case 'C':
event.preventDefault();
insertMarkdown('```\n', '\n```', 'code');
break;
case 'X':
event.preventDefault();
insertMarkdown('~~', '~~', 'texte barré');
break;
case 'H':
event.preventDefault();
insertAtLineStart('# ');
break;
case '2':
event.preventDefault();
insertAtLineStart('## ');
break;
case '3':
event.preventDefault();
insertAtLineStart('### ');
break;
case 'L':
event.preventDefault();
insertAtLineStart('- ');
break;
case 'O':
event.preventDefault();
insertAtLineStart('1. ');
break;
case 'Q':
event.preventDefault();
insertMarkdown('> ', '', 'citation');
break;
case 'S':
event.preventDefault();
onToggleSidebar?.();
break;
}
}
break;
}
},
[
isEditing,
showPreview,
handleSave,
insertMarkdown,
insertAtLineStart,
undo,
redo,
onCreateNote,
onToggleSidebar,
]
);
// Attacher les raccourcis clavier
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
// Auto-save
useEffect(() => {
if (autoSave && hasUnsavedChanges) {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
autoSaveTimeoutRef.current = setTimeout(() => {
handleSave();
}, 2000);
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}
}, [value, autoSave, hasUnsavedChanges, handleSave]);
// Détecter les changements
useEffect(() => {
if (value !== lastSavedValueRef.current) {
setHasUnsavedChanges(true);
}
}, [value]);
// Ajouter les changements manuels à l'historique
useEffect(() => {
if (value !== history[historyIndex]) {
addToHistory(value);
}
}, [value, history, historyIndex, addToHistory]);
return (
<div
className={`flex flex-col h-full bg-[var(--card)]/40 backdrop-blur-md border border-[var(--border)]/60 rounded-lg overflow-hidden 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 ${className}`}
>
{/* Tags Input en mode édition */}
{isEditing && onTagsChange && (
<div className="px-6 py-3 border-b border-[var(--border)]/30 bg-[var(--card)]/20 backdrop-blur-sm">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--foreground)]">
Tags:
</span>
<TagInput
tags={tags}
onChange={onTagsChange}
placeholder="Ajouter des tags..."
maxTags={10}
/>
</div>
</div>
)}
{/* Editor/Tags Area */}
<div className="flex-1 flex overflow-hidden">
{!isEditing ? (
/* Mode Aperçu avec Tags */
<div className="w-full flex flex-col">
{/* Barre des tags */}
{tags && tags.length > 0 && (
<div className="p-3 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">
<span className="text-sm font-medium text-[var(--foreground)] relative z-10 mb-2 block">
Tags:
</span>
<TagDisplay
tags={tags}
availableTags={availableTags}
maxTags={10}
size="sm"
showColors={true}
showDot={false}
/>
</div>
)}
{/* Barre de l'aperçu */}
<div className="p-3 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">
<div className="flex items-center justify-between relative z-10">
<span className="text-sm font-medium text-[var(--foreground)]">
Aperçu
</span>
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--primary)]/80 hover:bg-[var(--primary)]/90 backdrop-blur-sm text-[var(--primary-foreground)] transition-all duration-200"
>
<Edit3 className="w-3 h-3" />
Éditer
</button>
</div>
</div>
{/* Contenu de l'aperçu */}
<div className="flex-1 overflow-auto p-6 bg-[var(--background)]/50 backdrop-blur-sm">
<div className="prose prose-sm max-w-none prose-headings:text-[var(--foreground)] prose-p:text-[var(--foreground)] prose-strong:text-[var(--foreground)] prose-strong:font-bold prose-em:text-[var(--muted-foreground)] prose-code:text-[var(--accent)] prose-pre:bg-[var(--card)]/60 prose-pre:border prose-pre:border-[var(--border)]/60 prose-blockquote:border-[var(--primary)] prose-blockquote:text-[var(--muted-foreground)] prose-a:text-[var(--primary)] prose-table:border-[var(--border)]/60 prose-th:bg-[var(--card)]/40 prose-th:text-[var(--foreground)] prose-td:text-[var(--foreground)]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSanitize]}
components={{
// Custom styling for better integration
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-[var(--foreground)] mb-4 mt-6 border-b border-[var(--border)]/30 pb-2">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-[var(--foreground)] mb-3 mt-5 flex items-center gap-2">
<span className="w-2 h-2 bg-[var(--primary)] rounded-full"></span>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-[var(--foreground)] mb-2 mt-4">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="text-base font-semibold text-[var(--foreground)] mb-2 mt-3">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="text-sm font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]">
{children}
</h6>
),
p: ({ children }) => (
<p className="text-[var(--foreground)] mb-4 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 space-y-2 list-decimal list-inside">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-[var(--foreground)] flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[var(--primary)] rounded-full mt-2 flex-shrink-0"></span>
<span>{children}</span>
</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--primary)] pl-4 py-2 my-4 bg-[var(--card)]/20 backdrop-blur-sm rounded-r-lg">
<div className="text-[var(--muted-foreground)] italic">
{children}
</div>
</blockquote>
),
code: ({ children, className }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-[var(--card)]/60 px-2 py-1 rounded text-[var(--accent)] font-mono text-sm border border-[var(--border)]/40">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
),
a: ({ children, href }) => (
<a
href={href}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 underline decoration-[var(--primary)]/50 hover:decoration-[var(--primary)] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-6 rounded-lg border border-[var(--border)]/60">
<table className="min-w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-[var(--border)]/60 bg-[var(--card)]/40 px-4 py-3 text-left font-semibold text-[var(--foreground)] backdrop-blur-sm">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-[var(--border)]/30 px-4 py-3 text-[var(--foreground)]">
{children}
</td>
),
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-[var(--border)] to-transparent" />
),
strong: ({ children }) => (
<strong className="font-bold text-[var(--foreground)] bg-gradient-to-r from-[var(--primary)]/20 to-[var(--accent)]/20 px-1 py-0.5 rounded">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--muted-foreground)]">
{children}
</em>
),
}}
>
{value || "*Commencez à écrire pour voir l'aperçu...*"}
</ReactMarkdown>
</div>
</div>
</div>
) : (
/* Mode Édition */
<>
{/* Editor */}
<div
className={`flex-1 flex flex-col ${showPreview ? 'w-1/2' : 'w-full'}`}
>
<div className="p-3 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">
<div className="flex items-center justify-between relative z-10">
<span className="text-sm font-medium text-[var(--foreground)]">
Éditeur Markdown
</span>
<div className="flex items-center gap-2">
{hasUnsavedChanges && (
<span className="text-xs text-[var(--accent)] font-medium">
Non sauvegardé
</span>
)}
<button
onClick={() => setIsEditing(false)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded 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"
>
<X className="w-3 h-3" />
Fermer
</button>
<button
onClick={() => setShowPreview(!showPreview)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded 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"
>
{showPreview ? (
<EyeOff className="w-3 h-3" />
) : (
<Eye className="w-3 h-3" />
)}
{showPreview ? 'Masquer' : 'Aperçu'}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full h-full p-6 bg-[var(--background)]/50 backdrop-blur-sm text-[var(--foreground)] placeholder-[var(--muted-foreground)] resize-none border-none outline-none font-mono text-sm leading-relaxed"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace',
}}
/>
</div>
</div>
{/* Preview */}
{showPreview && (
<div className="w-1/2 flex flex-col border-l border-[var(--border)]/60">
<div className="p-3 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">
<span className="text-sm font-medium text-[var(--foreground)] relative z-10">
Aperçu
</span>
</div>
<div className="flex-1 overflow-auto p-6 bg-[var(--background)]/50 backdrop-blur-sm">
<div className="prose prose-sm max-w-none prose-headings:text-[var(--foreground)] prose-p:text-[var(--foreground)] prose-strong:text-[var(--foreground)] prose-strong:font-bold prose-em:text-[var(--muted-foreground)] prose-code:text-[var(--accent)] prose-pre:bg-[var(--card)]/60 prose-pre:border prose-pre:border-[var(--border)]/60 prose-blockquote:border-[var(--primary)] prose-blockquote:text-[var(--muted-foreground)] prose-a:text-[var(--primary)] prose-table:border-[var(--border)]/60 prose-th:bg-[var(--card)]/40 prose-th:text-[var(--foreground)] prose-td:text-[var(--foreground)]">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight, rehypeSanitize]}
components={{
// Custom styling for better integration
h1: ({ children }) => (
<h1 className="text-3xl font-bold text-[var(--foreground)] mb-6 mt-8 first:mt-0 bg-gradient-to-r from-[var(--primary)] to-[var(--accent)] bg-clip-text text-transparent">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-[var(--foreground)] mb-4 mt-6 border-b border-[var(--border)]/30 pb-2">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-xl font-semibold text-[var(--foreground)] mb-3 mt-5 flex items-center gap-2">
<span className="w-2 h-2 bg-[var(--primary)] rounded-full"></span>
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-lg font-semibold text-[var(--foreground)] mb-2 mt-4">
{children}
</h4>
),
h5: ({ children }) => (
<h5 className="text-base font-semibold text-[var(--foreground)] mb-2 mt-3">
{children}
</h5>
),
h6: ({ children }) => (
<h6 className="text-sm font-semibold text-[var(--foreground)] mb-2 mt-3 text-[var(--muted-foreground)]">
{children}
</h6>
),
p: ({ children }) => (
<p className="text-[var(--foreground)] mb-4 leading-relaxed">
{children}
</p>
),
ul: ({ children }) => (
<ul className="mb-4 space-y-2">{children}</ul>
),
ol: ({ children }) => (
<ol className="mb-4 space-y-2 list-decimal list-inside">
{children}
</ol>
),
li: ({ children }) => (
<li className="text-[var(--foreground)] flex items-start gap-2">
<span className="w-1.5 h-1.5 bg-[var(--primary)] rounded-full mt-2 flex-shrink-0"></span>
<span>{children}</span>
</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-[var(--primary)] pl-4 py-2 my-4 bg-[var(--card)]/20 backdrop-blur-sm rounded-r-lg">
<div className="text-[var(--muted-foreground)] italic">
{children}
</div>
</blockquote>
),
code: ({ children, className }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-[var(--card)]/60 px-2 py-1 rounded text-[var(--accent)] font-mono text-sm border border-[var(--border)]/40">
{children}
</code>
);
}
return <code className={className}>{children}</code>;
},
pre: ({ children }) => (
<pre className="bg-[var(--card)]/60 border border-[var(--border)]/60 rounded-lg p-4 overflow-x-auto backdrop-blur-sm">
{children}
</pre>
),
a: ({ children, href }) => (
<a
href={href}
className="text-[var(--primary)] hover:text-[var(--primary)]/80 underline decoration-[var(--primary)]/50 hover:decoration-[var(--primary)] transition-colors"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
table: ({ children }) => (
<div className="overflow-x-auto mb-6 rounded-lg border border-[var(--border)]/60">
<table className="min-w-full">{children}</table>
</div>
),
th: ({ children }) => (
<th className="border-b border-[var(--border)]/60 bg-[var(--card)]/40 px-4 py-3 text-left font-semibold text-[var(--foreground)] backdrop-blur-sm">
{children}
</th>
),
td: ({ children }) => (
<td className="border-b border-[var(--border)]/30 px-4 py-3 text-[var(--foreground)]">
{children}
</td>
),
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-[var(--border)] to-transparent" />
),
strong: ({ children }) => (
<strong className="font-bold text-[var(--foreground)] bg-gradient-to-r from-[var(--primary)]/20 to-[var(--accent)]/20 px-1 py-0.5 rounded">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-[var(--muted-foreground)]">
{children}
</em>
),
}}
>
{value || "*Commencez à écrire pour voir l'aperçu...*"}
</ReactMarkdown>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

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>
);
}

View File

@@ -47,33 +47,38 @@ export function KeyboardShortcutsModal({
isOpen, isOpen,
onClose, onClose,
shortcuts, shortcuts,
title = "Raccourcis clavier" title = 'Raccourcis clavier',
}: KeyboardShortcutsModalProps) { }: KeyboardShortcutsModalProps) {
// Grouper les raccourcis par catégorie // Grouper les raccourcis par catégorie
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => { const groupedShortcuts = shortcuts.reduce(
const category = shortcut.category || 'Général'; (acc, shortcut) => {
if (!acc[category]) { const category = shortcut.category || 'Général';
acc[category] = []; if (!acc[category]) {
} acc[category] = [];
acc[category].push(shortcut); }
return acc; acc[category].push(shortcut);
}, {} as Record<string, KeyboardShortcut[]>); return acc;
},
{} as Record<string, KeyboardShortcut[]>
);
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="md"> <Modal isOpen={isOpen} onClose={onClose} title={title} size="md">
<div className="space-y-4"> <div className="max-h-96 overflow-y-auto space-y-4">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => ( {Object.entries(groupedShortcuts).map(
<div key={category}> ([category, categoryShortcuts]) => (
<h3 className="text-xs font-mono font-semibold text-[var(--primary)] uppercase tracking-wider mb-2"> <div key={category}>
{category} <h3 className="text-xs font-mono font-semibold text-[var(--primary)] uppercase tracking-wider mb-2">
</h3> {category}
<div className="space-y-0.5"> </h3>
{categoryShortcuts.map((shortcut, index) => ( <div className="space-y-0.5">
<ShortcutRow key={index} shortcut={shortcut} /> {categoryShortcuts.map((shortcut, index) => (
))} <ShortcutRow key={index} shortcut={shortcut} />
))}
</div>
</div> </div>
</div> )
))} )}
{shortcuts.length === 0 && ( {shortcuts.length === 0 && (
<div className="text-center py-6"> <div className="text-center py-6">

View File

@@ -10,8 +10,13 @@ interface HeaderNavigationProps {
onLinkClick?: () => void; onLinkClick?: () => void;
} }
export function HeaderNavigation({ variant, className = '', onLinkClick }: HeaderNavigationProps) { export function HeaderNavigation({
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig(); variant,
className = '',
onLinkClick,
}: HeaderNavigationProps) {
const { isConfigured: isJiraConfigured, config: jiraConfig } =
useJiraConfig();
const pathname = usePathname(); const pathname = usePathname();
// Liste des liens de navigation // Liste des liens de navigation
@@ -20,8 +25,16 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
{ href: '/kanban', label: 'Kanban' }, { href: '/kanban', label: 'Kanban' },
{ href: '/daily', label: 'Daily' }, { href: '/daily', label: 'Daily' },
{ href: '/weekly-manager', label: 'Weekly' }, { href: '/weekly-manager', label: 'Weekly' },
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []), { href: '/notes', label: 'Notes' },
{ href: '/settings', label: 'Settings' } ...(isJiraConfigured
? [
{
href: '/jira-dashboard',
label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}`,
},
]
: []),
{ href: '/settings', label: 'Settings' },
]; ];
// Fonction pour déterminer si un lien est actif // Fonction pour déterminer si un lien est actif
@@ -34,7 +47,8 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
// Fonction pour obtenir les classes CSS d'un lien (desktop) // Fonction pour obtenir les classes CSS d'un lien (desktop)
const getLinkClasses = (href: string) => { const getLinkClasses = (href: string) => {
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-3 py-1.5 rounded-md"; const baseClasses =
'font-mono text-sm uppercase tracking-wider transition-colors px-3 py-1.5 rounded-md';
if (isActiveLink(href)) { if (isActiveLink(href)) {
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`; return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
@@ -45,7 +59,8 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
// Fonction pour obtenir les classes CSS d'un lien (mobile) // Fonction pour obtenir les classes CSS d'un lien (mobile)
const getMobileLinkClasses = (href: string) => { const getMobileLinkClasses = (href: string) => {
const baseClasses = "font-mono text-sm uppercase tracking-wider transition-colors px-4 py-3 rounded-md block w-full text-left"; const baseClasses =
'font-mono text-sm uppercase tracking-wider transition-colors px-4 py-3 rounded-md block w-full text-left';
if (isActiveLink(href)) { if (isActiveLink(href)) {
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`; return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
@@ -87,11 +102,7 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
{/* Plus d'éléments sur très grands écrans */} {/* Plus d'éléments sur très grands écrans */}
<div className="hidden 2xl:flex items-center gap-1"> <div className="hidden 2xl:flex items-center gap-1">
{navLinks.slice(4).map(({ href, label }) => ( {navLinks.slice(4).map(({ href, label }) => (
<Link <Link key={href} href={href} className={getLinkClasses(href)}>
key={href}
href={href}
className={getLinkClasses(href)}
>
{label} {label}
</Link> </Link>
))} ))}

View File

@@ -1,6 +1,12 @@
'use client'; 'use client';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
export interface KeyboardShortcut { export interface KeyboardShortcut {
@@ -20,28 +26,28 @@ const PAGE_SHORTCUTS: PageShortcuts = {
{ {
keys: ['Cmd', '?'], keys: ['Cmd', '?'],
description: 'Afficher les raccourcis clavier', description: 'Afficher les raccourcis clavier',
category: 'Navigation' category: 'Navigation',
}, },
{ {
keys: ['Shift', 'Q'], keys: ['Shift', 'Q'],
description: 'Basculer le thème', description: 'Basculer le thème',
category: 'Apparence' category: 'Apparence',
}, },
{ {
keys: ['Shift', 'W'], keys: ['Shift', 'W'],
description: 'Faire tourner les thèmes dark', description: 'Faire tourner les thèmes dark',
category: 'Apparence' category: 'Apparence',
}, },
{ {
keys: ['Shift', 'B'], keys: ['Shift', 'B'],
description: 'Changer le background', description: 'Changer le background',
category: 'Apparence' category: 'Apparence',
}, },
{ {
keys: ['Esc'], keys: ['Esc'],
description: 'Fermer les modales/annuler', description: 'Fermer les modales/annuler',
category: 'Navigation' category: 'Navigation',
} },
], ],
// Dashboard // Dashboard
@@ -49,8 +55,8 @@ const PAGE_SHORTCUTS: PageShortcuts = {
{ {
keys: ['Shift', 'K'], keys: ['Shift', 'K'],
description: 'Vers Kanban', description: 'Vers Kanban',
category: 'Navigation' category: 'Navigation',
} },
], ],
// Kanban // Kanban
@@ -58,53 +64,53 @@ const PAGE_SHORTCUTS: PageShortcuts = {
{ {
keys: ['Shift', 'N'], keys: ['Shift', 'N'],
description: 'Créer une nouvelle tâche', description: 'Créer une nouvelle tâche',
category: 'Actions' category: 'Actions',
}, },
{ {
keys: ['Space'], keys: ['Space'],
description: 'Basculer la vue compacte', description: 'Basculer la vue compacte',
category: 'Vue' category: 'Vue',
}, },
{ {
keys: ['Shift', 'F'], keys: ['Shift', 'F'],
description: 'Ouvrir les filtres', description: 'Ouvrir les filtres',
category: 'Filtres' category: 'Filtres',
}, },
{ {
keys: ['Shift', 'S'], keys: ['Shift', 'S'],
description: 'Basculer les swimlanes', description: 'Basculer les swimlanes',
category: 'Vue' category: 'Vue',
}, },
{ {
keys: ['Shift', 'O'], keys: ['Shift', 'O'],
description: 'Basculer les objectifs', description: 'Basculer les objectifs',
category: 'Vue' category: 'Vue',
}, },
{ {
keys: ['Shift', 'D'], keys: ['Shift', 'D'],
description: 'Filtrer par date de fin', description: 'Filtrer par date de fin',
category: 'Filtres' category: 'Filtres',
}, },
{ {
keys: ['Shift', 'Z'], keys: ['Shift', 'Z'],
description: 'Basculer la taille de police', description: 'Basculer la taille de police',
category: 'Vue' category: 'Vue',
}, },
{ {
keys: ['Tab'], keys: ['Tab'],
description: 'Navigation entre colonnes', description: 'Navigation entre colonnes',
category: 'Navigation' category: 'Navigation',
}, },
{ {
keys: ['Enter'], keys: ['Enter'],
description: 'Valider une tâche', description: 'Valider une tâche',
category: 'Actions' category: 'Actions',
}, },
{ {
keys: ['Esc'], keys: ['Esc'],
description: 'Annuler la création de tâche', description: 'Annuler la création de tâche',
category: 'Actions' category: 'Actions',
} },
], ],
// Daily // Daily
@@ -112,18 +118,18 @@ const PAGE_SHORTCUTS: PageShortcuts = {
{ {
keys: ['←', '→'], keys: ['←', '→'],
description: 'Navigation jour précédent/suivant', description: 'Navigation jour précédent/suivant',
category: 'Navigation' category: 'Navigation',
}, },
{ {
keys: ['Shift', 'H'], keys: ['Shift', 'H'],
description: 'Aller à aujourd\'hui', description: "Aller à aujourd'hui",
category: 'Navigation' category: 'Navigation',
}, },
{ {
keys: ['Enter'], keys: ['Enter'],
description: 'Valider un élément', description: 'Valider un élément',
category: 'Actions' category: 'Actions',
} },
], ],
// Manager // Manager
@@ -131,19 +137,153 @@ const PAGE_SHORTCUTS: PageShortcuts = {
{ {
keys: ['Cmd', 'Ctrl', 'N'], keys: ['Cmd', 'Ctrl', 'N'],
description: 'Créer un objectif', description: 'Créer un objectif',
category: 'Actions' category: 'Actions',
}, },
{ {
keys: ['←', '→'], keys: ['←', '→'],
description: 'Navigation semaine précédente/suivante', description: 'Navigation semaine précédente/suivante',
category: 'Navigation' category: 'Navigation',
}, },
{ {
keys: ['Cmd', 'Ctrl', 'T'], keys: ['Cmd', 'Ctrl', 'T'],
description: 'Aller à cette semaine', description: 'Aller à cette semaine',
category: 'Navigation' category: 'Navigation',
} },
] ],
// Notes
'/notes': [
{
keys: ['Cmd', 'N'],
description: 'Créer une nouvelle note',
category: 'Actions',
},
{
keys: ['Cmd', 'S'],
description: 'Sauvegarder la note',
category: 'Actions',
},
{
keys: ['Cmd', 'E'],
description: 'Basculer mode édition/aperçu',
category: 'Navigation',
},
{
keys: ['Cmd', 'Shift', 'P'],
description: 'Basculer aperçu',
category: 'Navigation',
},
{
keys: ['Cmd', 'B'],
description: 'Texte en gras',
category: 'Édition',
},
{
keys: ['Cmd', 'I'],
description: 'Texte en italique',
category: 'Édition',
},
{
keys: ['Cmd', 'K'],
description: 'Créer un lien',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'K'],
description: 'Code inline',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'C'],
description: 'Bloc de code',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'X'],
description: 'Texte barré',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'H'],
description: 'Titre niveau 1',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', '2'],
description: 'Titre niveau 2',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', '3'],
description: 'Titre niveau 3',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'L'],
description: 'Liste à puces',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'O'],
description: 'Liste numérotée',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'Q'],
description: 'Citation',
category: 'Édition',
},
{
keys: ['Cmd', 'Z'],
description: 'Annuler',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'Z'],
description: 'Rétablir',
category: 'Édition',
},
{
keys: ['Cmd', 'A'],
description: 'Tout sélectionner',
category: 'Édition',
},
{
keys: ['Cmd', 'C'],
description: 'Copier',
category: 'Édition',
},
{
keys: ['Cmd', 'V'],
description: 'Coller',
category: 'Édition',
},
{
keys: ['Cmd', 'X'],
description: 'Couper',
category: 'Édition',
},
{
keys: ['Cmd', 'F'],
description: 'Rechercher',
category: 'Édition',
},
{
keys: ['Cmd', 'G'],
description: 'Rechercher suivant',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'G'],
description: 'Rechercher précédent',
category: 'Édition',
},
{
keys: ['Cmd', 'Shift', 'S'],
description: 'Basculer la barre latérale',
category: 'Navigation',
},
],
}; };
interface KeyboardShortcutsContextType { interface KeyboardShortcutsContextType {
@@ -154,13 +294,17 @@ interface KeyboardShortcutsContextType {
toggleModal: () => void; toggleModal: () => void;
} }
const KeyboardShortcutsContext = createContext<KeyboardShortcutsContextType | undefined>(undefined); const KeyboardShortcutsContext = createContext<
KeyboardShortcutsContextType | undefined
>(undefined);
interface KeyboardShortcutsProviderProps { interface KeyboardShortcutsProviderProps {
children: ReactNode; children: ReactNode;
} }
export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProviderProps) { export function KeyboardShortcutsProvider({
children,
}: KeyboardShortcutsProviderProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
@@ -211,7 +355,7 @@ export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProvide
shortcuts, shortcuts,
openModal: () => setIsOpen(true), openModal: () => setIsOpen(true),
closeModal: () => setIsOpen(false), closeModal: () => setIsOpen(false),
toggleModal: () => setIsOpen(prev => !prev) toggleModal: () => setIsOpen((prev) => !prev),
}; };
return ( return (
@@ -224,7 +368,9 @@ export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProvide
export function useKeyboardShortcutsModal() { export function useKeyboardShortcutsModal() {
const context = useContext(KeyboardShortcutsContext); const context = useContext(KeyboardShortcutsContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useKeyboardShortcutsModal must be used within a KeyboardShortcutsProvider'); throw new Error(
'useKeyboardShortcutsModal must be used within a KeyboardShortcutsProvider'
);
} }
return context; return context;
} }

272
src/services/notes.ts Normal file
View File

@@ -0,0 +1,272 @@
import { prisma } from '@/services/core/database';
export interface Note {
id: string;
title: string;
content: string;
userId: string;
createdAt: Date;
updatedAt: Date;
tags?: string[];
}
export interface CreateNoteData {
title: string;
content: string;
userId: string;
tags?: string[];
}
export interface UpdateNoteData {
title?: string;
content?: string;
tags?: string[];
}
/**
* Service pour la gestion des notes markdown
*/
export class NotesService {
/**
* Récupère toutes les notes d'un utilisateur
*/
async getNotes(userId: string): Promise<Note[]> {
const notes = await prisma.note.findMany({
where: { userId },
include: {
noteTags: {
include: {
tag: true,
},
},
},
orderBy: { updatedAt: 'desc' },
});
return notes.map((note) => ({
...note,
tags: note.noteTags.map((nt) => nt.tag.name),
}));
}
/**
* Récupère une note par son ID
*/
async getNoteById(noteId: string, userId: string): Promise<Note | null> {
const note = await prisma.note.findFirst({
where: {
id: noteId,
userId,
},
include: {
noteTags: {
include: {
tag: true,
},
},
},
});
if (!note) return null;
return {
...note,
tags: note.noteTags.map((nt) => nt.tag.name),
};
}
/**
* Crée une nouvelle note
*/
async createNote(data: CreateNoteData): Promise<Note> {
const note = await prisma.note.create({
data: {
title: data.title,
content: data.content,
userId: data.userId,
noteTags: data.tags
? {
create: data.tags.map((tagName) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName },
},
},
})),
}
: undefined,
},
include: {
noteTags: {
include: {
tag: true,
},
},
},
});
return {
...note,
tags: note.noteTags.map((nt) => nt.tag.name),
};
}
/**
* Met à jour une note existante
*/
async updateNote(
noteId: string,
userId: string,
data: UpdateNoteData
): Promise<Note> {
// Vérifier que la note appartient à l'utilisateur
const existingNote = await prisma.note.findFirst({
where: {
id: noteId,
userId,
},
});
if (!existingNote) {
throw new Error('Note not found or access denied');
}
// Préparer les données de mise à jour
const updateData: {
updatedAt: Date;
title?: string;
content?: string;
noteTags?: {
deleteMany: Record<string, never>;
create: Array<{
tag: {
connectOrCreate: {
where: { name: string };
create: { name: string };
};
};
}>;
};
} = {
updatedAt: new Date(),
};
// Ajouter les champs de base s'ils sont fournis
if (data.title !== undefined) {
updateData.title = data.title;
}
if (data.content !== undefined) {
updateData.content = data.content;
}
// Gérer les tags si fournis
if (data.tags !== undefined) {
updateData.noteTags = {
deleteMany: {}, // Supprimer tous les tags existants
create: data.tags.map((tagName) => ({
tag: {
connectOrCreate: {
where: { name: tagName },
create: { name: tagName },
},
},
})),
};
}
const note = await prisma.note.update({
where: { id: noteId },
data: updateData,
include: {
noteTags: {
include: {
tag: true,
},
},
},
});
return {
...note,
tags: note.noteTags.map((nt) => nt.tag.name),
};
}
/**
* Supprime une note
*/
async deleteNote(noteId: string, userId: string): Promise<void> {
// Vérifier que la note appartient à l'utilisateur
const existingNote = await prisma.note.findFirst({
where: {
id: noteId,
userId,
},
});
if (!existingNote) {
throw new Error('Note not found or access denied');
}
await prisma.note.delete({
where: { id: noteId },
});
}
/**
* Recherche des notes par titre ou contenu
*/
async searchNotes(userId: string, query: string): Promise<Note[]> {
const notes = await prisma.note.findMany({
where: {
userId,
OR: [{ title: { contains: query } }, { content: { contains: query } }],
},
orderBy: { updatedAt: 'desc' },
});
return notes;
}
/**
* Récupère les statistiques des notes d'un utilisateur
*/
async getNotesStats(userId: string): Promise<{
totalNotes: number;
totalWords: number;
lastUpdated: Date | null;
}> {
const notes = await prisma.note.findMany({
where: { userId },
select: {
content: true,
updatedAt: true,
},
});
const totalNotes = notes.length;
const totalWords = notes.reduce((acc, note) => {
return (
acc + note.content.split(/\s+/).filter((word) => word.length > 0).length
);
}, 0);
const lastUpdated =
notes.length > 0
? notes.reduce(
(latest, note) =>
note.updatedAt > latest ? note.updatedAt : latest,
notes[0].updatedAt
)
: null;
return {
totalNotes,
totalWords,
lastUpdated,
};
}
}
// Instance singleton
export const notesService = new NotesService();