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:clear": "npx tsx scripts/cache-monitor.ts clear",
|
||||
"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": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -39,7 +42,11 @@
|
||||
"prisma": "^6.16.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"twemoji": "^14.0.2"
|
||||
},
|
||||
@@ -52,8 +59,16 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"husky": "^9.1.7",
|
||||
"knip": "^5.64.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"prettier --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
preferences UserPreferences?
|
||||
notes Note[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -62,6 +63,7 @@ model Tag {
|
||||
isPinned Boolean @default(false)
|
||||
taskTags TaskTag[]
|
||||
primaryTasks Task[] @relation("PrimaryTag")
|
||||
noteTags NoteTag[]
|
||||
|
||||
@@map("tags")
|
||||
}
|
||||
@@ -121,3 +123,24 @@ model UserPreferences {
|
||||
|
||||
@@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>
|
||||
);
|
||||
}
|
||||
@@ -47,22 +47,26 @@ export function KeyboardShortcutsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
shortcuts,
|
||||
title = "Raccourcis clavier"
|
||||
title = 'Raccourcis clavier',
|
||||
}: KeyboardShortcutsModalProps) {
|
||||
// Grouper les raccourcis par catégorie
|
||||
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
|
||||
const groupedShortcuts = shortcuts.reduce(
|
||||
(acc, shortcut) => {
|
||||
const category = shortcut.category || 'Général';
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(shortcut);
|
||||
return acc;
|
||||
}, {} as Record<string, KeyboardShortcut[]>);
|
||||
},
|
||||
{} as Record<string, KeyboardShortcut[]>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={title} size="md">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
|
||||
<div className="max-h-96 overflow-y-auto space-y-4">
|
||||
{Object.entries(groupedShortcuts).map(
|
||||
([category, categoryShortcuts]) => (
|
||||
<div key={category}>
|
||||
<h3 className="text-xs font-mono font-semibold text-[var(--primary)] uppercase tracking-wider mb-2">
|
||||
{category}
|
||||
@@ -73,7 +77,8 @@ export function KeyboardShortcutsModal({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
|
||||
{shortcuts.length === 0 && (
|
||||
<div className="text-center py-6">
|
||||
|
||||
@@ -10,8 +10,13 @@ interface HeaderNavigationProps {
|
||||
onLinkClick?: () => void;
|
||||
}
|
||||
|
||||
export function HeaderNavigation({ variant, className = '', onLinkClick }: HeaderNavigationProps) {
|
||||
const { isConfigured: isJiraConfigured, config: jiraConfig } = useJiraConfig();
|
||||
export function HeaderNavigation({
|
||||
variant,
|
||||
className = '',
|
||||
onLinkClick,
|
||||
}: HeaderNavigationProps) {
|
||||
const { isConfigured: isJiraConfigured, config: jiraConfig } =
|
||||
useJiraConfig();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Liste des liens de navigation
|
||||
@@ -20,8 +25,16 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
|
||||
{ href: '/kanban', label: 'Kanban' },
|
||||
{ href: '/daily', label: 'Daily' },
|
||||
{ href: '/weekly-manager', label: 'Weekly' },
|
||||
...(isJiraConfigured ? [{ href: '/jira-dashboard', label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}` }] : []),
|
||||
{ href: '/settings', label: 'Settings' }
|
||||
{ href: '/notes', label: 'Notes' },
|
||||
...(isJiraConfigured
|
||||
? [
|
||||
{
|
||||
href: '/jira-dashboard',
|
||||
label: `Jira${jiraConfig?.projectKey ? ` (${jiraConfig.projectKey})` : ''}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ href: '/settings', label: 'Settings' },
|
||||
];
|
||||
|
||||
// Fonction pour déterminer si un lien est actif
|
||||
@@ -34,7 +47,8 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (desktop)
|
||||
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)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
@@ -45,7 +59,8 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
|
||||
|
||||
// Fonction pour obtenir les classes CSS d'un lien (mobile)
|
||||
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)) {
|
||||
return `${baseClasses} text-[var(--primary)] bg-[var(--primary)]/10 border border-[var(--primary)]/30`;
|
||||
@@ -87,11 +102,7 @@ export function HeaderNavigation({ variant, className = '', onLinkClick }: Heade
|
||||
{/* Plus d'éléments sur très grands écrans */}
|
||||
<div className="hidden 2xl:flex items-center gap-1">
|
||||
{navLinks.slice(4).map(({ href, label }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={getLinkClasses(href)}
|
||||
>
|
||||
<Link key={href} href={href} className={getLinkClasses(href)}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
@@ -20,28 +26,28 @@ const PAGE_SHORTCUTS: PageShortcuts = {
|
||||
{
|
||||
keys: ['Cmd', '?'],
|
||||
description: 'Afficher les raccourcis clavier',
|
||||
category: 'Navigation'
|
||||
category: 'Navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'Q'],
|
||||
description: 'Basculer le thème',
|
||||
category: 'Apparence'
|
||||
category: 'Apparence',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'W'],
|
||||
description: 'Faire tourner les thèmes dark',
|
||||
category: 'Apparence'
|
||||
category: 'Apparence',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'B'],
|
||||
description: 'Changer le background',
|
||||
category: 'Apparence'
|
||||
category: 'Apparence',
|
||||
},
|
||||
{
|
||||
keys: ['Esc'],
|
||||
description: 'Fermer les modales/annuler',
|
||||
category: 'Navigation'
|
||||
}
|
||||
category: 'Navigation',
|
||||
},
|
||||
],
|
||||
|
||||
// Dashboard
|
||||
@@ -49,8 +55,8 @@ const PAGE_SHORTCUTS: PageShortcuts = {
|
||||
{
|
||||
keys: ['Shift', 'K'],
|
||||
description: 'Vers Kanban',
|
||||
category: 'Navigation'
|
||||
}
|
||||
category: 'Navigation',
|
||||
},
|
||||
],
|
||||
|
||||
// Kanban
|
||||
@@ -58,53 +64,53 @@ const PAGE_SHORTCUTS: PageShortcuts = {
|
||||
{
|
||||
keys: ['Shift', 'N'],
|
||||
description: 'Créer une nouvelle tâche',
|
||||
category: 'Actions'
|
||||
category: 'Actions',
|
||||
},
|
||||
{
|
||||
keys: ['Space'],
|
||||
description: 'Basculer la vue compacte',
|
||||
category: 'Vue'
|
||||
category: 'Vue',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'F'],
|
||||
description: 'Ouvrir les filtres',
|
||||
category: 'Filtres'
|
||||
category: 'Filtres',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'S'],
|
||||
description: 'Basculer les swimlanes',
|
||||
category: 'Vue'
|
||||
category: 'Vue',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'O'],
|
||||
description: 'Basculer les objectifs',
|
||||
category: 'Vue'
|
||||
category: 'Vue',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'D'],
|
||||
description: 'Filtrer par date de fin',
|
||||
category: 'Filtres'
|
||||
category: 'Filtres',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'Z'],
|
||||
description: 'Basculer la taille de police',
|
||||
category: 'Vue'
|
||||
category: 'Vue',
|
||||
},
|
||||
{
|
||||
keys: ['Tab'],
|
||||
description: 'Navigation entre colonnes',
|
||||
category: 'Navigation'
|
||||
category: 'Navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Enter'],
|
||||
description: 'Valider une tâche',
|
||||
category: 'Actions'
|
||||
category: 'Actions',
|
||||
},
|
||||
{
|
||||
keys: ['Esc'],
|
||||
description: 'Annuler la création de tâche',
|
||||
category: 'Actions'
|
||||
}
|
||||
category: 'Actions',
|
||||
},
|
||||
],
|
||||
|
||||
// Daily
|
||||
@@ -112,18 +118,18 @@ const PAGE_SHORTCUTS: PageShortcuts = {
|
||||
{
|
||||
keys: ['←', '→'],
|
||||
description: 'Navigation jour précédent/suivant',
|
||||
category: 'Navigation'
|
||||
category: 'Navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Shift', 'H'],
|
||||
description: 'Aller à aujourd\'hui',
|
||||
category: 'Navigation'
|
||||
description: "Aller à aujourd'hui",
|
||||
category: 'Navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Enter'],
|
||||
description: 'Valider un élément',
|
||||
category: 'Actions'
|
||||
}
|
||||
category: 'Actions',
|
||||
},
|
||||
],
|
||||
|
||||
// Manager
|
||||
@@ -131,19 +137,153 @@ const PAGE_SHORTCUTS: PageShortcuts = {
|
||||
{
|
||||
keys: ['Cmd', 'Ctrl', 'N'],
|
||||
description: 'Créer un objectif',
|
||||
category: 'Actions'
|
||||
category: 'Actions',
|
||||
},
|
||||
{
|
||||
keys: ['←', '→'],
|
||||
description: 'Navigation semaine précédente/suivante',
|
||||
category: 'Navigation'
|
||||
category: 'Navigation',
|
||||
},
|
||||
{
|
||||
keys: ['Cmd', 'Ctrl', 'T'],
|
||||
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 {
|
||||
@@ -154,13 +294,17 @@ interface KeyboardShortcutsContextType {
|
||||
toggleModal: () => void;
|
||||
}
|
||||
|
||||
const KeyboardShortcutsContext = createContext<KeyboardShortcutsContextType | undefined>(undefined);
|
||||
const KeyboardShortcutsContext = createContext<
|
||||
KeyboardShortcutsContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
interface KeyboardShortcutsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProviderProps) {
|
||||
export function KeyboardShortcutsProvider({
|
||||
children,
|
||||
}: KeyboardShortcutsProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -211,7 +355,7 @@ export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProvide
|
||||
shortcuts,
|
||||
openModal: () => setIsOpen(true),
|
||||
closeModal: () => setIsOpen(false),
|
||||
toggleModal: () => setIsOpen(prev => !prev)
|
||||
toggleModal: () => setIsOpen((prev) => !prev),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -224,7 +368,9 @@ export function KeyboardShortcutsProvider({ children }: KeyboardShortcutsProvide
|
||||
export function useKeyboardShortcutsModal() {
|
||||
const context = useContext(KeyboardShortcutsContext);
|
||||
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;
|
||||
}
|
||||
|
||||
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