Merge pull request #127 from R0m1k3/claude/fix-notes-sync-issue-01AMeEdrKQErsM7bvhKkqEEF

Claude/fix notes sync issue 01 a me edr kq ers m7bvh kkq eef
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-27 20:53:03 +01:00
commit 70bbe50cb2
27 files changed
+3409 -97

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
-- Migration: Add due_date to note_todos table
-- Description: Adds a due_date column to track task deadlines
-- Add due_date column to note_todos table
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
-- Create index on due_date for faster queries
CREATE INDEX IF NOT EXISTS idx_note_todos_due_date ON note_todos(due_date);
-- Create index on combination of completed and due_date for efficient queries of pending tasks
CREATE INDEX IF NOT EXISTS idx_note_todos_completed_due_date ON note_todos(completed, due_date);
@@ -0,0 +1,11 @@
-- Migration: Add due_date to global_todos table
-- Description: Adds a due_date column to track task deadlines for global todos
-- Add due_date column to global_todos table
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS due_date TIMESTAMPTZ;
-- Create index on due_date for faster queries
CREATE INDEX IF NOT EXISTS idx_global_todos_due_date ON global_todos(due_date);
-- Create index on combination of completed and due_date for efficient queries of pending tasks
CREATE INDEX IF NOT EXISTS idx_global_todos_completed_due_date ON global_todos(completed, due_date);
+16
View File
@@ -0,0 +1,16 @@
-- Migration: Add subtasks support to todos tables
-- Description: Adds parent_id column to enable hierarchical task structure
-- Add parent_id column to note_todos table (note todos)
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES note_todos(id) ON DELETE CASCADE;
-- Add parent_id column to global_todos table
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS parent_id INTEGER REFERENCES global_todos(id) ON DELETE CASCADE;
-- Create indexes for faster subtask queries
CREATE INDEX IF NOT EXISTS idx_note_todos_parent_id ON note_todos(parent_id);
CREATE INDEX IF NOT EXISTS idx_global_todos_parent_id ON global_todos(parent_id);
-- Add level column to track depth (optional, for UI optimization)
ALTER TABLE note_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0;
ALTER TABLE global_todos ADD COLUMN IF NOT EXISTS level INTEGER DEFAULT 0;
+25 -10
View File
@@ -61,7 +61,7 @@ router.get('/', async (req, res) => {
// Charger les todos, images, fichiers et tags pour chaque note
for (const note of notes) {
const todos = await getAll(`
SELECT id, text, completed, position
SELECT id, text, completed, position, parent_id, level
FROM note_todos
WHERE note_id = $1
ORDER BY position ASC, id ASC
@@ -391,10 +391,13 @@ router.delete('/:id/image', async (req, res) => {
/**
* POST /api/notes/:id/todos
* Ajouter un todo à une note
* Ajouter un todo à une note (supports subtasks via parent_id)
*/
router.post('/:id/todos',
[body('text').trim().notEmpty().withMessage('Le texte est requis')],
[
body('text').trim().notEmpty().withMessage('Le texte est requis'),
body('parent_id').optional().isInt()
],
async (req, res) => {
try {
const errors = validationResult(req);
@@ -408,17 +411,27 @@ router.post('/:id/todos',
return res.status(404).json({ error: 'Note non trouvée' });
}
const { text } = req.body;
const { text, parent_id } = req.body;
let level = 0;
logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"`);
// Si c'est un subtask, vérifier que le parent existe et calculer le niveau
if (parent_id) {
const parent = await getOne('SELECT id, level FROM note_todos WHERE id = $1 AND note_id = $2', [parent_id, req.params.id]);
if (!parent) {
return res.status(404).json({ error: 'Todo parent non trouvé' });
}
level = (parent.level || 0) + 1;
}
logger.info(`[CREATE TODO] Ajout todo à note ${req.params.id}: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`);
const result = await runQuery(`
INSERT INTO note_todos (note_id, text, position)
VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $3))
INSERT INTO note_todos (note_id, text, parent_id, level, position)
VALUES ($1, $2, $3, $4, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $5 AND COALESCE(parent_id, 0) = COALESCE($6, 0)))
RETURNING *
`, [req.params.id, text, req.params.id]);
`, [req.params.id, text, parent_id || null, level, req.params.id, parent_id || null]);
logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}`);
logger.info(`[CREATE TODO] Todo créé - ID: ${result.id}, note_id: ${req.params.id}, level: ${level}`);
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = $1', [req.params.id]);
@@ -426,7 +439,9 @@ router.post('/:id/todos',
id: result.id,
text,
completed: false,
position: 0
position: result.position || 0,
parent_id: parent_id || null,
level: level
});
} catch (error) {
logger.error('Erreur lors de l\'ajout du todo:', error);
+25 -10
View File
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
// Essayer d'abord avec le champ priority (nouvelle version)
try {
const todos = await getAll(`
SELECT id, text, completed, priority, in_progress, created_at
SELECT id, text, completed, priority, in_progress, parent_id, level, created_at
FROM global_todos
WHERE user_id = $1
ORDER BY priority DESC, created_at DESC
@@ -38,7 +38,7 @@ router.get('/', async (req, res) => {
`, [req.user.id]);
// Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend
const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false }));
const todosWithPriority = todos.map(t => ({ ...t, priority: false, in_progress: false, parent_id: null, level: 0 }));
res.json(todosWithPriority);
}
} catch (error) {
@@ -49,10 +49,13 @@ router.get('/', async (req, res) => {
/**
* POST /api/todos
* Créer un nouveau todo global
* Créer un nouveau todo global (supports subtasks via parent_id)
*/
router.post('/',
[body('text').trim().notEmpty().withMessage('Le texte est requis')],
[
body('text').trim().notEmpty().withMessage('Le texte est requis'),
body('parent_id').optional().isInt()
],
async (req, res) => {
try {
const errors = validationResult(req);
@@ -60,17 +63,27 @@ router.post('/',
return res.status(400).json({ error: 'Données invalides', details: errors.array() });
}
const { text } = req.body;
const { text, parent_id } = req.body;
let level = 0;
logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"`);
// Si c'est un subtask, vérifier que le parent existe et calculer le niveau
if (parent_id) {
const parent = await getOne('SELECT id, level FROM global_todos WHERE id = $1 AND user_id = $2', [parent_id, req.user.id]);
if (!parent) {
return res.status(404).json({ error: 'Todo parent non trouvé' });
}
level = (parent.level || 0) + 1;
}
logger.info(`[CREATE GLOBAL TODO] Début création - user_id: ${req.user.id}, text: "${text}"${parent_id ? ` (subtask of ${parent_id})` : ''}`);
const result = await runQuery(`
INSERT INTO global_todos (user_id, text)
VALUES ($1, $2)
INSERT INTO global_todos (user_id, text, parent_id, level)
VALUES ($1, $2, $3, $4)
RETURNING *
`, [req.user.id, text]);
`, [req.user.id, text, parent_id || null, level]);
logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}`);
logger.info(`[CREATE GLOBAL TODO] Todo global créé avec succès - ID: ${result.id}, user: ${req.user.username}, level: ${level}`);
res.status(201).json({
id: result.id,
@@ -78,6 +91,8 @@ router.post('/',
completed: false,
priority: false,
in_progress: false,
parent_id: parent_id || null,
level: level,
created_at: new Date().toISOString()
});
} catch (error) {
+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>
);
}
+197
View File
@@ -0,0 +1,197 @@
import { useState, useEffect, useRef } 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;
export function PomodoroTimer() {
const [mode, setMode] = useState<TimerMode>('work');
const [timeLeft, setTimeLeft] = useState(TIMER_DURATIONS.work);
const [isRunning, setIsRunning] = useState(false);
const [sessionsCompleted, setSessionsCompleted] = useState(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
// Create audio element for notification
audioRef.current = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvPaizMHGGS56+mgUBELTqXj8bllHgU2jdXtyHAmBSl+zPLaizsIE12y6OqqWBQLSKHh8rxrIQUsgs/z2ow0BxdnuertolIRC0ul4/K5Zx4FNo3V7chxJwUpf8zy24s7CBRdsujqqVgTCkig4fK8ayCFLILP8tqMNAcXZ7nq7aJSEQtLpePxuGYeBS+Mz+3McyYEKH3L8tmKOwgUXLLn6qdZFApHnuDyvGscBSyCzvLbjDMHF2e56+yiUhELSqXj8rhnHgU2jdXtyHEmBCh+y/LZijsJE1yx6OqoWRMKR57g8rxrHAUsgs7y24wzBxdnuevoIlIQC0ql4/K4Zh4FNo3V7chxJgQofszy2Yo7CBNcsefqp1oUCkee4PK8ahwELIHO8tqLNAcXZ7nr7KJSEQtKpePyuWYeBTaN1e3IcSYEKH7M8tmJOwgTXLLo6qlaFApHnuDxvGobBSuBzvPaizQHF2e56+uiURILSqTi8bllHgY2jdXtyHEmBCl+y/LZijsIE1yx5+qnWhQLR57g8bxqHAYrgs7y2oozCBdouevrolQRDEqk4/G4Zh4FNo3V7chxJgQpfsvy2Yo7CBNbsefqp1oUC0ee4PG8ahsGK4HO8tqLMwgXaLnr66JVEQxKpOPyuGUeBTaN1e3IcScEKX/L8tiKOwkSXLLn6qhbFQtHnt/xvGocByyBzvLajDMIF2e56+qiVBEMSqTj8rhlHgU2jdXuyHEnBCl/y/LYijsJElyx6OqoWxULR53f8bxrHAYsgc/y2owzCBdnuevqolQRDEqk4/K5ZR4FNo3V7shwJwQpf8vy2Is7CBJcsujqqFsVC0ed3/G8ahwGLIHO8tqMNAgWZ7nr6qJUEQxKpOPyuWUeBjaN1e7IcCcFKX/M8tiLOwgSXLHo6qhbFQtHnt/xvGodBiyBzvPajTMIF2a56+qiVBEMSqTj8rllHgU2jdXtyHAnBCl/y/LYijwIElyx6OqoWxUKR53f8bxqHQYsgc7z2owyBxdmuevsIVQRC0qk4/K5ZR4FNo3V7shwJwUpf8vy2Io7CBJcsejqqFsVC0ed3/G8ah0GLIDO89qMMggWZrnr66JUERALO6Tj8rllHgU2jdXuyHEnBCl/y/LYijsIElyx6OqoWxYKR53g8bxqHQYsgc7y2owyBxZmuevsI1QRC0qk4/G5ZR4FNo3V7shyJgUpfsvy2Ys7CBJcsejpqFsVCked4PG8ah0GLILO8tqLNAgWZrnr7KNUEQtKpOPxuWYeBTaN1e7HcScEKH7L8tmLPAgSW7Lo6alZFQpHneDxvGobBSyBzvLaizQIF2a56+yhVREMSqPi8bhlHgU2jdXux3ElBCh+zPLZjDwIEluw6OmpWBYJR5zg8bxrGwcsgs7y2oo0CBZmuevsI1QRC0qk4/K4Zh4FNo3V7sdxJwQofszx2Io8CBJbsOjqqVkVCked4PG7axsGLILO8tqKNAgWZrnr6yNVEQtKpOPxuGYeBTaN1e7HcScFKH7M8tmLOwgSXLHo6qlZFQpHneDxu2odBSyBzvLaijMIF2a56+uiVREMSqPj8bhlHgU2jdXuyHEnBCh+zPLZijsIElyx6OqpWRUKR57g8btrGwYrgs7y2oozCBdnuevoI1USCkqk4/G4Zh4FNo3V7shxJwQofczy2Yo7CBNcsefqqVgVCkee4PG8ahwGK4LO8tqLMwgXZ7nr66JUEgtKpOPxuGYeBTWN1e7IcSYFKH7M8tmKOwgTXLHo6qhZFQpHnuDxvGscBSuCzvLaizQHF2e56+uiUxILSqPj8rhmHQU1jtXuyHEmBSh+y/LZijsJE1ux6OqoWhUKR57g8bxrHAUrgs7y2owzCBdnuevrolMRC0qk4/K4ZR4FNY7V7shwJwUpfsvy2Ik7CBNcsejqqFsVC0ee3/G8ahwFLILO8tqMNAgXZ7nr66NUEgtKpOPyuGQeBjWN1e7IcCcFKX/L8tiKOwgTXLHo6qhaFQtHnt/xvGobBSyCzvLajDQIF2e56+ujVBEMSqTj8rhlHgU2jdXuyHEnBSl/y/LZijsIElyx6OqoWxUKR57g8bxqHAUsgs7y2owzCBdnuevrIlQRC0qk4/K4ZR4FNo3V7shxJgUpf8vy2Yk7CBNcsejqqFsVC0ed4PG8ahwFLILO8tqMMwgXZ7nr6yJUEQtKpOPyuGQeBTWO1e7IcCYEKX/L8tmKOwgSXLLo6qhbFQpHnuDxvGobBSyCzvLajDMIF2e56+siVBELSqTj8rhlHgU1jtXuyHEnBCl/y/LZizsIElyx6OqoWxUKR57f8bxqHAYrgs7y2owzCBdnuevoIlQRC0qk4/K4ZR4FNY7V7shxJwQpf8vy2Yo7CBJcsejpqFsVCkee3/G8ahwGK4LO8tqMMwgXZ7nr6yJUEQtKpOPyuGQeBTWO1e7IcSYEKX/L8tmKOwgSXLLo6qhbFQpHnt/xvGobBiyyzvLajDMIF2e56+siVBEMSqPi8rhlHgU1jtXuxnInBCl/y/LZijwIElyx6OmpmBUKR57g8bxrGwcrgs7y2oozCBdnuevoI1QRC0qk4/G4ZR4FNY7V7shxJgUpf8vy2Yo7CBJcsejqqFsVCked3/G8ahwGK4LO8tqMMwgXZ7nr6iNUEQtKpOPyuGQeBTWO1e7IcSYEKX/L8tmKOwgSXLHo6qlZFQpHnuDxvGobBSyBzvLaizMIF2a56+sjVRELSqPj8bhlHgU1jtXuyHAnBSh+y/HYijsJElux6OqpWhYKRp3f8bxqGwYrgs7y2oozCBdnuevrolQRC0qk4/K4ZR4FNY7V7sdxJwUpfsvy2Yo8CBJbsejqqVkVCkee3/G8ahsGK4LO8tqKNAgXZ7nr6qJUEQtKpOPxuGYeBTaN1e7IcSYEKH7M8tmLOwgSW7Do6qlZFQpHnt/xu2obBiuBzvPaizMIF2e56+qiVBILSqTj8bhlHgU2jdXuyHEmBSh+y/LZizsIElyx6OmpWhUKR57f8btqHAYsgc7z2oo0BxdnuevqolQSC0qk4/K4ZR4FNY7V7shxJgUofsvy2Yo7CBNbsejpqVoUC0ed3/G7ahwGLIHO89qKMwgXZ7nr66JTEQtKpOPyuGQeBjSN1e/IcCYEKH/L8tmKOwgTW7Ho6ahbFApHnuDxu2obBSyBzvPaizMIF2e56+qiUxELSqTj8rhlHgU1jtXuyHEnBCh+y/LZijwIE1ux6OmoWxQKR57g8btrGwYrgs7y2oszCBdnuevqolQRCkqk4/K4ZR4FNY7V7shxJgQofszy2Yo7CBNbsejpqFsUCked4PG7axwGLIHO89qKMwgXZ7nr6qJUEQpKpePyuGYeBTaN1e3IcSYEKH7M8dmLOwgSW7Ho6ahbFApHnuDxu2scBiuBzvPaizMIF2e56+qjVRELSqTj8bhlHgU1jtXuyHEnBCh+zPHYijwIElyx6OmpWRUKR57g8btrGwYrgs7y2oo0CBZnuevrolURDEqj4/K4ZR4FNY7V7shxJgUofsvy2Ys7CBJcsejpqVkVCked4PG8ahsGK4LO8tqKNAgWZ7nr66JVEQtKo+PyuWQeBTWO1e7IcSYFJ37M8tmLOwgSXLDo6ahZFQpHneDxvGocBSuCzvLajDQIF2a56+ujVRELSqPj8rllHgU2jdXuyHEnBCh+zPLYijsIE1uw6OmoWhYKR53f8bxqHAYrgs7y2ow0Bhdn');
}, []);
useEffect(() => {
if (isRunning && timeLeft > 0) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
handleTimerComplete();
return 0;
}
return prev - 1;
});
}, 1000);
} else if (intervalRef.current) {
clearInterval(intervalRef.current);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, timeLeft]);
const handleTimerComplete = () => {
setIsRunning(false);
// Play notification sound
if (audioRef.current) {
audioRef.current.play().catch(e => console.error('Audio play failed:', e));
}
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 ?');
}
};
const handleStart = () => {
setIsRunning(true);
};
const handlePause = () => {
setIsRunning(false);
};
const handleReset = () => {
setIsRunning(false);
setTimeLeft(TIMER_DURATIONS[mode]);
};
const handleModeChange = (newMode: TimerMode) => {
setMode(newMode);
setTimeLeft(TIMER_DURATIONS[newMode]);
setIsRunning(false);
};
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>
);
}
+145
View File
@@ -0,0 +1,145 @@
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
size="lg"
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-lg hover:shadow-xl transition-all z-50"
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>
);
}
+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>
);
}
+87
View File
@@ -0,0 +1,87 @@
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) => {
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) {
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' },
};
+329 -24
View File
@@ -15,7 +15,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
} from "lucide-react";
import AuthService from "@/services/AuthService";
import AdminService from "@/services/AdminService";
@@ -32,6 +32,18 @@ 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 { 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 { KeyboardShortcutsHelp } from "@/components/KeyboardShortcutsHelp";
import { useKeyboardShortcuts, type KeyboardShortcut } from "@/hooks/useKeyboardShortcuts";
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
@@ -111,6 +123,15 @@ 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[]>([]);
@@ -121,6 +142,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);
@@ -145,11 +167,14 @@ 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);
// Pagination states
const [notesPage, setNotesPage] = useState(0);
@@ -408,12 +433,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");
@@ -803,12 +848,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);
@@ -820,7 +924,7 @@ const Index = () => {
// Reset page when changing filters or search
useEffect(() => {
setNotesPage(0);
}, [showArchived, searchQuery]);
}, [showArchived, searchQuery, searchFilters]);
useEffect(() => {
setTodosActivePage(0);
@@ -835,6 +939,72 @@ 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: '?',
shift: true,
description: 'Aide raccourcis',
handler: () => setShowKeyboardHelp(true)
},
{
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);
@@ -923,6 +1093,8 @@ const Index = () => {
Nouvelle Note
</Button>
<ModeToggle />
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -1090,6 +1262,9 @@ const Index = () => {
)}
</CardContent>
</Card>
{/* Statistics Dashboard */}
<StatsDashboard notes={notes} todos={todos} />
</div>
{/* Middle Column: Notes or Open Note */}
@@ -1116,20 +1291,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
@@ -1267,6 +1473,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 */
@@ -1275,6 +1516,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"
@@ -1425,6 +1679,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 && (
<>
@@ -1624,8 +1906,13 @@ const Index = () => {
)}
</div>
{/* Right Column: Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Right Column: Pomodoro Timer, Todos & RSS */}
<div className="space-y-6">
{/* Pomodoro Timer */}
<PomodoroTimer />
{/* Todos & RSS Boxes side by side */}
<div className="grid grid-cols-2 gap-6">
{/* Todos Box */}
<Card className="shadow-lg">
<CardHeader className="pb-3">
@@ -1855,6 +2142,7 @@ const Index = () => {
</Card>
</div>
</div>
</div>
</div>
@@ -2270,6 +2558,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">
@@ -2846,6 +3141,16 @@ const Index = () => {
</div>
</div>
)}
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
open={showKeyboardHelp}
onOpenChange={setShowKeyboardHelp}
shortcuts={keyboardShortcuts}
/>
{/* Quick Capture Widget */}
<QuickCaptureWidget onNoteCaptured={loadNotes} />
</div>
);
};
+12 -1
View File
@@ -17,7 +17,8 @@ import {
Paperclip,
Tag as TagIcon,
X,
MoreVertical
MoreVertical,
FileDown
} from "lucide-react";
import {
DropdownMenu,
@@ -28,6 +29,7 @@ import {
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();
@@ -199,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;
+195 -33
View File
@@ -5,17 +5,33 @@ 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 { TemplateSelector } from "@/components/TemplateSelector";
import type { NoteTemplate } from "@/utils/noteTemplates";
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 [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const navigate = useNavigate();
const loadNotes = async () => {
@@ -31,13 +47,71 @@ export default function NotesPage() {
loadNotes();
}, []);
// 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;
@@ -48,10 +122,24 @@ export default function NotesPage() {
const totalPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
const paginatedNotes = filteredNotes.slice(page * NOTES_PER_PAGE, (page + 1) * NOTES_PER_PAGE);
const handleCreateNote = async () => {
const handleCreateNote = () => {
setTemplateSelectorOpen(true);
};
const handleCreateNoteFromTemplate = async (template: NoteTemplate) => {
try {
const newNote = await NotesService.createNote("Nouvelle note", "");
navigate(`/mobile/notes/${newNote.id}`);
const title = template.name === "Note vide" ? "Nouvelle note" : template.name;
const newNote = await NotesService.createNote(title, template.content);
if (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);
}
}
showSuccess("Note créée depuis le template");
navigate(`/mobile/notes/${newNote.id}`);
}
} catch (error) {
showError("Erreur lors de la création de la note");
}
@@ -89,6 +177,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,9 +212,32 @@ 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 */}
{/* Notes View - List or Kanban */}
{viewMode === 'list' ? (
<div className="p-4 space-y-3">
{paginatedNotes.length > 0 ? (
paginatedNotes.map(note => (
@@ -209,35 +330,76 @@ export default function NotesPage() {
)}
</div>
)}
</div>
{/* 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>
{/* 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>
</div>
)}
</div>
) : (
/* Kanban View */
<div className="p-2 overflow-x-auto">
<KanbanBoard
notes={filteredNotes}
onNoteClick={(note) => navigate(`/mobile/notes/${note.id}`)}
onCreateNote={(columnId) => {
setTemplateSelectorOpen(true);
}}
onMoveNote={async (noteId, newStatus) => {
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();
showSuccess(`Note déplacée vers ${newStatus}`);
}
}}
/>
</div>
)}
{/* FAB */}
<MobileFAB icon={Plus} onClick={handleCreateNote} label="Créer une note" />
{/* Template Selector */}
<TemplateSelector
open={templateSelectorOpen}
onOpenChange={setTemplateSelectorOpen}
onSelectTemplate={handleCreateNoteFromTemplate}
/>
</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>
+10 -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 {
@@ -221,12 +224,17 @@ class NotesService {
}
// Todo management methods
async addTodo(noteId: number, text: string): Promise<Todo | null> {
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({ text })
body: JSON.stringify(payload)
});
if (!response.ok) {
+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
};
}
+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);
}