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:
3
.husky/pre-commit
Executable file
3
.husky/pre-commit
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||||
|
lint-staged
|
||||||
2213
package-lock.json
generated
2213
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
115
src/app/api/notes/[id]/route.ts
Normal file
115
src/app/api/notes/[id]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/app/api/notes/route.ts
Normal file
73
src/app/api/notes/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/app/notes/NotesPageClient.tsx
Normal file
289
src/app/notes/NotesPageClient.tsx
Normal 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
41
src/app/notes/page.tsx
Normal 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
95
src/clients/notes.ts
Normal 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();
|
||||||
710
src/components/notes/MarkdownEditor.tsx
Normal file
710
src/components/notes/MarkdownEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,38 +43,43 @@ function ShortcutRow({ shortcut }: { shortcut: KeyboardShortcut }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KeyboardShortcutsModal({
|
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">
|
||||||
<p className="text-xs text-[var(--muted-foreground)] font-mono">
|
<p className="text-xs text-[var(--muted-foreground)] font-mono">
|
||||||
@@ -83,7 +88,7 @@ export function KeyboardShortcutsModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer avec info */}
|
{/* Footer avec info */}
|
||||||
<div className="mt-4 pt-3 border-t border-[var(--border)]/50">
|
<div className="mt-4 pt-3 border-t border-[var(--border)]/50">
|
||||||
<p className="text-xs text-[var(--muted-foreground)] font-mono text-center">
|
<p className="text-xs text-[var(--muted-foreground)] font-mono text-center">
|
||||||
|
|||||||
@@ -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,23 +47,25 @@ 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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover)]`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover"]`;
|
return `${baseClasses} text-[var(--muted-foreground)] hover:text-[var(--primary)] hover:bg-[var(--card-hover"]`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -83,15 +98,11 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
|
|||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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,130 +26,264 @@ 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
|
||||||
'/': [
|
'/': [
|
||||||
{
|
{
|
||||||
keys: ['Shift', 'K'],
|
keys: ['Shift', 'K'],
|
||||||
description: 'Vers Kanban',
|
description: 'Vers Kanban',
|
||||||
category: 'Navigation'
|
category: 'Navigation',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Kanban
|
// Kanban
|
||||||
'/kanban': [
|
'/kanban': [
|
||||||
{
|
{
|
||||||
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
|
||||||
'/daily': [
|
'/daily': [
|
||||||
{
|
{
|
||||||
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
|
||||||
'/weekly-manager': [
|
'/weekly-manager': [
|
||||||
{
|
{
|
||||||
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,36 +294,40 @@ 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();
|
||||||
|
|
||||||
// Obtenir les raccourcis pour la page actuelle
|
// Obtenir les raccourcis pour la page actuelle
|
||||||
const getCurrentPageShortcuts = (): KeyboardShortcut[] => {
|
const getCurrentPageShortcuts = (): KeyboardShortcut[] => {
|
||||||
const shortcuts: KeyboardShortcut[] = [];
|
const shortcuts: KeyboardShortcut[] = [];
|
||||||
|
|
||||||
// Ajouter les raccourcis globaux
|
// Ajouter les raccourcis globaux
|
||||||
if (PAGE_SHORTCUTS['*']) {
|
if (PAGE_SHORTCUTS['*']) {
|
||||||
shortcuts.push(...PAGE_SHORTCUTS['*']);
|
shortcuts.push(...PAGE_SHORTCUTS['*']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter les raccourcis spécifiques à la page
|
// Ajouter les raccourcis spécifiques à la page
|
||||||
const pageShortcuts = PAGE_SHORTCUTS[pathname];
|
const pageShortcuts = PAGE_SHORTCUTS[pathname];
|
||||||
if (pageShortcuts) {
|
if (pageShortcuts) {
|
||||||
shortcuts.push(...pageShortcuts);
|
shortcuts.push(...pageShortcuts);
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts;
|
return shortcuts;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shortcuts = getCurrentPageShortcuts();
|
const shortcuts = getCurrentPageShortcuts();
|
||||||
|
|
||||||
// Gérer le raccourci Cmd+? pour ouvrir la popup
|
// Gérer le raccourci Cmd+? pour ouvrir la popup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
@@ -192,28 +336,28 @@ export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProvide
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esc pour fermer
|
// Esc pour fermer
|
||||||
if (event.key === 'Escape' && isOpen) {
|
if (event.key === 'Escape' && isOpen) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeyDown);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const contextValue: KeyboardShortcutsContextType = {
|
const contextValue: KeyboardShortcutsContextType = {
|
||||||
isOpen,
|
isOpen,
|
||||||
shortcuts,
|
shortcuts,
|
||||||
openModal: () => setIsOpen(true),
|
openModal: () => setIsOpen(true),
|
||||||
closeModal: () => setIsOpen(false),
|
closeModal: () => setIsOpen(false),
|
||||||
toggleModal: () => setIsOpen(prev => !prev)
|
toggleModal: () => setIsOpen((prev) => !prev),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KeyboardShortcutsContext.Provider value={contextValue}>
|
<KeyboardShortcutsContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
@@ -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
272
src/services/notes.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user