Merge branch 'main' into antigravity - Resolve conflict in NotesPage.tsx (Archive fix + Kanban)

This commit is contained in:
Michael committed 2025-12-08 08:46:23 +01:00
commit ad3f415469
50 files changed
+4916 -359

No files matched your search

+1 -1
View File
@@ -75,7 +75,7 @@ const App = () => {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="light" storageKey="notes-theme">
<ThemeProvider defaultTheme="system" storageKey="notes-theme">
<TooltipProvider>
<BrowserRouter>
<Routes>
+283
View File
@@ -0,0 +1,283 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Calendar } from "@/components/ui/calendar";
import { SlidersHorizontal, X, Calendar as CalendarIcon, Tag as TagIcon } from "lucide-react";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
export interface SearchFilters {
tags: string[]; // Tag names
dateFrom?: Date;
dateTo?: Date;
hasTodos?: boolean;
hasImages?: boolean;
hasFiles?: boolean;
priority?: boolean;
}
export interface TagOption {
id: number;
name: string;
}
interface AdvancedSearchProps {
filters: SearchFilters;
onFiltersChange: (filters: SearchFilters) => void;
availableTags: TagOption[];
}
export function AdvancedSearch({ filters, onFiltersChange, availableTags }: AdvancedSearchProps) {
const [open, setOpen] = useState(false);
const [datePickerOpen, setDatePickerOpen] = useState<'from' | 'to' | null>(null);
const [tagSelectorOpen, setTagSelectorOpen] = useState(false);
const hasActiveFilters = filters.tags.length > 0 ||
filters.dateFrom !== undefined ||
filters.dateTo !== undefined ||
filters.hasTodos !== undefined ||
filters.hasImages !== undefined ||
filters.hasFiles !== undefined ||
filters.priority !== undefined;
const clearFilters = () => {
onFiltersChange({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
};
const toggleTag = (tagName: string) => {
const newTags = filters.tags.includes(tagName)
? filters.tags.filter(name => name !== tagName)
: [...filters.tags, tagName];
onFiltersChange({ ...filters, tags: newTags });
};
const selectedTags = availableTags.filter(tag => filters.tags.includes(tag.name));
return (
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<SlidersHorizontal className="h-4 w-4" />
Filtres avancés
{hasActiveFilters && (
<Badge variant="secondary" className="ml-1 px-1.5">
{filters.tags.length +
(filters.dateFrom ? 1 : 0) +
(filters.dateTo ? 1 : 0) +
(filters.hasTodos ? 1 : 0) +
(filters.hasImages ? 1 : 0) +
(filters.hasFiles ? 1 : 0) +
(filters.priority ? 1 : 0)}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="font-medium text-sm">Filtres avancés</h4>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Réinitialiser
</Button>
)}
</div>
{/* Tags Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Tags</Label>
<Popover open={tagSelectorOpen} onOpenChange={setTagSelectorOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-start gap-2">
<TagIcon className="h-4 w-4" />
{selectedTags.length > 0 ? (
<span className="text-xs">
{selectedTags.length} tag{selectedTags.length > 1 ? 's' : ''} sélectionné{selectedTags.length > 1 ? 's' : ''}
</span>
) : (
<span className="text-xs text-muted-foreground">Sélectionner des tags</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-0" align="start">
<Command>
<CommandInput placeholder="Rechercher un tag..." />
<CommandList>
<CommandEmpty>Aucun tag trouvé</CommandEmpty>
<CommandGroup>
{availableTags.map((tag) => (
<CommandItem
key={tag.id}
onSelect={() => toggleTag(tag.name)}
className="cursor-pointer"
>
<Checkbox
checked={filters.tags.includes(tag.name)}
className="mr-2"
/>
<TagIcon className="h-3 w-3 mr-2" />
{tag.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTags.map((tag) => (
<Badge key={tag.id} variant="secondary" className="text-xs gap-1">
{tag.name}
<X
className="h-3 w-3 cursor-pointer"
onClick={() => toggleTag(tag.name)}
/>
</Badge>
))}
</div>
)}
</div>
{/* Date Range Filter */}
<div className="space-y-2">
<Label className="text-xs font-medium">Période</Label>
<div className="grid grid-cols-2 gap-2">
<Popover open={datePickerOpen === 'from'} onOpenChange={(open) => setDatePickerOpen(open ? 'from' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateFrom ? filters.dateFrom.toLocaleDateString('fr-FR') : 'Du'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateFrom}
onSelect={(date) => {
onFiltersChange({ ...filters, dateFrom: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
<Popover open={datePickerOpen === 'to'} onOpenChange={(open) => setDatePickerOpen(open ? 'to' : null)}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="justify-start text-xs">
<CalendarIcon className="h-3 w-3 mr-2" />
{filters.dateTo ? filters.dateTo.toLocaleDateString('fr-FR') : 'Au'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={filters.dateTo}
onSelect={(date) => {
onFiltersChange({ ...filters, dateTo: date });
setDatePickerOpen(null);
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
{(filters.dateFrom || filters.dateTo) && (
<Button
variant="ghost"
size="sm"
onClick={() => onFiltersChange({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
>
Effacer les dates
</Button>
)}
</div>
{/* Content Type Filters */}
<div className="space-y-2">
<Label className="text-xs font-medium">Contenu</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="hasTodos"
checked={filters.hasTodos === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasTodos: checked ? true : undefined })
}
/>
<label htmlFor="hasTodos" className="text-xs cursor-pointer">
Avec des tâches
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasImages"
checked={filters.hasImages === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasImages: checked ? true : undefined })
}
/>
<label htmlFor="hasImages" className="text-xs cursor-pointer">
Avec des images
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, hasFiles: checked ? true : undefined })
}
/>
<label htmlFor="hasFiles" className="text-xs cursor-pointer">
Avec des fichiers
</label>
</div>
</div>
</div>
{/* Priority Filter */}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id="priority"
checked={filters.priority === true}
onCheckedChange={(checked) =>
onFiltersChange({ ...filters, priority: checked ? true : undefined })
}
/>
<label htmlFor="priority" className="text-xs cursor-pointer font-medium">
Notes prioritaires uniquement
</label>
</div>
</div>
</div>
</PopoverContent>
</Popover>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-8 gap-1">
<X className="h-3 w-3" />
Effacer
</Button>
)}
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Download, Upload, FileJson, FileSpreadsheet, AlertCircle, CheckCircle } from "lucide-react";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
import {
exportToJSON,
downloadBackup,
downloadCSV,
exportNotesToCSV,
parseBackupJSON,
readFileAsText,
getBackupStats,
type BackupData
} from "@/utils/exportImport";
import { showSuccess, showError } from "@/utils/toast";
interface ExportImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
notes: Note[];
todos: Todo[];
settings?: Record<string, any>;
onImport?: (backup: BackupData) => Promise<void>;
}
export function ExportImportModal({
open,
onOpenChange,
notes,
todos,
settings,
onImport
}: ExportImportModalProps) {
const [importing, setImporting] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [previewData, setPreviewData] = useState<BackupData | null>(null);
const handleExportJSON = async () => {
try {
const jsonData = await exportToJSON(notes, todos, settings);
downloadBackup(jsonData);
showSuccess('Backup exporté avec succès');
} catch (error) {
showError('Erreur lors de l\'export');
}
};
const handleExportCSV = () => {
try {
const csvData = exportNotesToCSV(notes);
downloadCSV(csvData);
showSuccess('CSV exporté avec succès');
} catch (error) {
showError('Erreur lors de l\'export CSV');
}
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImportFile(file);
try {
const content = await readFileAsText(file);
const backup = parseBackupJSON(content);
if (backup) {
setPreviewData(backup);
} else {
showError('Fichier de backup invalide');
setImportFile(null);
}
} catch (error) {
showError('Erreur lors de la lecture du fichier');
setImportFile(null);
}
};
const handleImport = async () => {
if (!previewData || !onImport) return;
setImporting(true);
try {
await onImport(previewData);
showSuccess('Données importées avec succès');
setImportFile(null);
setPreviewData(null);
onOpenChange(false);
} catch (error) {
showError('Erreur lors de l\'import');
} finally {
setImporting(false);
}
};
const stats = notes.length > 0 ? {
notesCount: notes.length,
archivedCount: notes.filter(n => n.archived).length,
priorityCount: notes.filter(n => n.priority).length,
todosCount: todos.length,
tagsCount: new Set(notes.flatMap(n => n.tags?.map(t => t.name) || [])).size
} : null;
const previewStats = previewData ? getBackupStats(previewData) : null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Export / Import</DialogTitle>
<DialogDescription>
Exportez ou importez toutes vos données
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="export">
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="export">Export</TabsTrigger>
<TabsTrigger value="import">Import</TabsTrigger>
</TabsList>
<TabsContent value="export" className="space-y-4">
{/* Current Data Stats */}
{stats && (
<div className="grid grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.notesCount}</div>
<div className="text-xs text-muted-foreground">Notes</div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.todosCount}</div>
<div className="text-xs text-muted-foreground">Tâches</div>
</div>
<div className="p-4 border rounded-lg">
<div className="text-2xl font-bold">{stats.tagsCount}</div>
<div className="text-xs text-muted-foreground">Tags</div>
</div>
</div>
)}
{/* Export Options */}
<div className="space-y-3">
<div>
<Label className="text-sm font-medium mb-2 block">Format JSON (Backup complet)</Label>
<Button onClick={handleExportJSON} className="w-full" variant="outline">
<FileJson className="h-4 w-4 mr-2" />
Exporter en JSON
</Button>
<p className="text-xs text-muted-foreground mt-1">
Inclut toutes les notes, tâches, tags, et paramètres
</p>
</div>
<div>
<Label className="text-sm font-medium mb-2 block">Format CSV (Notes uniquement)</Label>
<Button onClick={handleExportCSV} className="w-full" variant="outline">
<FileSpreadsheet className="h-4 w-4 mr-2" />
Exporter en CSV
</Button>
<p className="text-xs text-muted-foreground mt-1">
Tableau des notes pour Excel ou Google Sheets
</p>
</div>
</div>
<Alert>
<Download className="h-4 w-4" />
<AlertDescription>
Les fichiers seront téléchargés dans votre dossier de téléchargements
</AlertDescription>
</Alert>
</TabsContent>
<TabsContent value="import" className="space-y-4">
{/* File Upload */}
<div>
<Label htmlFor="import-file" className="text-sm font-medium mb-2 block">
Sélectionner un fichier de backup (JSON)
</Label>
<Input
id="import-file"
type="file"
accept=".json"
onChange={handleFileSelect}
/>
</div>
{/* Preview */}
{previewData && previewStats && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium">Backup valide</span>
<Badge variant="outline">{previewData.version}</Badge>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.notesCount}</div>
<div className="text-xs text-muted-foreground">Notes</div>
</div>
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.todosCount}</div>
<div className="text-xs text-muted-foreground">Tâches</div>
</div>
<div className="p-3 border rounded-lg">
<div className="text-xl font-bold">{previewStats.tagsCount}</div>
<div className="text-xs text-muted-foreground">Tags</div>
</div>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Créé le {new Date(previewData.exportDate).toLocaleDateString('fr-FR')}
</AlertDescription>
</Alert>
<Button
onClick={handleImport}
disabled={importing || !onImport}
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
{importing ? 'Import en cours...' : 'Importer les données'}
</Button>
{!onImport && (
<p className="text-xs text-muted-foreground text-center">
La fonction d'import n'est pas disponible
</p>
)}
</div>
)}
{!previewData && !importFile && (
<div className="text-center py-8 text-muted-foreground">
<Upload className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Sélectionnez un fichier de backup pour commencer</p>
</div>
)}
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, Plus, GripVertical } from "lucide-react";
import { Note } from "@/services/NotesService";
interface KanbanColumn {
id: string;
title: string;
color: string;
}
const DEFAULT_COLUMNS: KanbanColumn[] = [
{ id: 'backlog', title: 'Backlog', color: 'bg-gray-100 dark:bg-gray-800' },
{ id: 'todo', title: 'À faire', color: 'bg-blue-100 dark:bg-blue-900' },
{ id: 'in_progress', title: 'En cours', color: 'bg-yellow-100 dark:bg-yellow-900' },
{ id: 'review', title: 'En révision', color: 'bg-purple-100 dark:bg-purple-900' },
{ id: 'done', title: 'Terminé', color: 'bg-green-100 dark:bg-green-900' },
];
interface KanbanBoardProps {
notes: Note[];
onNoteClick: (note: Note) => void;
onCreateNote: (columnId: string) => void;
onMoveNote?: (noteId: number, newStatus: string) => void;
}
export function KanbanBoard({ notes, onNoteClick, onCreateNote, onMoveNote }: KanbanBoardProps) {
const [draggedNote, setDraggedNote] = useState<Note | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<string | null>(null);
// Group notes by their kanban_status tag or default to 'backlog'
const getNoteStatus = (note: Note): string => {
const statusTag = note.tags?.find(tag =>
DEFAULT_COLUMNS.some(col => col.id === tag.name.toLowerCase())
);
return statusTag?.name.toLowerCase() || 'backlog';
};
const notesByColumn = DEFAULT_COLUMNS.reduce((acc, column) => {
acc[column.id] = notes.filter(note =>
!note.archived && getNoteStatus(note) === column.id
);
return acc;
}, {} as Record<string, Note[]>);
const handleDragStart = (note: Note) => {
setDraggedNote(note);
};
const handleDragOver = (e: React.DragEvent, columnId: string) => {
e.preventDefault();
setDragOverColumn(columnId);
};
const handleDragLeave = () => {
setDragOverColumn(null);
};
const handleDrop = (e: React.DragEvent, columnId: string) => {
e.preventDefault();
setDragOverColumn(null);
if (draggedNote && onMoveNote && getNoteStatus(draggedNote) !== columnId) {
onMoveNote(draggedNote.id!, columnId);
}
setDraggedNote(null);
};
const handleDragEnd = () => {
setDraggedNote(null);
setDragOverColumn(null);
};
return (
<div className="flex gap-4 h-full overflow-x-auto pb-4">
{DEFAULT_COLUMNS.map((column) => (
<div
key={column.id}
className="flex-shrink-0 w-80"
onDragOver={(e) => handleDragOver(e, column.id)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, column.id)}
>
<Card className={`h-full flex flex-col ${dragOverColumn === column.id ? 'ring-2 ring-primary' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<div className={`w-3 h-3 rounded-full ${column.color}`} />
{column.title}
<Badge variant="secondary" className="ml-1">
{notesByColumn[column.id]?.length || 0}
</Badge>
</CardTitle>
<Button
size="sm"
variant="ghost"
onClick={() => onCreateNote(column.id)}
className="h-7 w-7 p-0"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 pt-0">
<ScrollArea className="h-[calc(100vh-250px)]">
<div className="space-y-2 pr-4">
{notesByColumn[column.id]?.map((note) => (
<div
key={note.id}
draggable
onDragStart={() => handleDragStart(note)}
onDragEnd={handleDragEnd}
onClick={() => onNoteClick(note)}
className={`p-3 border rounded-lg cursor-move hover:shadow-md transition-all group ${
draggedNote?.id === note.id ? 'opacity-50' : ''
} ${note.priority ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start gap-2 mb-2">
<GripVertical className="h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm line-clamp-2 mb-1">
{note.title || "Sans titre"}
</h4>
{note.content && (
<p className="text-xs text-muted-foreground line-clamp-2">
{note.content.replace(/<[^>]*>/g, '')}
</p>
)}
</div>
</div>
{/* Metadata badges */}
<div className="flex flex-wrap gap-1 mt-2">
{note.todos && note.todos.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<CheckSquare className="h-3 w-3 mr-1" />
{note.todos.filter(t => t.completed).length}/{note.todos.length}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs h-5">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs h-5">
<TagIcon className="h-2 w-2 mr-1" />
{tag.name}
</Badge>
))}
{note.tags.length > 2 && (
<Badge variant="outline" className="text-xs h-5">
+{note.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
{/* Date */}
{note.updated_at && (
<p className="text-xs text-muted-foreground mt-2">
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short'
})}
</p>
)}
</div>
))}
{notesByColumn[column.id]?.length === 0 && (
<div className="text-center py-8 text-muted-foreground text-sm">
Aucune note
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
))}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Keyboard } from "lucide-react";
import { type KeyboardShortcut, formatShortcut } from "@/hooks/useKeyboardShortcuts";
interface KeyboardShortcutsHelpProps {
open: boolean;
onOpenChange: (open: boolean) => void;
shortcuts: KeyboardShortcut[];
}
export function KeyboardShortcutsHelp({ open, onOpenChange, shortcuts }: KeyboardShortcutsHelpProps) {
// Group shortcuts by category
const groupedShortcuts = shortcuts.reduce((acc, shortcut) => {
// Determine category based on description
let category = 'Général';
if (shortcut.description.toLowerCase().includes('note')) {
category = 'Notes';
} else if (shortcut.description.toLowerCase().includes('recherch')) {
category = 'Recherche';
} else if (shortcut.description.toLowerCase().includes('todo') || shortcut.description.toLowerCase().includes('tâche')) {
category = 'Tâches';
} else if (shortcut.description.toLowerCase().includes('navigation')) {
category = 'Navigation';
} else if (shortcut.description.toLowerCase().includes('édition') || shortcut.description.toLowerCase().includes('gras') || shortcut.description.toLowerCase().includes('italique')) {
category = 'Édition';
}
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(shortcut);
return acc;
}, {} as Record<string, KeyboardShortcut[]>);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Keyboard className="h-5 w-5" />
Raccourcis clavier
</DialogTitle>
<DialogDescription>
Utilisez ces raccourcis pour naviguer plus rapidement
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[500px] pr-4">
<div className="space-y-6">
{Object.entries(groupedShortcuts).map(([category, categoryShortcuts]) => (
<div key={category}>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
{category}
</h3>
<div className="space-y-2">
{categoryShortcuts.map((shortcut, index) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-accent transition-colors"
>
<span className="text-sm">{shortcut.description}</span>
<Badge variant="secondary" className="font-mono text-xs">
{formatShortcut(shortcut)}
</Badge>
</div>
))}
</div>
</div>
))}
</div>
</ScrollArea>
<div className="text-xs text-muted-foreground text-center pt-4 border-t">
Appuyez sur <Badge variant="outline" className="mx-1">?</Badge> pour afficher cette aide
</div>
</DialogContent>
</Dialog>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface PomodoroModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}
export function PomodoroModal({ open, onOpenChange, children }: PomodoroModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl">Pomodoro Timer</DialogTitle>
</DialogHeader>
<div className="mt-4">
{children}
</div>
</DialogContent>
</Dialog>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Play, Pause, RotateCcw, Clock, Coffee, Trophy } from "lucide-react";
import { showSuccess } from "@/utils/toast";
type TimerMode = 'work' | 'shortBreak' | 'longBreak';
const TIMER_DURATIONS = {
work: 25 * 60, // 25 minutes
shortBreak: 5 * 60, // 5 minutes
longBreak: 15 * 60, // 15 minutes
};
const SESSIONS_BEFORE_LONG_BREAK = 4;
interface PomodoroTimerProps {
onStateChange?: (isRunning: boolean, timeLeft: number, mode: TimerMode) => void;
}
export function PomodoroTimer({ onStateChange }: PomodoroTimerProps = {}) {
// Load initial state from localStorage
const [mode, setMode] = useState<TimerMode>(() => {
const saved = localStorage.getItem('pomodoroMode');
return (saved as TimerMode) || 'work';
});
const [timeLeft, setTimeLeft] = useState(() => {
const saved = localStorage.getItem('pomodoroTimeLeft');
return saved ? parseInt(saved) : TIMER_DURATIONS.work;
});
const [isRunning, setIsRunning] = useState(() => {
const saved = localStorage.getItem('pomodoroIsRunning');
return saved === 'true';
});
const [sessionsCompleted, setSessionsCompleted] = useState(() => {
const saved = localStorage.getItem('pomodoroSessionsCompleted');
return saved ? parseInt(saved) : 0;
});
const [endTime, setEndTime] = useState<number | null>(() => {
const saved = localStorage.getItem('pomodoroEndTime');
return saved ? parseInt(saved) : null;
});
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Save state to localStorage whenever it changes and dispatch custom event
useEffect(() => {
localStorage.setItem('pomodoroMode', mode);
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [mode]);
useEffect(() => {
localStorage.setItem('pomodoroTimeLeft', timeLeft.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [timeLeft]);
useEffect(() => {
localStorage.setItem('pomodoroIsRunning', isRunning.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [isRunning]);
useEffect(() => {
localStorage.setItem('pomodoroSessionsCompleted', sessionsCompleted.toString());
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [sessionsCompleted]);
useEffect(() => {
if (endTime !== null) {
localStorage.setItem('pomodoroEndTime', endTime.toString());
} else {
localStorage.removeItem('pomodoroEndTime');
}
window.dispatchEvent(new CustomEvent('pomodoroStateChange'));
}, [endTime]);
// Restore timer state on mount if it was running
useEffect(() => {
// Clean up invalid states on mount
if (isRunning && endTime === null) {
// If running but no endTime, stop the timer
setIsRunning(false);
setTimeLeft(TIMER_DURATIONS[mode]);
} else if (endTime !== null && isRunning) {
// Recalculate remaining time based on endTime
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTime - now) / 1000));
if (remaining > 0) {
setTimeLeft(remaining);
} else {
// Timer expired while page was closed/reloaded
setIsRunning(false);
setEndTime(null);
setTimeLeft(0);
}
} else if (endTime !== null && !isRunning) {
// If paused with an endTime, clean up the endTime
setEndTime(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only on mount
// Listen for localStorage changes from other instances
useEffect(() => {
const handleStorageChange = () => {
const savedMode = localStorage.getItem('pomodoroMode') as TimerMode;
const savedTimeLeft = localStorage.getItem('pomodoroTimeLeft');
const savedIsRunning = localStorage.getItem('pomodoroIsRunning');
const savedSessions = localStorage.getItem('pomodoroSessionsCompleted');
const savedEndTime = localStorage.getItem('pomodoroEndTime');
if (savedMode && savedMode !== mode) {
setMode(savedMode);
}
if (savedTimeLeft && parseInt(savedTimeLeft) !== timeLeft) {
setTimeLeft(parseInt(savedTimeLeft));
}
if (savedIsRunning && (savedIsRunning === 'true') !== isRunning) {
setIsRunning(savedIsRunning === 'true');
}
if (savedSessions && parseInt(savedSessions) !== sessionsCompleted) {
setSessionsCompleted(parseInt(savedSessions));
}
const newEndTime = savedEndTime ? parseInt(savedEndTime) : null;
if (newEndTime !== endTime) {
setEndTime(newEndTime);
}
};
window.addEventListener('pomodoroStateChange', handleStorageChange);
return () => window.removeEventListener('pomodoroStateChange', handleStorageChange);
}, [mode, timeLeft, isRunning, sessionsCompleted, endTime]);
// Function to play notification sound using Web Audio API
const playNotificationSound = () => {
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Create a pleasant notification sound (two beeps)
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
// Second beep
setTimeout(() => {
const oscillator2 = audioContext.createOscillator();
const gainNode2 = audioContext.createGain();
oscillator2.connect(gainNode2);
gainNode2.connect(audioContext.destination);
oscillator2.frequency.value = 1000;
oscillator2.type = 'sine';
gainNode2.gain.setValueAtTime(0.3, audioContext.currentTime);
gainNode2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator2.start(audioContext.currentTime);
oscillator2.stop(audioContext.currentTime + 0.2);
}, 250);
} catch (e) {
console.error('Audio play failed:', e);
}
};
// Notify parent component of state changes
useEffect(() => {
if (onStateChange) {
onStateChange(isRunning, timeLeft, mode);
}
}, [isRunning, timeLeft, mode, onStateChange]);
const handleTimerComplete = useCallback(() => {
setIsRunning(false);
setEndTime(null);
// Play notification sound
playNotificationSound();
if (mode === 'work') {
const newSessionCount = sessionsCompleted + 1;
setSessionsCompleted(newSessionCount);
// Determine next break type
if (newSessionCount % SESSIONS_BEFORE_LONG_BREAK === 0) {
setMode('longBreak');
setTimeLeft(TIMER_DURATIONS.longBreak);
showSuccess(`Session terminée ! Temps pour une longue pause (${TIMER_DURATIONS.longBreak / 60} min)`);
} else {
setMode('shortBreak');
setTimeLeft(TIMER_DURATIONS.shortBreak);
showSuccess(`Session terminée ! Temps pour une courte pause (${TIMER_DURATIONS.shortBreak / 60} min)`);
}
} else {
// Break completed, back to work
setMode('work');
setTimeLeft(TIMER_DURATIONS.work);
showSuccess('Pause terminée ! Prêt pour une nouvelle session ?');
}
}, [mode, sessionsCompleted]);
// Manage timer interval - using timestamps for accuracy across tab switches
useEffect(() => {
if (isRunning && endTime) {
intervalRef.current = setInterval(() => {
const now = Date.now();
const remaining = Math.max(0, Math.ceil((endTime - now) / 1000));
setTimeLeft(remaining);
if (remaining <= 0) {
handleTimerComplete();
}
}, 100); // Check every 100ms for better accuracy
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [isRunning, endTime, handleTimerComplete]);
const handleStart = () => {
const now = Date.now();
const newEndTime = now + (timeLeft * 1000);
setEndTime(newEndTime);
setIsRunning(true);
};
const handlePause = () => {
setIsRunning(false);
setEndTime(null);
// timeLeft is already updated by the interval
};
const handleReset = () => {
setIsRunning(false);
setEndTime(null);
setTimeLeft(TIMER_DURATIONS[mode]);
};
const handleModeChange = (newMode: TimerMode) => {
setMode(newMode);
setTimeLeft(TIMER_DURATIONS[newMode]);
setIsRunning(false);
setEndTime(null);
};
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const progress = ((TIMER_DURATIONS[mode] - timeLeft) / TIMER_DURATIONS[mode]) * 100;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Pomodoro Timer
<Badge variant="outline" className="ml-auto">
<Trophy className="h-3 w-3 mr-1" />
{sessionsCompleted} session{sessionsCompleted !== 1 ? 's' : ''}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Mode Selector */}
<div className="flex gap-2">
<Button
variant={mode === 'work' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('work')}
className="flex-1"
disabled={isRunning}
>
Travail
</Button>
<Button
variant={mode === 'shortBreak' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('shortBreak')}
className="flex-1"
disabled={isRunning}
>
<Coffee className="h-4 w-4 mr-1" />
Courte
</Button>
<Button
variant={mode === 'longBreak' ? 'default' : 'outline'}
size="sm"
onClick={() => handleModeChange('longBreak')}
className="flex-1"
disabled={isRunning}
>
<Coffee className="h-4 w-4 mr-1" />
Longue
</Button>
</div>
{/* Timer Display */}
<div className="relative">
<div className="text-6xl font-bold text-center py-8 tabular-nums">
{formatTime(timeLeft)}
</div>
{/* Progress bar */}
<div className="w-full h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-1000 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
</div>
{/* Controls */}
<div className="flex gap-2">
{!isRunning ? (
<Button onClick={handleStart} className="flex-1" size="lg">
<Play className="h-5 w-5 mr-2" />
Démarrer
</Button>
) : (
<Button onClick={handlePause} variant="secondary" className="flex-1" size="lg">
<Pause className="h-5 w-5 mr-2" />
Pause
</Button>
)}
<Button onClick={handleReset} variant="outline" size="lg">
<RotateCcw className="h-5 w-5" />
</Button>
</div>
{/* Session Info */}
<div className="text-center text-sm text-muted-foreground">
{mode === 'work' && (
<p>Session {(sessionsCompleted % SESSIONS_BEFORE_LONG_BREAK) + 1} sur {SESSIONS_BEFORE_LONG_BREAK}</p>
)}
{mode === 'shortBreak' && <p>Pause courte - Profitez-en pour vous étirer</p>}
{mode === 'longBreak' && <p>Pause longue - Temps de vraiment déconnecter</p>}
</div>
</CardContent>
</Card>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Zap, Send } from "lucide-react";
import NotesService from "@/services/NotesService";
import { showSuccess, showError } from "@/utils/toast";
interface QuickCaptureWidgetProps {
onNoteCaptured?: () => void;
}
export function QuickCaptureWidget({ onNoteCaptured }: QuickCaptureWidgetProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const handleCapture = async () => {
if (!content.trim()) {
showError("Le contenu ne peut pas être vide");
return;
}
setIsSubmitting(true);
try {
const noteTitle = title.trim() || `Note rapide - ${new Date().toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})}`;
await NotesService.createNote(noteTitle, content);
showSuccess("Note capturée avec succès");
// Reset form
setTitle("");
setContent("");
setOpen(false);
// Notify parent component
if (onNoteCaptured) {
onNoteCaptured();
}
} catch (error) {
showError("Erreur lors de la capture de la note");
} finally {
setIsSubmitting(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Cmd/Ctrl + Enter to submit
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleCapture();
}
// Escape to close
if (e.key === 'Escape') {
setOpen(false);
}
};
return (
<>
{/* Floating Action Button */}
<Button
className="fixed bottom-24 right-4 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all z-40"
onClick={() => setOpen(true)}
title="Capture rapide (Ctrl+Q)"
>
<Zap className="h-6 w-6" />
</Button>
{/* Quick Capture Modal */}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl" onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Zap className="h-5 w-5 text-primary" />
Capture rapide
</DialogTitle>
<DialogDescription>
Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Optional Title */}
<div>
<Input
placeholder="Titre (optionnel)"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="text-lg font-medium"
autoFocus={false}
/>
<p className="text-xs text-muted-foreground mt-1">
Si vide, un titre avec la date sera généré automatiquement
</p>
</div>
{/* Content */}
<Textarea
placeholder="Votre note..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="min-h-[200px] resize-none"
autoFocus
/>
{/* Actions */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
<kbd className="px-2 py-1 bg-muted rounded text-xs">Ctrl</kbd> +{' '}
<kbd className="px-2 py-1 bg-muted rounded text-xs">Enter</kbd> pour enregistrer
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Annuler
</Button>
<Button
onClick={handleCapture}
disabled={isSubmitting || !content.trim()}
className="gap-2"
>
<Send className="h-4 w-4" />
{isSubmitting ? "Enregistrement..." : "Capturer"}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import {
FileText,
CheckSquare,
Tag as TagIcon,
TrendingUp,
Calendar as CalendarIcon,
Clock,
Target,
Zap
} from "lucide-react";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
interface StatsDashboardProps {
notes: Note[];
todos: Todo[];
}
export function StatsDashboard({ notes, todos }: StatsDashboardProps) {
// Calculate statistics
const totalNotes = notes.length;
const archivedNotes = notes.filter(n => n.archived).length;
const activeNotes = totalNotes - archivedNotes;
const priorityNotes = notes.filter(n => n.priority && !n.archived).length;
const totalTodos = todos.length;
const completedTodos = todos.filter(t => t.completed).length;
const activeTodos = totalTodos - completedTodos;
const completionRate = totalTodos > 0 ? (completedTodos / totalTodos) * 100 : 0;
// Notes with specific content
const notesWithTodos = notes.filter(n => n.todos && n.todos.length > 0 && !n.archived).length;
const notesWithImages = notes.filter(n => n.images && n.images.length > 0 && !n.archived).length;
const notesWithFiles = notes.filter(n => n.files && n.files.length > 0 && !n.archived).length;
// Get all unique tags
const allTags = new Set<string>();
notes.forEach(note => {
note.tags?.forEach(tag => allTags.add(tag.name));
});
const totalTags = allTags.size;
// Recent activity (notes updated in last 7 days)
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const recentlyUpdated = notes.filter(n =>
n.updated_at && new Date(n.updated_at) > sevenDaysAgo
).length;
// Calculate productivity score (0-100)
const productivityScore = Math.round(
(completionRate * 0.4) +
((activeNotes > 0 ? Math.min(priorityNotes / activeNotes, 1) : 0) * 20) +
((recentlyUpdated / Math.max(activeNotes, 1)) * 40)
);
return (
<div className="space-y-4">
{/* Productivity Score */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Target className="h-4 w-4 text-primary" />
Score de Productivité
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-3xl font-bold">{productivityScore}</span>
<Badge variant={productivityScore >= 70 ? "default" : productivityScore >= 40 ? "secondary" : "outline"}>
{productivityScore >= 70 ? "Excellent" : productivityScore >= 40 ? "Bien" : "À améliorer"}
</Badge>
</div>
<Progress value={productivityScore} className="h-2" />
<p className="text-xs text-muted-foreground">
Basé sur le taux de complétion, notes prioritaires et activité récente
</p>
</div>
</CardContent>
</Card>
{/* Notes Statistics */}
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Notes
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Total</span>
<span className="text-2xl font-bold">{totalNotes}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Actives</span>
<Badge variant="secondary">{activeNotes}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Archivées</span>
<Badge variant="outline">{archivedNotes}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Prioritaires</span>
<Badge variant="destructive">{priorityNotes}</Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<CheckSquare className="h-4 w-4" />
Tâches
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Total</span>
<span className="text-2xl font-bold">{totalTodos}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Complétées</span>
<Badge variant="secondary">{completedTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">En cours</span>
<Badge variant="default">{activeTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Taux</span>
<Badge variant="outline">{completionRate.toFixed(0)}%</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Content Statistics */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Zap className="h-4 w-4" />
Contenu
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec tâches</span>
<Badge variant="secondary">{notesWithTodos}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec images</span>
<Badge variant="secondary">{notesWithImages}</Badge>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Avec fichiers</span>
<Badge variant="secondary">{notesWithFiles}</Badge>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">Tags uniques</span>
<Badge variant="secondary">{totalTags}</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Activité Récente
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">Derniers 7 jours</span>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold">{recentlyUpdated}</span>
<span className="text-sm text-muted-foreground">notes modifiées</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { StatsDashboard } from "./StatsDashboard";
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
interface StatsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
notes: Note[];
todos: Todo[];
}
export function StatsModal({ open, onOpenChange, notes, todos }: StatsModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl">Statistiques</DialogTitle>
</DialogHeader>
<div className="mt-4">
<StatsDashboard notes={notes} todos={todos} />
</div>
</DialogContent>
</Dialog>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Search, Sparkles } from "lucide-react";
import { DEFAULT_TEMPLATES, getTemplatesByCategory, searchTemplates, type NoteTemplate } from "@/utils/noteTemplates";
interface TemplateSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectTemplate: (template: NoteTemplate) => void;
}
export function TemplateSelector({ open, onOpenChange, onSelectTemplate }: TemplateSelectorProps) {
const [searchQuery, setSearchQuery] = useState("");
const templates = searchQuery.trim()
? searchTemplates(searchQuery)
: DEFAULT_TEMPLATES;
const templatesByCategory = getTemplatesByCategory();
const handleSelectTemplate = (template: NoteTemplate) => {
onSelectTemplate(template);
onOpenChange(false);
setSearchQuery("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Choisir un template
</DialogTitle>
<DialogDescription>
Démarrez rapidement avec un modèle prédéfini
</DialogDescription>
</DialogHeader>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un template..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Templates Grid */}
<ScrollArea className="h-[400px] pr-4">
{searchQuery.trim() ? (
// Search Results
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{templates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onClick={() => handleSelectTemplate(template)}
/>
))}
{templates.length === 0 && (
<div className="col-span-2 text-center py-8 text-muted-foreground">
Aucun template trouvé
</div>
)}
</div>
) : (
// Templates by Category
<div className="space-y-6">
{Array.from(templatesByCategory.entries()).map(([category, categoryTemplates]) => (
<div key={category}>
<h3 className="font-semibold mb-3 text-sm text-muted-foreground uppercase tracking-wide">
{category}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{categoryTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
onClick={() => handleSelectTemplate(template)}
/>
))}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
);
}
interface TemplateCardProps {
template: NoteTemplate;
onClick: () => void;
}
function TemplateCard({ template, onClick }: TemplateCardProps) {
return (
<Button
variant="outline"
className="h-auto p-4 flex flex-col items-start gap-2 hover:bg-accent hover:border-primary transition-all"
onClick={onClick}
>
<div className="flex items-center gap-2 w-full">
<span className="text-2xl">{template.icon}</span>
<span className="font-semibold text-left flex-1">{template.name}</span>
</div>
<p className="text-sm text-muted-foreground text-left">
{template.description}
</p>
{template.todos && template.todos.length > 0 && (
<Badge variant="secondary" className="text-xs">
{template.todos.length} tâche{template.todos.length > 1 ? 's' : ''}
</Badge>
)}
</Button>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Clair
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Sombre
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
Système
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from 'react';
export interface KeyboardShortcut {
key: string;
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
meta?: boolean;
description: string;
handler: () => void;
preventDefault?: boolean;
}
/**
* Custom hook for managing keyboard shortcuts
*/
export function useKeyboardShortcuts(
shortcuts: KeyboardShortcut[],
enabled = true
) {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Ignore keyboard shortcuts when typing in input fields, textareas, or contentEditable elements
// UNLESS the shortcut has a modifier key (Ctrl, Alt, Meta)
const target = event.target as HTMLElement;
const isInputField = target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable;
for (const shortcut of shortcuts) {
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatch = shortcut.ctrl ? (event.ctrlKey || event.metaKey) : !event.ctrlKey && !event.metaKey;
const altMatch = shortcut.alt ? event.altKey : !event.altKey;
const shiftMatch = shortcut.shift ? event.shiftKey : !event.shiftKey;
const metaMatch = shortcut.meta ? event.metaKey : !event.metaKey;
if (keyMatch && ctrlMatch && altMatch && shiftMatch && metaMatch) {
// Skip shortcuts without modifiers when user is typing in input fields
const hasModifier = shortcut.ctrl || shortcut.alt || shortcut.meta;
if (isInputField && !hasModifier) {
continue;
}
if (shortcut.preventDefault !== false) {
event.preventDefault();
}
shortcut.handler();
break;
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [shortcuts, enabled]);
}
/**
* Format keyboard shortcut for display
*/
export function formatShortcut(shortcut: KeyboardShortcut): string {
const parts: string[] = [];
if (shortcut.ctrl || shortcut.meta) {
parts.push(navigator.platform.includes('Mac') ? '⌘' : 'Ctrl');
}
if (shortcut.alt) {
parts.push(navigator.platform.includes('Mac') ? '⌥' : 'Alt');
}
if (shortcut.shift) {
parts.push(navigator.platform.includes('Mac') ? '⇧' : 'Shift');
}
parts.push(shortcut.key.toUpperCase());
return parts.join('+');
}
/**
* Common keyboard shortcuts used across the app
*/
export const COMMON_SHORTCUTS = {
NEW_NOTE: { key: 'n', ctrl: true, description: 'Nouvelle note' },
SEARCH: { key: 'k', ctrl: true, description: 'Rechercher' },
QUICK_CAPTURE: { key: 'q', ctrl: true, description: 'Capture rapide' },
TOGGLE_SIDEBAR: { key: 'b', ctrl: true, description: 'Basculer sidebar' },
SAVE: { key: 's', ctrl: true, description: 'Sauvegarder' },
CLOSE: { key: 'Escape', description: 'Fermer' },
HELP: { key: '?', shift: true, description: 'Aide raccourcis' },
ARCHIVE: { key: 'e', ctrl: true, description: 'Archiver note' },
DELETE: { key: 'Delete', ctrl: true, description: 'Supprimer' },
NEXT: { key: 'j', description: 'Suivant' },
PREVIOUS: { key: 'k', description: 'Précédent' },
TOGGLE_TODO: { key: 'Enter', ctrl: true, description: 'Toggle todo' },
BOLD: { key: 'b', ctrl: true, description: 'Gras' },
ITALIC: { key: 'i', ctrl: true, description: 'Italique' },
LINK: { key: 'k', ctrl: true, shift: true, description: 'Lien' },
};
+633 -42
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, useState, useRef, useCallback, useId } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -15,7 +16,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { RichTextEditor } from "@/components/RichTextEditor";
import {
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List, Sparkles, BarChart3, Clock, HelpCircle
} from "lucide-react";
import AuthService from "@/services/AuthService";
import AdminService from "@/services/AdminService";
@@ -31,6 +32,21 @@ import { showError, showSuccess } from "@/utils/toast";
import { ConfirmModal } from "@/components/modals/ConfirmModal";
import { InputModal } from "@/components/modals/InputModal";
import { AddUserModal } from "@/components/modals/AddUserModal";
import { compressImage, formatFileSize } from "@/utils/imageCompression";
import { ModeToggle } from "@/components/mode-toggle";
import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport";
import { TemplateSelector } from "@/components/TemplateSelector";
import type { NoteTemplate } from "@/utils/noteTemplates";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
import { PomodoroModal } from "@/components/PomodoroModal";
import { PomodoroTimer } from "@/components/PomodoroTimer";
import { KanbanBoard } from "@/components/KanbanBoard";
import { getSmartTagSuggestions } from "@/utils/smartTags";
import { QuickCaptureWidget } from "@/components/QuickCaptureWidget";
import { StatsDashboard } from "@/components/StatsDashboard";
import { StatsModal } from "@/components/StatsModal";
import { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp";
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts";
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
@@ -106,10 +122,36 @@ interface UserType {
is_admin: boolean;
}
// Function to clean HTML content and decode entities
const cleanHtmlContent = (html: string): string => {
if (!html) return '';
// Create a temporary div to decode HTML entities
const temp = document.createElement('div');
temp.innerHTML = html;
// Get text content (removes HTML tags and decodes entities)
let text = temp.textContent || temp.innerText || '';
// Remove any remaining HTML entity artifacts
text = text.replace(/&[a-z]+;?/gi, ' ').replace(/\s+/g, ' ').trim();
return text;
};
const Index = () => {
const [user, setUser] = useState<UserType | null>(null);
const [showAdmin, setShowAdmin] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [notes, setNotes] = useState<Note[]>([]);
const [openNote, setOpenNote] = useState<Note | null>(null);
const [users, setUsers] = useState<UserType[]>([]);
@@ -120,6 +162,7 @@ const Index = () => {
const [settings, setSettings] = useState<AppSettings>({});
const [adminTab, setAdminTab] = useState("users");
const [showArchived, setShowArchived] = useState(false);
const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list');
const [noteTags, setNoteTags] = useState<Tag[]>([]);
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
@@ -144,11 +187,20 @@ const Index = () => {
const [addUserModal, setAddUserModal] = useState(false);
const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false });
const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false });
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
const [addTagModal, setAddTagModal] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
const [addEventModal, setAddEventModal] = useState(false);
const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false });
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [showStatsDashboard, setShowStatsDashboard] = useState(false);
const [showPomodoroModal, setShowPomodoroModal] = useState(false);
// Pomodoro timer states
const [pomodoroRunning, setPomodoroRunning] = useState(false);
const [pomodoroTimeLeft, setPomodoroTimeLeft] = useState(25 * 60);
const [pomodoroMode, setPomodoroMode] = useState<'work' | 'shortBreak' | 'longBreak'>('work');
// Pagination states
const [notesPage, setNotesPage] = useState(0);
@@ -165,6 +217,7 @@ const Index = () => {
// Chatbox states
const [chatOpen, setChatOpen] = useState(false);
const [chatExpandedModal, setChatExpandedModal] = useState(false);
const [chatMessages, setChatMessages] = useState<Array<{ role: string; content: string }>>([]);
const [chatInput, setChatInput] = useState("");
const [chatLoading, setChatLoading] = useState(false);
@@ -234,6 +287,20 @@ const Index = () => {
return () => clearInterval(interval);
}, []);
// Handle Pomodoro timer state changes
const handlePomodoroStateChange = (isRunning: boolean, timeLeft: number, mode: 'work' | 'shortBreak' | 'longBreak') => {
setPomodoroRunning(isRunning);
setPomodoroTimeLeft(timeLeft);
setPomodoroMode(mode);
};
// Format time for Pomodoro display
const formatPomodoroTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const loadNotes = async () => {
try {
const fetchedNotes = await NotesService.getNotes(showArchived);
@@ -414,12 +481,32 @@ const Index = () => {
navigate("/login");
};
const handleCreateNote = async () => {
const handleCreateNote = () => {
setTemplateSelectorOpen(true);
};
const handleCreateNoteFromTemplate = async (template: NoteTemplate) => {
try {
const newNote = await NotesService.createNote("Nouvelle note");
const title = template.name === "Note vide" ? "Nouvelle note" : template.name;
const newNote = await NotesService.createNote(title, template.content);
if (newNote) {
setNotes([newNote, ...notes]);
setOpenNote(newNote);
// Add todos if template has any
if (template.todos && template.todos.length > 0) {
for (const todo of template.todos) {
await NotesService.addTodo(newNote.id!, todo.text);
}
// Reload note to get the todos
const notes = await NotesService.getNotes();
const updatedNote = notes.find(n => n.id === newNote.id);
if (updatedNote) {
setNotes([updatedNote, ...notes.filter(n => n.id !== newNote.id)]);
setOpenNote(updatedNote);
}
} else {
setNotes([newNote, ...notes]);
setOpenNote(newNote);
}
showSuccess("Note créée depuis le template");
}
} catch (error) {
showError("Erreur lors de la création de la note");
@@ -809,12 +896,71 @@ const Index = () => {
}
};
const filteredNotes = notes.filter(note =>
(showArchived ? note.archived : !note.archived) &&
(searchQuery === "" ||
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase())))
);
// Extract unique tags from all notes
const availableTags: TagOption[] = Array.from(
new Map(
notes.flatMap(note =>
(note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
)
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes.filter(note => {
// Archived filter
if (showArchived ? !note.archived : note.archived) return false;
// Text search
if (searchQuery !== "" &&
!(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
});
// Pagination for notes
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
@@ -826,7 +972,7 @@ const Index = () => {
// Reset page when changing filters or search
useEffect(() => {
setNotesPage(0);
}, [showArchived, searchQuery]);
}, [showArchived, searchQuery, searchFilters]);
useEffect(() => {
setTodosActivePage(0);
@@ -841,6 +987,66 @@ const Index = () => {
setCalendarPage(0);
}, [calendarEvents.length]);
// Define keyboard shortcuts
const keyboardShortcuts: KeyboardShortcut[] = [
{
key: 'n',
ctrl: true,
description: 'Nouvelle note',
handler: () => !showAdmin && handleCreateNote()
},
{
key: 'k',
ctrl: true,
description: 'Rechercher',
handler: () => {
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
if (searchInput) searchInput.focus();
}
},
{
key: 'q',
ctrl: true,
description: 'Capture rapide',
handler: () => {
const captureButton = document.querySelector('[title="Capture rapide (Ctrl+Q)"]') as HTMLButtonElement;
if (captureButton) captureButton.click();
}
},
{
key: 'Escape',
description: 'Fermer note/modal',
handler: () => {
if (openNote) setOpenNote(null);
else if (showAdmin) setShowAdmin(false);
else if (showKeyboardHelp) setShowKeyboardHelp(false);
},
preventDefault: false
},
{
key: 'e',
ctrl: true,
description: 'Archiver note',
handler: async () => {
if (openNote?.id) {
await handleUpdateNote({ archived: !openNote.archived });
}
}
},
{
key: 's',
ctrl: true,
description: 'Sauvegarder (auto)',
handler: () => {
// Note: Auto-save is handled by the editor
showSuccess('Auto-save actif');
}
}
];
// Register keyboard shortcuts
useKeyboardShortcuts(keyboardShortcuts, !showAdmin);
// Pagination for todos
const activeTodos = todos.filter(t => !t.completed);
const completedTodos = todos.filter(t => t.completed);
@@ -929,6 +1135,41 @@ const Index = () => {
Nouvelle Note
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowStatsDashboard(true)}
title="Statistiques"
>
<BarChart3 className="h-5 w-5" />
</Button>
<Button
variant={pomodoroRunning ? "default" : "ghost"}
size="icon"
onClick={() => setShowPomodoroModal(true)}
title="Pomodoro Timer"
className="relative"
>
<Clock className="h-5 w-5" />
{pomodoroRunning && (
<span className="absolute -bottom-1 -right-1 text-[10px] font-mono bg-primary text-primary-foreground px-1 rounded">
{formatPomodoroTime(pomodoroTimeLeft)}
</span>
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setShowKeyboardHelp(true)}
title="Aide - Raccourcis clavier"
>
<HelpCircle className="h-5 w-5" />
</Button>
<ModeToggle />
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1122,20 +1363,51 @@ const Index = () => {
</div>
</div>
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher dans les notes..."
className="pl-10 h-12 text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
<div className="flex items-center gap-2">
<div className="relative w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher dans les notes..."
className="pl-10 h-12 text-base"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<AdvancedSearch
filters={searchFilters}
onFiltersChange={setSearchFilters}
availableTags={availableTags}
/>
{/* View Mode Toggle */}
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={notesViewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('list')}
className="gap-2"
>
<List className="h-4 w-4" />
Liste
</Button>
<Button
variant={notesViewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => setNotesViewMode('kanban')}
className="gap-2"
>
<LayoutGrid className="h-4 w-4" />
Kanban
</Button>
</div>
</div>
</div>
{/* Notes Grid - 1 column for better visibility */}
<div className="grid grid-cols-1 gap-4">
{/* Notes View - List or Kanban */}
{notesViewMode === 'list' ? (
<>
<div className="grid grid-cols-1 gap-4">
{filteredNotes.length > 0 ? (
paginatedNotes.map(note => (
<Card
@@ -1191,7 +1463,7 @@ const Index = () => {
{note.title || "Sans titre"}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
{note.content ? cleanHtmlContent(note.content) : "Note vide"}
</p>
<div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && (
@@ -1273,6 +1545,41 @@ const Index = () => {
</Button>
</div>
)}
</>
) : (
/* Kanban View */
<KanbanBoard
notes={filteredNotes}
onNoteClick={handleOpenNote}
onCreateNote={(columnId) => {
setTemplateSelectorOpen(true);
// TODO: Set initial tag based on columnId
}}
onMoveNote={async (noteId, newStatus) => {
// Move note to new column by updating its status tag
const note = notes.find(n => n.id === noteId);
if (!note) return;
// Remove old status tags
const oldStatusTags = note.tags?.filter(tag =>
['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase())
) || [];
for (const tag of oldStatusTags) {
if (tag.id && note.id) {
await TagsService.deleteTag(note.id, tag.id);
}
}
// Add new status tag
if (note.id) {
await TagsService.addTag(note.id, newStatus);
await loadNotes(); // Refresh notes
showSuccess(`Note déplacée vers ${newStatus}`);
}
}}
/>
)}
</>
) : (
/* Open Note Editor */
@@ -1281,6 +1588,19 @@ const Index = () => {
<div></div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => {
if (openNote) {
downloadNoteAsMarkdown(openNote);
showSuccess("Note exportée en Markdown");
}
}}
title="Exporter en Markdown"
>
<FileDown className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
@@ -1313,7 +1633,7 @@ const Index = () => {
<Input
type="text"
placeholder="Titre de la note"
className="text-2xl font-semibold border-none shadow-none h-auto px-0 focus-visible:ring-0"
className="text-2xl font-semibold"
value={openNote?.title || ""}
onChange={(e) => handleUpdateNote({ title: e.target.value })}
/>
@@ -1342,20 +1662,42 @@ const Index = () => {
<input
id="image-upload"
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={async (e) => {
if (e.target.files && e.target.files[0] && openNote?.id) {
const originalFile = e.target.files[0];
const originalSize = formatFileSize(originalFile.size);
try {
const image = await NotesService.uploadImage(openNote.id, e.target.files[0]);
// Compresser l'image avant l'upload
const compressedFile = await compressImage(originalFile);
const compressedSize = formatFileSize(compressedFile.size);
// Afficher un message si l'image a été compressée
if (compressedFile.size < originalFile.size) {
showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`);
}
// Vérifier la taille finale (5MB max après compression)
const maxSize = 5 * 1024 * 1024; // 5MB
if (compressedFile.size > maxSize) {
showError("L'image est trop grande même après compression (max 5MB)");
e.target.value = '';
return;
}
const image = await NotesService.uploadImage(openNote.id, compressedFile);
if (image) {
const updatedImages = [...(openNote.images || []), image];
setOpenNote({ ...openNote, images: updatedImages });
await handleUpdateNote({ images: updatedImages });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, images: updatedImages } : note));
showSuccess("Image ajoutée");
} else {
showError("Erreur lors de l'upload de l'image");
}
} catch (error) {
showError("Erreur lors de l'upload de l'image");
showError(error instanceof Error ? error.message : "Erreur lors de l'upload de l'image");
}
e.target.value = '';
}
@@ -1409,6 +1751,34 @@ const Index = () => {
</div>
)}
{/* Smart Tag Suggestions */}
{openNote && (() => {
const existingTags = noteTags.map(t => t.tag);
const suggestions = getSmartTagSuggestions(openNote, notes, existingTags);
return suggestions.length > 0 && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Sparkles className="h-3 w-3" />
Suggestions de tags
</p>
<div className="flex gap-2 flex-wrap">
{suggestions.map((suggestion) => (
<Badge
key={suggestion}
variant="outline"
className="text-xs px-2 py-1 gap-1 cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
onClick={() => confirmAddTag(suggestion)}
>
<Plus className="h-3 w-3" />
{suggestion}
</Badge>
))}
</div>
</div>
);
})()}
{/* Todos */}
{openNote.todos && openNote.todos.length > 0 && (
<>
@@ -1421,9 +1791,14 @@ const Index = () => {
<Checkbox
checked={todo.completed}
onCheckedChange={async () => {
const updatedTodos = [...(openNote.todos || [])];
updatedTodos[index] = { ...todo, completed: !todo.completed };
await handleUpdateNote({ todos: updatedTodos });
if (!todo.id) return;
const success = await NotesService.toggleTodo(todo.id, !todo.completed);
if (success) {
const updatedTodos = [...(openNote.todos || [])];
updatedTodos[index] = { ...todo, completed: !todo.completed };
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
}
}}
/>
<span className={todo.completed ? "line-through text-muted-foreground flex-1 text-sm" : "flex-1 text-sm"}>
@@ -1434,9 +1809,14 @@ const Index = () => {
size="icon"
className="h-6 w-6"
onClick={async () => {
const updatedTodos = [...(openNote.todos || [])];
updatedTodos.splice(index, 1);
await handleUpdateNote({ todos: updatedTodos });
if (!todo.id) return;
const success = await NotesService.deleteTodo(todo.id);
if (success) {
const updatedTodos = openNote.todos.filter((_, i) => i !== index);
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
showSuccess("Tâche supprimée");
}
}}
>
<Trash2 className="h-3 w-3" />
@@ -1598,8 +1978,10 @@ const Index = () => {
)}
</div>
{/* Right Column: Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Right Column: Todos & RSS */}
<div className="space-y-6">
{/* Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Todos Box */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
@@ -1829,6 +2211,7 @@ const Index = () => {
</Card>
</div>
</div>
</div>
</div>
@@ -2222,9 +2605,13 @@ const Index = () => {
label="Tâche"
placeholder="Entrez la tâche..."
onConfirm={async (text) => {
if (openNote) {
const updatedTodos = [...(openNote.todos || []), { text, completed: false }];
await handleUpdateNote({ todos: updatedTodos });
if (openNote?.id) {
const newTodo = await NotesService.addTodo(openNote.id, text);
if (newTodo) {
const updatedTodos = [...(openNote.todos || []), newTodo];
setOpenNote({ ...openNote, todos: updatedTodos });
setNotes(prev => prev.map(note => note.id === openNote.id ? { ...note, todos: updatedTodos } : note));
}
}
}}
confirmText="Ajouter"
@@ -2240,6 +2627,13 @@ const Index = () => {
confirmText="Ajouter"
/>
{/* Template Selector */}
<TemplateSelector
open={templateSelectorOpen}
onOpenChange={setTemplateSelectorOpen}
onSelectTemplate={handleCreateNoteFromTemplate}
/>
{/* Add Event Modal */}
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -2598,13 +2992,14 @@ const Index = () => {
{/* AI Chatbox */}
{!chatOpen ? (
<Button
className="fixed bottom-4 right-4 h-14 w-14 rounded-full shadow-lg"
className="fixed bottom-4 right-4 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all z-40"
onClick={() => setChatOpen(true)}
title="Assistant IA"
>
<MessageSquare className="h-6 w-6" />
</Button>
) : (
<Card className="fixed bottom-4 right-4 w-96 h-[600px] shadow-2xl flex flex-col">
<Card className="fixed bottom-4 right-4 w-96 h-[600px] shadow-2xl flex flex-col z-50">
<CardHeader className="pb-3 border-b">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
@@ -2612,11 +3007,23 @@ const Index = () => {
Assistant IA
</CardTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => {
setChatExpandedModal(true);
setChatOpen(false);
}}
title="Agrandir"
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={chatMessages.length === 0}
title="Effacer la conversation"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -2624,6 +3031,7 @@ const Index = () => {
variant="ghost"
size="sm"
onClick={() => setChatOpen(false)}
title="Fermer"
>
<X className="h-4 w-4" />
</Button>
@@ -2792,6 +3200,154 @@ const Index = () => {
</Card>
)}
{/* Expanded Chat Modal */}
<Dialog open={chatExpandedModal} onOpenChange={setChatExpandedModal}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-3 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-xl flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Assistant IA
</DialogTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={handleClearChat}
disabled={chatMessages.length === 0}
title="Effacer la conversation"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Popover open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={modelSelectorOpen}
className="flex-1 justify-between text-xs h-9"
disabled={chatLoading || loadingModels}
>
<span className="truncate">
{loadingModels ? (
"Chargement des modèles..."
) : openRouterModels.length === 0 ? (
user?.is_admin ? "Configurer clé API OpenRouter" : "Aucun modèle IA configuré"
) : selectedModel ? (
openRouterModels.find(m => m.id === selectedModel)?.name || selectedModel
) : (
"Sélectionner un modèle"
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[500px] p-0">
<Command>
<CommandInput placeholder="Rechercher un modèle..." />
<CommandEmpty>Aucun modèle trouvé.</CommandEmpty>
<CommandList>
<CommandGroup>
{openRouterModels.map((model) => (
<CommandItem
key={model.id}
value={model.id}
onSelect={(currentValue) => {
setSelectedModel(currentValue);
setModelSelectorOpen(false);
}}
>
<Check
className={`mr-2 h-4 w-4 ${selectedModel === model.id ? "opacity-100" : "opacity-0"
}`}
/>
<div className="flex flex-col">
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground">{model.id}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{chatMessages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<MessageSquare className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Bienvenue dans l'Assistant IA</h3>
<p className="text-sm text-muted-foreground max-w-md">
Posez vos questions et obtenez des réponses intelligentes grâce à l'IA.
{openRouterModels.length === 0 && user?.is_admin && (
<span className="block mt-2">Configurez une clé API OpenRouter dans les paramètres.</span>
)}
</p>
</div>
) : (
chatMessages.map((msg, idx) => (
<div
key={idx}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-3 ${msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
</div>
))
)}
{chatLoading && (
<div className="flex justify-start">
<div className="bg-muted rounded-lg px-4 py-3">
<div className="flex gap-1">
<span className="animate-bounce">●</span>
<span className="animate-bounce delay-100">●</span>
<span className="animate-bounce delay-200">●</span>
</div>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
<div className="border-t px-6 py-4">
<form
onSubmit={(e) => {
e.preventDefault();
handleSendMessage();
}}
className="flex gap-2"
>
<Input
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Posez une question..."
disabled={chatLoading || !selectedModel}
className="flex-1"
/>
<Button
type="submit"
size="icon"
disabled={chatLoading || !chatInput.trim() || !selectedModel}
>
<Send className="h-4 w-4" />
</Button>
</form>
</div>
</DialogContent>
</Dialog>
{/* Image modal for fullscreen view */}
{selectedImage && (
<div
@@ -2816,6 +3372,41 @@ const Index = () => {
</div>
</div>
)}
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
open={showKeyboardHelp}
onOpenChange={setShowKeyboardHelp}
shortcuts={keyboardShortcuts}
/>
{/* Statistics Modal */}
<StatsModal
open={showStatsDashboard}
onOpenChange={setShowStatsDashboard}
notes={notes}
todos={todos}
/>
{/* Pomodoro Timer - Always mounted in hidden div to keep timer running */}
<div style={{ position: 'absolute', left: '-9999px', top: '-9999px' }}>
<PomodoroTimer onStateChange={handlePomodoroStateChange} />
</div>
{/* Pomodoro Timer Modal - Shows timer controls when opened */}
<Dialog open={showPomodoroModal} onOpenChange={setShowPomodoroModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-2xl">Pomodoro Timer</DialogTitle>
</DialogHeader>
<div className="mt-4">
<PomodoroTimer onStateChange={handlePomodoroStateChange} />
</div>
</DialogContent>
</Dialog>
{/* Quick Capture Widget */}
<QuickCaptureWidget onNoteCaptured={loadNotes} />
</div>
);
};
+65 -18
View File
@@ -17,7 +17,8 @@ import {
Paperclip,
Tag as TagIcon,
X,
MoreVertical
MoreVertical,
FileDown
} from "lucide-react";
import {
DropdownMenu,
@@ -27,6 +28,8 @@ import {
} from "@/components/ui/dropdown-menu";
import NotesService, { Note } from "@/services/NotesService";
import { showError, showSuccess } from "@/utils/toast";
import { compressImage, formatFileSize } from "@/utils/imageCompression";
import { downloadNoteAsMarkdown } from "@/utils/markdownExport";
export default function NoteDetailPage() {
const { id } = useParams();
@@ -80,41 +83,76 @@ export default function NoteDetailPage() {
};
const handleAddTodo = async () => {
if (!note || !todoText.trim()) return;
const newTodo = { text: todoText, completed: false };
const updatedTodos = [...(note.todos || []), newTodo];
await updateNote({ todos: updatedTodos });
setTodoText("");
setAddTodoModal(false);
showSuccess("Tâche ajoutée");
if (!note?.id || !todoText.trim()) return;
const newTodo = await NotesService.addTodo(note.id, todoText.trim());
if (newTodo) {
const updatedTodos = [...(note.todos || []), newTodo];
setNote({ ...note, todos: updatedTodos });
setTodoText("");
setAddTodoModal(false);
}
};
const handleToggleTodo = async (index: number) => {
if (!note?.todos) return;
const updatedTodos = [...note.todos];
updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed };
await updateNote({ todos: updatedTodos });
const todo = note.todos[index];
if (!todo.id) return;
const success = await NotesService.toggleTodo(todo.id, !todo.completed);
if (success) {
const updatedTodos = [...note.todos];
updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed };
setNote({ ...note, todos: updatedTodos });
}
};
const handleDeleteTodo = async (index: number) => {
if (!note?.todos) return;
const updatedTodos = [...note.todos];
updatedTodos.splice(index, 1);
await updateNote({ todos: updatedTodos });
showSuccess("Tâche supprimée");
const todo = note.todos[index];
if (!todo.id) return;
const success = await NotesService.deleteTodo(todo.id);
if (success) {
const updatedTodos = note.todos.filter((_, i) => i !== index);
setNote({ ...note, todos: updatedTodos });
showSuccess("Tâche supprimée");
}
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files || !e.target.files[0] || !note?.id) return;
const originalFile = e.target.files[0];
const originalSize = formatFileSize(originalFile.size);
try {
const image = await NotesService.uploadImage(note.id, e.target.files[0]);
// Compresser l'image avant l'upload
const compressedFile = await compressImage(originalFile);
const compressedSize = formatFileSize(compressedFile.size);
// Afficher un message si l'image a été compressée
if (compressedFile.size < originalFile.size) {
showSuccess(`Image compressée: ${originalSize} → ${compressedSize}`);
}
// Vérifier la taille finale (5MB max après compression)
const maxSize = 5 * 1024 * 1024; // 5MB
if (compressedFile.size > maxSize) {
showError("L'image est trop grande même après compression (max 5MB)");
e.target.value = '';
return;
}
const image = await NotesService.uploadImage(note.id, compressedFile);
if (image) {
const updatedImages = [...(note.images || []), image];
setNote({ ...note, images: updatedImages });
showSuccess("Image ajoutée");
} else {
showError("Erreur lors de l'upload de l'image");
}
} catch (error) {
showError("Erreur lors de l'upload");
showError(error instanceof Error ? error.message : "Erreur lors de l'upload");
}
e.target.value = '';
};
@@ -163,6 +201,15 @@ export default function NoteDetailPage() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
if (note) {
downloadNoteAsMarkdown(note);
showSuccess("Note exportée en Markdown");
}
}}>
<FileDown className="h-4 w-4 mr-2" />
Exporter en Markdown
</DropdownMenuItem>
<DropdownMenuItem onClick={async () => {
if (note?.id) {
const newArchived = !note.archived;
@@ -216,7 +263,7 @@ export default function NoteDetailPage() {
<input
id="image-upload"
type="file"
accept="image/*"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={handleImageUpload}
/>
+255 -114
View File
@@ -5,17 +5,30 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { MobileFAB } from "@/components/mobile/MobileFAB";
import { MobileCard } from "@/components/mobile/MobileCard";
import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText } from "lucide-react";
import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText, LayoutGrid, List } from "lucide-react";
import NotesService, { Note } from "@/services/NotesService";
import { showError, showSuccess } from "@/utils/toast";
import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch";
import { KanbanBoard } from "@/components/KanbanBoard";
import TagsService from "@/services/TagsService";
const NOTES_PER_PAGE = 10;
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [searchFilters, setSearchFilters] = useState<SearchFilters>({
tags: [],
dateFrom: undefined,
dateTo: undefined,
hasTodos: undefined,
hasImages: undefined,
hasFiles: undefined,
priority: undefined
});
const [showArchived, setShowArchived] = useState(false);
const [page, setPage] = useState(0);
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const navigate = useNavigate();
const loadNotes = async () => {
@@ -31,13 +44,71 @@ export default function NotesPage() {
loadNotes();
}, [showArchived]);
// Extract unique tags from all notes
const availableTags: TagOption[] = Array.from(
new Map(
notes.flatMap(note =>
(note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }])
)
).values()
).sort((a, b) => a.name.localeCompare(b.name));
const filteredNotes = notes
.filter(note => {
const matchArchived = showArchived ? note.archived : !note.archived;
const matchSearch = searchQuery === "" ||
note.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content?.toLowerCase().includes(searchQuery.toLowerCase());
return matchArchived && matchSearch;
// Archived filter
if (showArchived ? !note.archived : note.archived) return false;
// Text search
if (searchQuery !== "" &&
!(note.title?.toLowerCase().includes(searchQuery.toLowerCase())) &&
!(note.content?.toLowerCase().includes(searchQuery.toLowerCase()))) {
return false;
}
// Tags filter
if (searchFilters.tags.length > 0) {
const noteTags = (note.tags || []).map(t => t.name);
if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) {
return false;
}
}
// Date range filter
if (searchFilters.dateFrom || searchFilters.dateTo) {
const noteDate = new Date(note.updated_at || note.created_at || 0);
if (searchFilters.dateFrom) {
const fromDate = new Date(searchFilters.dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (noteDate < fromDate) return false;
}
if (searchFilters.dateTo) {
const toDate = new Date(searchFilters.dateTo);
toDate.setHours(23, 59, 59, 999);
if (noteDate > toDate) return false;
}
}
// Has todos filter
if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) {
return false;
}
// Has images filter
if (searchFilters.hasImages && (!note.images || note.images.length === 0)) {
return false;
}
// Has files filter
if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) {
return false;
}
// Priority filter
if (searchFilters.priority && !note.priority) {
return false;
}
return true;
})
.sort((a, b) => {
if (a.priority && !b.priority) return -1;
@@ -51,7 +122,10 @@ export default function NotesPage() {
const handleCreateNote = async () => {
try {
const newNote = await NotesService.createNote("Nouvelle note", "");
navigate(`/mobile/notes/${newNote.id}`);
if (newNote) {
showSuccess("Note créée");
navigate(`/mobile/notes/${newNote.id}`);
}
} catch (error) {
showError("Erreur lors de la création de la note");
}
@@ -89,6 +163,16 @@ export default function NotesPage() {
/>
</div>
{/* Advanced Filters */}
<AdvancedSearch
filters={searchFilters}
onFiltersChange={(newFilters) => {
setSearchFilters(newFilters);
setPage(0);
}}
availableTags={availableTags}
/>
{/* Filter Tabs */}
<div className="flex gap-2">
<Button
@@ -114,124 +198,181 @@ export default function NotesPage() {
Archivées ({notes.filter(n => n.archived).length})
</Button>
</div>
{/* View Mode Toggle */}
<div className="flex gap-1 border rounded-md p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="flex-1 gap-2"
>
<List className="h-4 w-4" />
Liste
</Button>
<Button
variant={viewMode === 'kanban' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('kanban')}
className="flex-1 gap-2"
>
<LayoutGrid className="h-4 w-4" />
Kanban
</Button>
</div>
</div>
{/* Notes List */}
<div className="p-4 space-y-3">
{paginatedNotes.length > 0 ? (
paginatedNotes.map(note => (
<MobileCard
key={note.id}
onClick={() => navigate(`/mobile/notes/${note.id}`)}
>
<div className="relative">
{/* Priority Button */}
<Button
variant="ghost"
size="icon"
className={`absolute -top-1 -right-1 h-8 w-8 ${note.priority ? 'opacity-100' : 'opacity-50'
}`}
onClick={(e) => handleTogglePriority(note, e)}
>
<span className={`text-lg font-bold ${note.priority ? 'text-red-500' : 'text-muted-foreground'}`}>
!
</span>
</Button>
{/* Notes View - List or Kanban */}
{viewMode === 'list' ? (
<div className="p-4 space-y-3">
{paginatedNotes.length > 0 ? (
paginatedNotes.map(note => (
<MobileCard
key={note.id}
onClick={() => navigate(`/mobile/notes/${note.id}`)}
>
<div className="relative">
{/* Priority Button */}
<Button
variant="ghost"
size="icon"
className={`absolute -top-1 -right-1 h-8 w-8 ${note.priority ? 'opacity-100' : 'opacity-50'
}`}
onClick={(e) => handleTogglePriority(note, e)}
>
<span className={`text-lg font-bold ${note.priority ? 'text-red-500' : 'text-muted-foreground'}`}>
!
</span>
</Button>
<h3 className="font-semibold mb-2 line-clamp-1 pr-8">
{note.title || "Sans titre"}
</h3>
<h3 className="font-semibold mb-2 line-clamp-1 pr-8">
{note.title || "Sans titre"}
</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
</p>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
</p>
{/* Badges */}
<div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && (
<Badge variant="secondary" className="text-xs">
<CheckSquare className="h-3 w-3 mr-1" />
{note.todos.length}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary" className="text-xs">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
<TagIcon className="h-3 w-3 mr-1" />
{tag.name}
{/* Badges */}
<div className="flex gap-2 flex-wrap mb-2">
{note.todos && note.todos.length > 0 && (
<Badge variant="secondary" className="text-xs">
<CheckSquare className="h-3 w-3 mr-1" />
{note.todos.length}
</Badge>
))
)}
{note.tags && note.tags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{note.tags.length - 2}
</Badge>
)}
{note.images && note.images.length > 0 && (
<Badge variant="secondary" className="text-xs">
<ImageIcon className="h-3 w-3 mr-1" />
{note.images.length}
</Badge>
)}
{note.files && note.files.length > 0 && (
<Badge variant="secondary" className="text-xs">
<Paperclip className="h-3 w-3 mr-1" />
{note.files.length}
</Badge>
)}
{note.tags && note.tags.length > 0 && (
note.tags.slice(0, 2).map((tag) => (
<Badge key={tag.id} variant="outline" className="text-xs">
<TagIcon className="h-3 w-3 mr-1" />
{tag.name}
</Badge>
))
)}
{note.tags && note.tags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{note.tags.length - 2}
</Badge>
)}
</div>
{/* Date */}
{note.updated_at && (
<p className="text-xs text-muted-foreground">
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</p>
)}
</div>
</MobileCard>
))
) : (
<div className="text-center py-16">
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNote} size="lg">
<Plus className="h-5 w-5 mr-2" />
Créer votre première note
</Button>
)}
</div>
)}
{/* Date */}
{note.updated_at && (
<p className="text-xs text-muted-foreground">
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</p>
)}
</div>
</MobileCard>
))
) : (
<div className="text-center py-16">
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
</p>
{!searchQuery && (
<Button onClick={handleCreateNote} size="lg">
<Plus className="h-5 w-5 mr-2" />
Créer votre première note
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 px-4 pb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
‹ Précédent
</Button>
)}
</div>
)}
</div>
<span className="text-sm text-muted-foreground">
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
>
Suivant ›
</Button>
</div>
)}
</div>
) : (
/* Kanban View */
<div className="p-2 overflow-x-auto">
<KanbanBoard
notes={filteredNotes}
onNoteClick={(note) => navigate(`/mobile/notes/${note.id}`)}
onCreateNote={(columnId) => {
handleCreateNote();
}}
onMoveNote={async (noteId, newStatus) => {
const note = notes.find(n => n.id === noteId);
if (!note) return;
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 px-4 pb-4">
<Button
variant="ghost"
size="sm"
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
>
‹ Précédent
</Button>
<span className="text-sm text-muted-foreground">
{page + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
>
Suivant ›
</Button>
// Remove old status tags
const oldStatusTags = note.tags?.filter(tag =>
['backlog', 'todo', 'in_progress', 'review', 'done'].includes(tag.name.toLowerCase())
) || [];
for (const tag of oldStatusTags) {
if (tag.id && note.id) {
await TagsService.deleteTag(note.id, tag.id);
}
}
// Add new status tag
if (note.id) {
await TagsService.addTag(note.id, newStatus);
await loadNotes();
showSuccess(`Note déplacée vers ${newStatus}`);
}
}}
/>
</div>
)}
+24 -14
View File
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
User,
LogOut,
@@ -53,10 +54,6 @@ export default function SettingsPage() {
}
};
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return (
<div className="p-4 space-y-6 pb-24">
{/* Appearance */}
@@ -68,16 +65,29 @@ export default function SettingsPage() {
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<Label htmlFor="dark-mode" className="text-base">
Mode sombre
</Label>
<Switch
id="dark-mode"
checked={theme === "dark"}
onCheckedChange={toggleTheme}
/>
</div>
<RadioGroup value={theme} onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}>
<div className="flex items-center space-x-2 mb-3">
<RadioGroupItem value="light" id="light" />
<Label htmlFor="light" className="text-base font-normal cursor-pointer flex items-center gap-2">
<Sun className="h-4 w-4" />
Clair
</Label>
</div>
<div className="flex items-center space-x-2 mb-3">
<RadioGroupItem value="dark" id="dark" />
<Label htmlFor="dark" className="text-base font-normal cursor-pointer flex items-center gap-2">
<Moon className="h-4 w-4" />
Sombre
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="system" id="system" />
<Label htmlFor="system" className="text-base font-normal cursor-pointer flex items-center gap-2">
<User className="h-4 w-4" />
Système
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
+89 -2
View File
@@ -6,6 +6,9 @@ interface Todo {
text: string;
completed: boolean;
position?: number;
due_date?: string;
parent_id?: number;
level?: number;
}
interface Image {
@@ -36,9 +39,9 @@ export interface Note {
}
class NotesService {
async getNotes(): Promise<Note[]> {
async getNotes(archived: boolean = false): Promise<Note[]> {
try {
const response = await fetch("/api/notes", {
const response = await fetch(`/api/notes?archived=${archived}`, {
headers: AuthService.getHeaders()
});
@@ -219,6 +222,90 @@ class NotesService {
return false;
}
}
// Todo management methods
async addTodo(noteId: number, text: string, dueDate?: string): Promise<Todo | null> {
try {
const payload: any = { text };
if (dueDate) {
payload.due_date = dueDate;
}
const response = await fetch(`/api/notes/${noteId}/todos`, {
method: "POST",
headers: AuthService.getHeaders(),
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error("Erreur lors de l'ajout de la tâche");
}
const result = await response.json();
showSuccess("Tâche ajoutée");
return result.todo || result;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return null;
}
}
async updateTodo(todoId: number, updates: Partial<Todo>): Promise<Todo | null> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "PUT",
headers: AuthService.getHeaders(),
body: JSON.stringify(updates)
});
if (!response.ok) {
throw new Error("Erreur lors de la mise à jour de la tâche");
}
const result = await response.json();
return result.todo || result;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return null;
}
}
async toggleTodo(todoId: number, completed: boolean): Promise<boolean> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "PUT",
headers: AuthService.getHeaders(),
body: JSON.stringify({ completed })
});
if (!response.ok) {
throw new Error("Erreur lors de la mise à jour de la tâche");
}
return true;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return false;
}
}
async deleteTodo(todoId: number): Promise<boolean> {
try {
const response = await fetch(`/api/notes/todos/${todoId}`, {
method: "DELETE",
headers: AuthService.getHeaders()
});
if (!response.ok) {
throw new Error("Erreur lors de la suppression de la tâche");
}
return true;
} catch (error) {
showError(error instanceof Error ? error.message : "Erreur serveur");
return false;
}
}
}
export default new NotesService();
+33 -4
View File
@@ -60,6 +60,8 @@ class OpenRouterService {
*/
async sendMessage(model: string, messages: Array<{ role: string; content: string }>): Promise<string> {
try {
console.log(`[OpenRouter] Envoi requête - model: ${model}, messages: ${messages.length}`);
const response = await fetch('/api/openrouter/chat', {
method: 'POST',
headers: {
@@ -69,15 +71,42 @@ class OpenRouterService {
body: JSON.stringify({ model, messages })
});
console.log(`[OpenRouter] Réponse reçue - status: ${response.status}`);
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Erreur inconnue' }));
throw new Error(error.error || 'Erreur lors de la communication avec l\'IA');
const errorData = await response.json().catch(() => ({ error: 'Erreur inconnue' }));
const errorMessage = errorData.error || `Erreur HTTP ${response.status}`;
console.error('[OpenRouter] Erreur:', errorMessage);
// Messages d'erreur plus clairs selon le code HTTP
if (response.status === 400) {
throw new Error(errorMessage);
} else if (response.status === 401) {
throw new Error('Clé API invalide. Vérifiez la configuration dans Paramètres.');
} else if (response.status === 402) {
throw new Error('Crédits OpenRouter épuisés. Rechargez votre compte.');
} else if (response.status === 429) {
throw new Error('Trop de requêtes. Attendez quelques instants.');
} else if (response.status === 502 || response.status === 503) {
throw new Error('Service OpenRouter temporairement indisponible. Réessayez dans quelques instants.');
} else {
throw new Error(errorMessage);
}
}
const data = await response.json();
return data.choices?.[0]?.message?.content || 'Pas de réponse';
const content = data.choices?.[0]?.message?.content;
if (!content) {
console.error('[OpenRouter] Réponse vide:', data);
throw new Error('Aucune réponse générée par le modèle');
}
console.log(`[OpenRouter] Succès - ${content.length} caractères`);
return content;
} catch (error: any) {
console.error('Erreur lors de l\'envoi du message:', error);
console.error('[OpenRouter] Erreur exception:', error);
throw error;
}
}
+6 -2
View File
@@ -33,9 +33,13 @@ class TodosService {
}
}
async createTodo(text: string, priority?: boolean): Promise<Todo> {
async createTodo(text: string, priority?: boolean, dueDate?: string): Promise<Todo> {
try {
const response = await axios.post(this.baseURL, { text, priority: priority || false, completed: false }, this.getAuthHeader());
const payload: any = { text, priority: priority || false, completed: false };
if (dueDate) {
payload.due_date = dueDate;
}
const response = await axios.post(this.baseURL, payload, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error creating todo:', error);
+163
View File
@@ -0,0 +1,163 @@
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
export interface BackupData {
version: string;
exportDate: string;
notes: Note[];
globalTodos: Todo[];
settings?: Record<string, any>;
}
/**
* Export all app data to JSON
*/
export async function exportToJSON(
notes: Note[],
globalTodos: Todo[],
settings?: Record<string, any>
): Promise<string> {
const backup: BackupData = {
version: "1.0.0",
exportDate: new Date().toISOString(),
notes,
globalTodos,
settings
};
return JSON.stringify(backup, null, 2);
}
/**
* Download backup as JSON file
*/
export function downloadBackup(jsonData: string, filename?: string): void {
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const timestamp = new Date().toISOString().split('T')[0];
link.href = url;
link.download = filename || `noteflow-backup-${timestamp}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Parse and validate backup JSON
*/
export function parseBackupJSON(jsonString: string): BackupData | null {
try {
const data = JSON.parse(jsonString);
// Validate structure
if (!data.version || !data.exportDate || !Array.isArray(data.notes)) {
throw new Error('Format de backup invalide');
}
return data as BackupData;
} catch (error) {
console.error('Error parsing backup:', error);
return null;
}
}
/**
* Read file as text
*/
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Failed to read file'));
}
};
reader.onerror = () => {
reject(new Error('File reading error'));
};
reader.readAsText(file);
});
}
/**
* Export notes to CSV format
*/
export function exportNotesToCSV(notes: Note[]): string {
const headers = ['ID', 'Title', 'Created', 'Updated', 'Archived', 'Priority', 'Tags', 'Todos Count'];
const rows = notes.map(note => [
note.id || '',
`"${(note.title || '').replace(/"/g, '""')}"`,
note.created_at || '',
note.updated_at || '',
note.archived ? 'Yes' : 'No',
note.priority ? 'Yes' : 'No',
`"${(note.tags || []).map(t => t.name).join(', ')}"`,
(note.todos || []).length
]);
const csv = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
return csv;
}
/**
* Download CSV file
*/
export function downloadCSV(csvData: string, filename?: string): void {
const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const timestamp = new Date().toISOString().split('T')[0];
link.href = url;
link.download = filename || `noteflow-export-${timestamp}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Get backup statistics
*/
export function getBackupStats(backup: BackupData): {
notesCount: number;
todosCount: number;
tagsCount: number;
imagesCount: number;
filesCount: number;
} {
const allTags = new Set<string>();
let imagesCount = 0;
let filesCount = 0;
backup.notes.forEach(note => {
note.tags?.forEach(tag => allTags.add(tag.name));
imagesCount += (note.images?.length || 0);
filesCount += (note.files?.length || 0);
});
return {
notesCount: backup.notes.length,
todosCount: backup.globalTodos?.length || 0,
tagsCount: allTags.size,
imagesCount,
filesCount
};
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Compresse une image en la redimensionnant et en réduisant sa qualité
* @param file - Le fichier image à compresser
* @param maxWidth - Largeur maximale (défaut: 1920px)
* @param maxHeight - Hauteur maximale (défaut: 1080px)
* @param quality - Qualité de compression JPEG (0-1, défaut: 0.8)
* @returns Promise<File> - Le fichier image compressé
*/
export async function compressImage(
file: File,
maxWidth: number = 1920,
maxHeight: number = 1080,
quality: number = 0.8
): Promise<File> {
return new Promise((resolve, reject) => {
// Vérifier que c'est bien une image
if (!file.type.startsWith('image/')) {
reject(new Error('Le fichier n\'est pas une image'));
return;
}
// Si l'image est déjà petite (< 1MB), on la retourne telle quelle
const oneMB = 1024 * 1024;
if (file.size < oneMB) {
resolve(file);
return;
}
const reader = new FileReader();
reader.onerror = () => reject(new Error('Erreur lors de la lecture du fichier'));
reader.onload = (e) => {
const img = new Image();
img.onerror = () => reject(new Error('Erreur lors du chargement de l\'image'));
img.onload = () => {
try {
// Calculer les nouvelles dimensions en gardant le ratio
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
// Créer un canvas pour redimensionner l'image
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Impossible de créer le contexte canvas'));
return;
}
// Dessiner l'image redimensionnée
ctx.drawImage(img, 0, 0, width, height);
// Déterminer le type de sortie et la qualité
let outputType = 'image/jpeg';
let outputQuality = quality;
// Si c'est un PNG avec transparence, on garde le format PNG
if (file.type === 'image/png') {
// Vérifier si l'image a de la transparence
const imageData = ctx.getImageData(0, 0, width, height);
const hasTransparency = imageData.data.some((_, i) => i % 4 === 3 && imageData.data[i] < 255);
if (hasTransparency) {
outputType = 'image/png';
outputQuality = 0.9; // PNG utilise une compression sans perte
}
}
// Convertir le canvas en Blob
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Erreur lors de la conversion de l\'image'));
return;
}
// Si l'image compressée fait toujours plus de 2MB, on réduit encore la qualité
const twoMB = 2 * 1024 * 1024;
if (blob.size > twoMB && outputType === 'image/jpeg' && outputQuality > 0.5) {
// Réessayer avec une qualité plus faible
canvas.toBlob(
(blob2) => {
if (!blob2) {
reject(new Error('Erreur lors de la deuxième compression'));
return;
}
// Créer un nouveau fichier avec le blob compressé
const compressedFile = new File(
[blob2],
file.name.replace(/\.[^/.]+$/, '.jpg'), // Remplacer l'extension par .jpg
{ type: 'image/jpeg' }
);
resolve(compressedFile);
},
'image/jpeg',
0.6 // Qualité réduite
);
return;
}
// Créer un nouveau fichier avec le blob compressé
const extension = outputType === 'image/png' ? '.png' : '.jpg';
const compressedFile = new File(
[blob],
file.name.replace(/\.[^/.]+$/, extension),
{ type: outputType }
);
resolve(compressedFile);
},
outputType,
outputQuality
);
} catch (error) {
reject(error);
}
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
}
/**
* Formate la taille d'un fichier en MB ou KB
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
+241
View File
@@ -0,0 +1,241 @@
import { Note } from "@/services/NotesService";
import { Todo } from "@/services/TodosService";
/**
* Convertit le contenu HTML d'une note en Markdown
*/
function htmlToMarkdown(html: string): string {
let markdown = html;
// Enlever les balises HTML simples et les convertir en Markdown
markdown = markdown
// Headings
.replace(/<h1[^>]*>(.*?)<\/h1>/g, '# $1\n')
.replace(/<h2[^>]*>(.*?)<\/h2>/g, '## $1\n')
.replace(/<h3[^>]*>(.*?)<\/h3>/g, '### $1\n')
// Bold and italic
.replace(/<strong[^>]*>(.*?)<\/strong>/g, '**$1**')
.replace(/<b[^>]*>(.*?)<\/b>/g, '**$1**')
.replace(/<em[^>]*>(.*?)<\/em>/g, '*$1*')
.replace(/<i[^>]*>(.*?)<\/i>/g, '*$1*')
// Lists
.replace(/<ul[^>]*>/g, '\n')
.replace(/<\/ul>/g, '\n')
.replace(/<ol[^>]*>/g, '\n')
.replace(/<\/ol>/g, '\n')
.replace(/<li[^>]*>(.*?)<\/li>/g, '- $1\n')
// Paragraphs and breaks
.replace(/<p[^>]*>/g, '\n')
.replace(/<\/p>/g, '\n')
.replace(/<br\s*\/?>/g, '\n')
// Links
.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/g, '[$2]($1)')
// Code
.replace(/<code[^>]*>(.*?)<\/code>/g, '`$1`')
.replace(/<pre[^>]*>(.*?)<\/pre>/g, '```\n$1\n```')
// Remove remaining HTML tags
.replace(/<[^>]*>/g, '')
// Decode HTML entities
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
// Clean up extra whitespace
.replace(/\n{3,}/g, '\n\n')
.trim();
return markdown;
}
/**
* Exporte une note au format Markdown
*/
export function exportNoteAsMarkdown(note: Note): string {
let markdown = '';
// Title
markdown += `# ${note.title || 'Sans titre'}\n\n`;
// Metadata
if (note.created_at) {
const date = new Date(note.created_at);
markdown += `*Créée le: ${date.toLocaleDateString('fr-FR')} à ${date.toLocaleTimeString('fr-FR')}*\n\n`;
}
// Tags
if (note.tags && note.tags.length > 0) {
markdown += `**Tags:** ${note.tags.map(tag => `#${tag.name}`).join(', ')}\n\n`;
}
// Priority
if (note.priority) {
markdown += `⭐ **Prioritaire**\n\n`;
}
// Separator
markdown += '---\n\n';
// Content
if (note.content) {
markdown += htmlToMarkdown(note.content);
markdown += '\n\n';
}
// Todos
if (note.todos && note.todos.length > 0) {
markdown += '## Tâches\n\n';
note.todos.forEach(todo => {
const checkbox = todo.completed ? '[x]' : '[ ]';
markdown += `- ${checkbox} ${todo.text}\n`;
});
markdown += '\n';
}
// Images
if (note.images && note.images.length > 0) {
markdown += '## Images\n\n';
note.images.forEach(image => {
markdown += `![${image.original_name || 'Image'}](/uploads/images/${image.filename})\n`;
});
markdown += '\n';
}
return markdown;
}
/**
* Télécharge une note au format Markdown
*/
export function downloadNoteAsMarkdown(note: Note): void {
const markdown = exportNoteAsMarkdown(note);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${note.title || 'note'}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Exporte toutes les notes au format Markdown (un fichier par note dans un zip)
*/
export function exportAllNotesAsMarkdown(notes: Note[]): string {
let combined = `# Mes Notes\n\n`;
combined += `*Exporté le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}*\n\n`;
combined += `---\n\n`;
notes.forEach((note, index) => {
if (index > 0) {
combined += `\n\n---\n\n`;
}
combined += exportNoteAsMarkdown(note);
});
return combined;
}
/**
* Télécharge toutes les notes dans un fichier Markdown
*/
export function downloadAllNotesAsMarkdown(notes: Note[]): void {
const markdown = exportAllNotesAsMarkdown(notes);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const filename = `noteflow-export-${new Date().toISOString().split('T')[0]}.md`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Exporte un todo au format Markdown
*/
export function exportTodoAsMarkdown(todo: Todo): string {
const checkbox = todo.completed ? '[x]' : '[ ]';
let markdown = `- ${checkbox} ${todo.text}`;
if (todo.priority) {
markdown += ' ⭐';
}
if (todo.in_progress) {
markdown += ' 🔄';
}
return markdown;
}
/**
* Exporte tous les todos au format Markdown
*/
export function exportTodosAsMarkdown(todos: Todo[]): string {
let markdown = '# Mes Tâches\n\n';
markdown += `*Exporté le ${new Date().toLocaleDateString('fr-FR')} à ${new Date().toLocaleTimeString('fr-FR')}*\n\n`;
// Tâches prioritaires
const priorityTodos = todos.filter(t => t.priority && !t.completed);
if (priorityTodos.length > 0) {
markdown += '## ⭐ Prioritaires\n\n';
priorityTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches en cours
const inProgressTodos = todos.filter(t => t.in_progress && !t.completed);
if (inProgressTodos.length > 0) {
markdown += '## 🔄 En cours\n\n';
inProgressTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches en attente
const pendingTodos = todos.filter(t => !t.completed && !t.priority && !t.in_progress);
if (pendingTodos.length > 0) {
markdown += '## 📋 En attente\n\n';
pendingTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
markdown += '\n';
}
// Tâches terminées
const completedTodos = todos.filter(t => t.completed);
if (completedTodos.length > 0) {
markdown += '## ✅ Terminées\n\n';
completedTodos.forEach(todo => {
markdown += exportTodoAsMarkdown(todo) + '\n';
});
}
return markdown;
}
/**
* Télécharge tous les todos au format Markdown
*/
export function downloadTodosAsMarkdown(todos: Todo[]): void {
const markdown = exportTodosAsMarkdown(todos);
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const filename = `noteflow-todos-${new Date().toISOString().split('T')[0]}.md`;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
+187
View File
@@ -0,0 +1,187 @@
import { Note } from "@/services/NotesService";
/**
* Pattern to match wiki-style links: [[Note Title]]
*/
const WIKI_LINK_PATTERN = /\[\[([^\]]+)\]\]/g;
/**
* Extract all note links from content
*/
export function extractNoteLinks(content: string): string[] {
if (!content) return [];
const matches = content.matchAll(WIKI_LINK_PATTERN);
const links: string[] = [];
for (const match of matches) {
if (match[1]) {
links.push(match[1].trim());
}
}
return [...new Set(links)]; // Remove duplicates
}
/**
* Find notes that match the given link text
*/
export function findLinkedNotes(linkText: string, allNotes: Note[]): Note[] {
const searchText = linkText.toLowerCase().trim();
return allNotes.filter(note => {
if (!note.title) return false;
return note.title.toLowerCase().includes(searchText);
});
}
/**
* Convert wiki-style links to HTML clickable links
*/
export function convertLinksToHTML(
content: string,
allNotes: Note[],
onLinkClick: (noteId: number) => void
): string {
if (!content) return '';
return content.replace(WIKI_LINK_PATTERN, (match, linkText) => {
const trimmedText = linkText.trim();
const matchedNotes = findLinkedNotes(trimmedText, allNotes);
if (matchedNotes.length > 0) {
// Found matching note - create clickable link
const note = matchedNotes[0];
return `<a
href="#"
class="note-link"
data-note-id="${note.id}"
style="color: #3b82f6; text-decoration: underline; cursor: pointer;"
title="Ouvrir: ${note.title}"
>${trimmedText}</a>`;
} else {
// No matching note - show as grayed out
return `<span
class="note-link-broken"
style="color: #9ca3af; text-decoration: line-through;"
title="Note introuvable: ${trimmedText}"
>[[${trimmedText}]]</span>`;
}
});
}
/**
* Get backlinks for a note (notes that link to this one)
*/
export function getBacklinks(note: Note, allNotes: Note[]): Note[] {
if (!note.title) return [];
const backlinks: Note[] = [];
const noteTitle = note.title.toLowerCase();
for (const otherNote of allNotes) {
if (otherNote.id === note.id || !otherNote.content) continue;
const links = extractNoteLinks(otherNote.content);
const hasLink = links.some(link =>
link.toLowerCase().includes(noteTitle) ||
noteTitle.includes(link.toLowerCase())
);
if (hasLink) {
backlinks.push(otherNote);
}
}
return backlinks;
}
/**
* Suggest note titles for autocomplete when typing [[
*/
export function suggestNoteTitles(
partialText: string,
allNotes: Note[],
limit = 10
): Note[] {
if (!partialText) {
// Return recent notes when no search text
return allNotes
.filter(note => note.title && !note.archived)
.sort((a, b) =>
new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime()
)
.slice(0, limit);
}
const searchText = partialText.toLowerCase().trim();
// Find notes matching the partial text
const matches = allNotes
.filter(note => {
if (!note.title || note.archived) return false;
return note.title.toLowerCase().includes(searchText);
})
.sort((a, b) => {
// Prioritize exact matches and more recent notes
const aTitle = a.title?.toLowerCase() || '';
const bTitle = b.title?.toLowerCase() || '';
const aExact = aTitle === searchText ? 1 : 0;
const bExact = bTitle === searchText ? 1 : 0;
if (aExact !== bExact) return bExact - aExact;
const aStarts = aTitle.startsWith(searchText) ? 1 : 0;
const bStarts = bTitle.startsWith(searchText) ? 1 : 0;
if (aStarts !== bStarts) return bStarts - aStarts;
// Fall back to recency
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime();
});
return matches.slice(0, limit);
}
/**
* Parse content and detect cursor position for link autocomplete
* Returns the partial link text if cursor is inside a [[...]] pattern
*/
export function detectLinkAtCursor(content: string, cursorPosition: number): {
isInLink: boolean;
linkText: string;
startPos: number;
endPos: number;
} | null {
// Find all [[ positions before cursor
const beforeCursor = content.substring(0, cursorPosition);
const lastOpenBracket = beforeCursor.lastIndexOf('[[');
if (lastOpenBracket === -1) {
return null; // Not in a link
}
// Check if there's a closing ]] between lastOpenBracket and cursor
const betweenBrackets = content.substring(lastOpenBracket, cursorPosition);
if (betweenBrackets.includes(']]')) {
return null; // Link is already closed
}
// Find the closing ]] after cursor (if any)
const afterCursor = content.substring(cursorPosition);
const nextCloseBracket = afterCursor.indexOf(']]');
const endPos = nextCloseBracket !== -1
? cursorPosition + nextCloseBracket + 2
: cursorPosition;
// Extract the link text
const linkText = content.substring(lastOpenBracket + 2, cursorPosition).trim();
return {
isInLink: true,
linkText,
startPos: lastOpenBracket,
endPos
};
}
+363
View File
@@ -0,0 +1,363 @@
export interface NoteTemplate {
id: string;
name: string;
description: string;
icon: string;
category: string;
content: string;
todos?: Array<{ text: string; completed: boolean }>;
}
export const DEFAULT_TEMPLATES: NoteTemplate[] = [
{
id: 'blank',
name: 'Note vide',
description: 'Commencer avec une page blanche',
icon: '📄',
category: 'Général',
content: '',
todos: []
},
{
id: 'meeting',
name: 'Réunion',
description: 'Notes de réunion structurées',
icon: '🤝',
category: 'Travail',
content: `<h2>Réunion - [Titre]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Participants:</strong> </p>
<p><strong>Objectif:</strong> </p>
<h3>Points discutés</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>Décisions</h3>
<p>• </p>
<h3>Prochaines étapes</h3>
<p>→ </p>`,
todos: [
{ text: 'Envoyer le compte-rendu', completed: false },
{ text: 'Planifier le suivi', completed: false }
]
},
{
id: 'daily-journal',
name: 'Journal quotidien',
description: 'Réflexions et gratitude du jour',
icon: '📔',
category: 'Personnel',
content: `<h2>Journal - ${new Date().toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</h2>
<h3>🌅 Ce matin je me sens...</h3>
<p></p>
<h3>🎯 Mes priorités aujourd'hui</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>💡 Idées et réflexions</h3>
<p></p>
<h3>🙏 Gratitude</h3>
<p>Aujourd'hui je suis reconnaissant(e) pour:</p>
<p>• </p>
<h3>🌙 Bilan de la journée</h3>
<p></p>`,
todos: []
},
{
id: 'project-planning',
name: 'Planification de projet',
description: 'Organiser un nouveau projet',
icon: '🎯',
category: 'Travail',
content: `<h2>Projet - [Nom du projet]</h2>
<h3>📋 Vue d'ensemble</h3>
<p><strong>Objectif:</strong> </p>
<p><strong>Deadline:</strong> </p>
<p><strong>Responsable:</strong> </p>
<h3>🎯 Objectifs</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>📊 Phases du projet</h3>
<p><strong>Phase 1:</strong> Préparation</p>
<p>• </p>
<p><strong>Phase 2:</strong> Développement</p>
<p>• </p>
<p><strong>Phase 3:</strong> Finalisation</p>
<p>• </p>
<h3>👥 Équipe</h3>
<p>• </p>
<h3>⚠️ Risques identifiés</h3>
<p>• </p>
<h3>📌 Ressources nécessaires</h3>
<p>• </p>`,
todos: [
{ text: 'Définir le scope', completed: false },
{ text: 'Assembler l\'équipe', completed: false },
{ text: 'Créer le planning', completed: false }
]
},
{
id: 'brainstorming',
name: 'Brainstorming',
description: 'Session de génération d\'idées',
icon: '💭',
category: 'Créativité',
content: `<h2>💭 Brainstorming - [Sujet]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Question centrale:</strong> </p>
<h3>💡 Idées</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
<p>5. </p>
<h3>⭐ Idées à creuser</h3>
<p>• </p>
<h3>🚀 Actions possibles</h3>
<p>→ </p>
<h3>🔗 Connexions et insights</h3>
<p></p>`,
todos: []
},
{
id: 'recipe',
name: 'Recette de cuisine',
description: 'Noter une recette',
icon: '👨‍🍳',
category: 'Personnel',
content: `<h2>🍳 Recette - [Nom du plat]</h2>
<p><strong>⏱️ Temps de préparation:</strong> </p>
<p><strong>👥 Portions:</strong> </p>
<p><strong>😋 Difficulté:</strong> Facile / Moyen / Difficile</p>
<h3>📝 Ingrédients</h3>
<p>• </p>
<h3>👨‍🍳 Préparation</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>💡 Astuces</h3>
<p>• </p>
<h3>📌 Notes</h3>
<p></p>`,
todos: []
},
{
id: 'weekly-review',
name: 'Revue hebdomadaire',
description: 'Bilan et planification de la semaine',
icon: '📊',
category: 'Productivité',
content: `<h2>📊 Revue de la semaine</h2>
<p><strong>Semaine du:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<h3>✅ Accomplissements</h3>
<p>Cette semaine j'ai réussi à:</p>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>📈 Progrès</h3>
<p>• </p>
<h3>⚠️ Défis rencontrés</h3>
<p>• </p>
<h3>💡 Leçons apprises</h3>
<p>• </p>
<h3>🎯 Objectifs semaine prochaine</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>🔄 Habitudes à améliorer</h3>
<p>• </p>`,
todos: [
{ text: 'Planifier la semaine prochaine', completed: false }
]
},
{
id: 'bug-report',
name: 'Rapport de bug',
description: 'Documenter un problème technique',
icon: '🐛',
category: 'Développement',
content: `<h2>🐛 Bug Report</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Priorité:</strong> 🔴 Critique / 🟠 Haute / 🟡 Moyenne / 🟢 Basse</p>
<p><strong>Statut:</strong> Nouveau / En cours / Résolu</p>
<h3>📋 Description</h3>
<p></p>
<h3>🔄 Étapes pour reproduire</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>✅ Résultat attendu</h3>
<p></p>
<h3>❌ Résultat actuel</h3>
<p></p>
<h3>💻 Environnement</h3>
<p><strong>OS:</strong> </p>
<p><strong>Navigateur:</strong> </p>
<p><strong>Version:</strong> </p>
<h3>📌 Notes additionnelles</h3>
<p></p>
<h3>🔧 Solution proposée</h3>
<p></p>`,
todos: [
{ text: 'Reproduire le bug', completed: false },
{ text: 'Identifier la cause', completed: false },
{ text: 'Implémenter le fix', completed: false },
{ text: 'Tester la solution', completed: false }
]
},
{
id: 'learning-notes',
name: 'Notes d\'apprentissage',
description: 'Apprendre un nouveau sujet',
icon: '📚',
category: 'Éducation',
content: `<h2>📚 Notes d'apprentissage - [Sujet]</h2>
<p><strong>Date:</strong> ${new Date().toLocaleDateString('fr-FR')}</p>
<p><strong>Source:</strong> </p>
<h3>🎯 Objectifs d'apprentissage</h3>
<p>• </p>
<h3>📝 Concepts clés</h3>
<p><strong>Concept 1:</strong> </p>
<p>• </p>
<p><strong>Concept 2:</strong> </p>
<p>• </p>
<h3>💡 Exemples</h3>
<p></p>
<h3>❓ Questions</h3>
<p>• </p>
<h3>🔗 Ressources</h3>
<p>• </p>
<h3>📌 À retenir</h3>
<p>• </p>
<h3>✍️ Pratique</h3>
<p>Exercices à faire:</p>
<p>1. </p>`,
todos: [
{ text: 'Réviser les notes', completed: false },
{ text: 'Faire les exercices', completed: false }
]
},
{
id: 'travel-planning',
name: 'Planification de voyage',
description: 'Organiser un déplacement',
icon: '✈️',
category: 'Personnel',
content: `<h2>✈️ Voyage - [Destination]</h2>
<p><strong>📅 Dates:</strong> du [date] au [date]</p>
<p><strong>👥 Voyageurs:</strong> </p>
<p><strong>💰 Budget:</strong> </p>
<h3>🎒 À emporter</h3>
<p>Vêtements:</p>
<p>• </p>
<p>Documents:</p>
<p>• Passeport</p>
<p>• Billets</p>
<p>• Assurance</p>
<h3>🏨 Hébergement</h3>
<p><strong>Hôtel/Airbnb:</strong> </p>
<p><strong>Adresse:</strong> </p>
<p><strong>Check-in:</strong> </p>
<h3>✈️ Transport</h3>
<p><strong>Vol aller:</strong> </p>
<p><strong>Vol retour:</strong> </p>
<h3>📍 Lieux à visiter</h3>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<h3>🍽️ Restaurants recommandés</h3>
<p>• </p>
<h3>💡 Conseils et infos pratiques</h3>
<p>• </p>`,
todos: [
{ text: 'Réserver les vols', completed: false },
{ text: 'Réserver l\'hébergement', completed: false },
{ text: 'Faire la valise', completed: false }
]
}
];
export function getTemplatesByCategory(): Map<string, NoteTemplate[]> {
const categorized = new Map<string, NoteTemplate[]>();
DEFAULT_TEMPLATES.forEach(template => {
const category = template.category;
if (!categorized.has(category)) {
categorized.set(category, []);
}
categorized.get(category)!.push(template);
});
return categorized;
}
export function getTemplateById(id: string): NoteTemplate | undefined {
return DEFAULT_TEMPLATES.find(t => t.id === id);
}
export function searchTemplates(query: string): NoteTemplate[] {
const lowerQuery = query.toLowerCase();
return DEFAULT_TEMPLATES.filter(template =>
template.name.toLowerCase().includes(lowerQuery) ||
template.description.toLowerCase().includes(lowerQuery) ||
template.category.toLowerCase().includes(lowerQuery)
);
}
+179
View File
@@ -0,0 +1,179 @@
import { Note } from "@/services/NotesService";
// Common French stop words to filter out
const FRENCH_STOP_WORDS = new Set([
'le', 'la', 'les', 'un', 'une', 'des', 'de', 'du', 'et', 'ou', 'mais', 'donc',
'or', 'ni', 'car', 'est', 'sont', 'être', 'avoir', 'a', 'dans', 'sur', 'pour',
'par', 'avec', 'sans', 'sous', 'vers', 'chez', 'depuis', 'pendant', 'avant',
'après', 'entre', 'parmi', 'selon', 'dont', 'que', 'qui', 'quoi', 'où', 'quand',
'comment', 'pourquoi', 'ce', 'cet', 'cette', 'ces', 'mon', 'ton', 'son', 'ma',
'ta', 'sa', 'mes', 'tes', 'ses', 'notre', 'votre', 'leur', 'nos', 'vos', 'leurs',
'je', 'tu', 'il', 'elle', 'nous', 'vous', 'ils', 'elles', 'on', 'me', 'te', 'se',
'lui', 'leur', 'y', 'en', 'ne', 'pas', 'plus', 'moins', 'très', 'trop', 'assez',
'beaucoup', 'peu', 'bien', 'mal', 'tout', 'toute', 'tous', 'toutes', 'même',
'autre', 'autres', 'tel', 'telle', 'quel', 'quelle', 'quelque', 'plusieurs',
'chaque', 'aucun', 'aucune', 'nul', 'nulle', 'certain', 'certaine'
]);
// Category keywords for auto-tagging
const CATEGORY_KEYWORDS: Record<string, string[]> = {
'travail': ['réunion', 'projet', 'équipe', 'client', 'présentation', 'deadline', 'objectif', 'tâche', 'mission', 'entreprise', 'bureau', 'collègue', 'manager'],
'personnel': ['maison', 'famille', 'ami', 'vacances', 'loisir', 'hobby', 'détente', 'week-end', 'anniversaire', 'fête'],
'développement': ['code', 'bug', 'feature', 'api', 'database', 'frontend', 'backend', 'git', 'deploy', 'test', 'debug', 'javascript', 'python', 'react', 'node'],
'design': ['ui', 'ux', 'interface', 'maquette', 'prototype', 'couleur', 'police', 'layout', 'wireframe', 'mockup', 'figma', 'sketch'],
'finance': ['budget', 'dépense', 'économie', 'investissement', 'facture', 'paiement', 'compte', 'banque', 'argent', 'revenu'],
'santé': ['sport', 'exercice', 'nutrition', 'médecin', 'santé', 'bien-être', 'fitness', 'régime', 'yoga', 'course'],
'formation': ['cours', 'apprentissage', 'étude', 'formation', 'certifica', 'examen', 'leçon', 'tutoriel', 'documentation', 'lecture'],
'idée': ['brainstorming', 'innovation', 'créativité', 'concept', 'inspiration', 'réflexion', 'hypothèse', 'proposition'],
'urgent': ['urgent', 'important', 'prioritaire', 'asap', 'deadline', 'critique', 'essentiel'],
'meeting': ['réunion', 'rendez-vous', 'appel', 'visio', 'conférence', 'présentation', 'démonstration']
};
/**
* Extract keywords from text content
*/
export function extractKeywords(text: string, limit = 10): string[] {
if (!text) return [];
// Remove HTML tags
const cleanText = text.replace(/<[^>]*>/g, ' ');
// Convert to lowercase and split into words
const words = cleanText
.toLowerCase()
.replace(/[^\w\sàâäæçéèêëïîôùûüÿœ]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 3) // Min 4 chars
.filter(word => !FRENCH_STOP_WORDS.has(word));
// Count word frequency
const frequency = new Map<string, number>();
words.forEach(word => {
frequency.set(word, (frequency.get(word) || 0) + 1);
});
// Sort by frequency and take top N
return Array.from(frequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([word]) => word);
}
/**
* Suggest category tags based on content
*/
export function suggestCategoryTags(text: string): string[] {
if (!text) return [];
const cleanText = text.toLowerCase().replace(/<[^>]*>/g, ' ');
const suggestions: string[] = [];
for (const [category, keywords] of Object.entries(CATEGORY_KEYWORDS)) {
// Check if any category keyword appears in the text
const matches = keywords.filter(keyword =>
cleanText.includes(keyword.toLowerCase())
);
// If multiple matches, suggest this category
if (matches.length >= 2) {
suggestions.push(category);
}
}
return suggestions;
}
/**
* Suggest tags based on existing tags from similar notes
*/
export function suggestFromSimilarNotes(
currentNote: Note,
allNotes: Note[],
limit = 5
): string[] {
if (!currentNote.title && !currentNote.content) return [];
const currentKeywords = extractKeywords(
`${currentNote.title || ''} ${currentNote.content || ''}`,
15
);
if (currentKeywords.length === 0) return [];
// Find notes with similar keywords
const noteSimilarity = allNotes
.filter(note => note.id !== currentNote.id && note.tags && note.tags.length > 0)
.map(note => {
const noteKeywords = extractKeywords(
`${note.title || ''} ${note.content || ''}`,
15
);
// Calculate similarity (common keywords / total unique keywords)
const commonKeywords = currentKeywords.filter(kw =>
noteKeywords.includes(kw)
).length;
const totalKeywords = new Set([...currentKeywords, ...noteKeywords]).size;
const similarity = totalKeywords > 0 ? commonKeywords / totalKeywords : 0;
return {
note,
similarity
};
})
.filter(item => item.similarity > 0.2) // Min 20% similarity
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5); // Top 5 similar notes
// Extract tags from similar notes
const tagFrequency = new Map<string, number>();
noteSimilarity.forEach(({ note }) => {
note.tags?.forEach(tag => {
const tagName = tag.name.toLowerCase();
tagFrequency.set(tagName, (tagFrequency.get(tagName) || 0) + 1);
});
});
// Sort by frequency and return top tags
return Array.from(tagFrequency.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, limit)
.map(([tag]) => tag);
}
/**
* Get all tag suggestions for a note
*/
export function getSmartTagSuggestions(
note: Note,
allNotes: Note[],
existingTags: string[] = []
): string[] {
const content = `${note.title || ''} ${note.content || ''}`;
// Get category suggestions
const categoryTags = suggestCategoryTags(content);
// Get suggestions from similar notes
const similarNoteTags = suggestFromSimilarNotes(note, allNotes);
// Get keyword-based suggestions
const keywords = extractKeywords(content, 5);
// Combine all suggestions
const allSuggestions = [
...categoryTags,
...similarNoteTags,
...keywords
];
// Filter out existing tags and duplicates
const existingTagsLower = existingTags.map(t => t.toLowerCase());
const uniqueSuggestions = Array.from(
new Set(allSuggestions.map(t => t.toLowerCase()))
).filter(tag => !existingTagsLower.includes(tag));
// Return top 8 suggestions
return uniqueSuggestions.slice(0, 8);
}