mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
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:
27 files changed
+3409
-97
No files matched your search
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 += `\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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user