mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-23 07:08:17 +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
|
// Charger les todos, images, fichiers et tags pour chaque note
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const todos = await getAll(`
|
const todos = await getAll(`
|
||||||
SELECT id, text, completed, position
|
SELECT id, text, completed, position, parent_id, level
|
||||||
FROM note_todos
|
FROM note_todos
|
||||||
WHERE note_id = $1
|
WHERE note_id = $1
|
||||||
ORDER BY position ASC, id ASC
|
ORDER BY position ASC, id ASC
|
||||||
@@ -391,10 +391,13 @@ router.delete('/:id/image', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/notes/:id/todos
|
* POST /api/notes/:id/todos
|
||||||
* Ajouter un todo à une note
|
* Ajouter un todo à une note (supports subtasks via parent_id)
|
||||||
*/
|
*/
|
||||||
router.post('/:id/todos',
|
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -408,17 +411,27 @@ router.post('/:id/todos',
|
|||||||
return res.status(404).json({ error: 'Note non trouvée' });
|
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(`
|
const result = await runQuery(`
|
||||||
INSERT INTO note_todos (note_id, text, position)
|
INSERT INTO note_todos (note_id, text, parent_id, level, position)
|
||||||
VALUES ($1, $2, (SELECT COALESCE(MAX(position), 0) + 1 FROM note_todos WHERE note_id = $3))
|
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 *
|
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]);
|
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,
|
id: result.id,
|
||||||
text,
|
text,
|
||||||
completed: false,
|
completed: false,
|
||||||
position: 0
|
position: result.position || 0,
|
||||||
|
parent_id: parent_id || null,
|
||||||
|
level: level
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Erreur lors de l\'ajout du todo:', 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)
|
// Essayer d'abord avec le champ priority (nouvelle version)
|
||||||
try {
|
try {
|
||||||
const todos = await getAll(`
|
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
|
FROM global_todos
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY priority DESC, created_at DESC
|
ORDER BY priority DESC, created_at DESC
|
||||||
@@ -38,7 +38,7 @@ router.get('/', async (req, res) => {
|
|||||||
`, [req.user.id]);
|
`, [req.user.id]);
|
||||||
|
|
||||||
// Ajouter priority=false et in_progress=false par défaut pour compatibilité frontend
|
// 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);
|
res.json(todosWithPriority);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,10 +49,13 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/todos
|
* POST /api/todos
|
||||||
* Créer un nouveau todo global
|
* Créer un nouveau todo global (supports subtasks via parent_id)
|
||||||
*/
|
*/
|
||||||
router.post('/',
|
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) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -60,17 +63,27 @@ router.post('/',
|
|||||||
return res.status(400).json({ error: 'Données invalides', details: errors.array() });
|
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(`
|
const result = await runQuery(`
|
||||||
INSERT INTO global_todos (user_id, text)
|
INSERT INTO global_todos (user_id, text, parent_id, level)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *
|
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({
|
res.status(201).json({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
@@ -78,6 +91,8 @@ router.post('/',
|
|||||||
completed: false,
|
completed: false,
|
||||||
priority: false,
|
priority: false,
|
||||||
in_progress: false,
|
in_progress: false,
|
||||||
|
parent_id: parent_id || null,
|
||||||
|
level: level,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+1
-1
@@ -75,7 +75,7 @@ const App = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider defaultTheme="light" storageKey="notes-theme">
|
<ThemeProvider defaultTheme="system" storageKey="notes-theme">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<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 { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
import {
|
import {
|
||||||
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
|
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";
|
} from "lucide-react";
|
||||||
import AuthService from "@/services/AuthService";
|
import AuthService from "@/services/AuthService";
|
||||||
import AdminService from "@/services/AdminService";
|
import AdminService from "@/services/AdminService";
|
||||||
@@ -32,6 +32,18 @@ import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
|||||||
import { InputModal } from "@/components/modals/InputModal";
|
import { InputModal } from "@/components/modals/InputModal";
|
||||||
import { AddUserModal } from "@/components/modals/AddUserModal";
|
import { AddUserModal } from "@/components/modals/AddUserModal";
|
||||||
import { compressImage, formatFileSize } from "@/utils/imageCompression";
|
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 =====
|
// ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS =====
|
||||||
|
|
||||||
@@ -111,6 +123,15 @@ const Index = () => {
|
|||||||
const [user, setUser] = useState<UserType | null>(null);
|
const [user, setUser] = useState<UserType | null>(null);
|
||||||
const [showAdmin, setShowAdmin] = useState(false);
|
const [showAdmin, setShowAdmin] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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 [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [openNote, setOpenNote] = useState<Note | null>(null);
|
const [openNote, setOpenNote] = useState<Note | null>(null);
|
||||||
const [users, setUsers] = useState<UserType[]>([]);
|
const [users, setUsers] = useState<UserType[]>([]);
|
||||||
@@ -121,6 +142,7 @@ const Index = () => {
|
|||||||
const [settings, setSettings] = useState<AppSettings>({});
|
const [settings, setSettings] = useState<AppSettings>({});
|
||||||
const [adminTab, setAdminTab] = useState("users");
|
const [adminTab, setAdminTab] = useState("users");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
||||||
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
@@ -145,11 +167,14 @@ const Index = () => {
|
|||||||
const [addUserModal, setAddUserModal] = useState(false);
|
const [addUserModal, setAddUserModal] = useState(false);
|
||||||
const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false });
|
const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false });
|
||||||
const [changePasswordModal, setChangePasswordModal] = 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 [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
||||||
const [addTagModal, setAddTagModal] = useState(false);
|
const [addTagModal, setAddTagModal] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
const [addEventModal, setAddEventModal] = useState(false);
|
const [addEventModal, setAddEventModal] = useState(false);
|
||||||
const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false });
|
const [editEventModal, setEditEventModal] = useState<{ open: boolean, event?: CalendarEvent }>({ open: false });
|
||||||
|
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
|
||||||
|
const [showStatsDashboard, setShowStatsDashboard] = useState(false);
|
||||||
|
|
||||||
// Pagination states
|
// Pagination states
|
||||||
const [notesPage, setNotesPage] = useState(0);
|
const [notesPage, setNotesPage] = useState(0);
|
||||||
@@ -408,12 +433,32 @@ const Index = () => {
|
|||||||
navigate("/login");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateNote = async () => {
|
const handleCreateNote = () => {
|
||||||
|
setTemplateSelectorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateNoteFromTemplate = async (template: NoteTemplate) => {
|
||||||
try {
|
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) {
|
if (newNote) {
|
||||||
setNotes([newNote, ...notes]);
|
// Add todos if template has any
|
||||||
setOpenNote(newNote);
|
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) {
|
} catch (error) {
|
||||||
showError("Erreur lors de la création de la note");
|
showError("Erreur lors de la création de la note");
|
||||||
@@ -803,12 +848,71 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredNotes = notes.filter(note =>
|
// Extract unique tags from all notes
|
||||||
(showArchived ? note.archived : !note.archived) &&
|
const availableTags: TagOption[] = Array.from(
|
||||||
(searchQuery === "" ||
|
new Map(
|
||||||
(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
notes.flatMap(note =>
|
||||||
(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase())))
|
(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
|
// Pagination for notes
|
||||||
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
|
const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
|
||||||
@@ -820,7 +924,7 @@ const Index = () => {
|
|||||||
// Reset page when changing filters or search
|
// Reset page when changing filters or search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNotesPage(0);
|
setNotesPage(0);
|
||||||
}, [showArchived, searchQuery]);
|
}, [showArchived, searchQuery, searchFilters]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTodosActivePage(0);
|
setTodosActivePage(0);
|
||||||
@@ -835,6 +939,72 @@ const Index = () => {
|
|||||||
setCalendarPage(0);
|
setCalendarPage(0);
|
||||||
}, [calendarEvents.length]);
|
}, [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
|
// Pagination for todos
|
||||||
const activeTodos = todos.filter(t => !t.completed);
|
const activeTodos = todos.filter(t => !t.completed);
|
||||||
const completedTodos = todos.filter(t => t.completed);
|
const completedTodos = todos.filter(t => t.completed);
|
||||||
@@ -923,6 +1093,8 @@ const Index = () => {
|
|||||||
Nouvelle Note
|
Nouvelle Note
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<ModeToggle />
|
||||||
|
|
||||||
{user && (
|
{user && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -1090,6 +1262,9 @@ const Index = () => {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics Dashboard */}
|
||||||
|
<StatsDashboard notes={notes} todos={todos} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle Column: Notes or Open Note */}
|
{/* Middle Column: Notes or Open Note */}
|
||||||
@@ -1116,20 +1291,51 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative w-96">
|
<div className="flex items-center gap-2">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
<div className="relative w-96">
|
||||||
<Input
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
type="search"
|
<Input
|
||||||
placeholder="Rechercher dans les notes..."
|
type="search"
|
||||||
className="pl-10 h-12 text-base"
|
placeholder="Rechercher dans les notes..."
|
||||||
value={searchQuery}
|
className="pl-10 h-12 text-base"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Grid - 1 column for better visibility */}
|
{/* Notes View - List or Kanban */}
|
||||||
<div className="grid grid-cols-1 gap-4">
|
{notesViewMode === 'list' ? (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{filteredNotes.length > 0 ? (
|
{filteredNotes.length > 0 ? (
|
||||||
paginatedNotes.map(note => (
|
paginatedNotes.map(note => (
|
||||||
<Card
|
<Card
|
||||||
@@ -1267,6 +1473,41 @@ const Index = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 */
|
/* Open Note Editor */
|
||||||
@@ -1275,6 +1516,19 @@ const Index = () => {
|
|||||||
<div></div>
|
<div></div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -1425,6 +1679,34 @@ const Index = () => {
|
|||||||
</div>
|
</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 */}
|
{/* Todos */}
|
||||||
{openNote.todos && openNote.todos.length > 0 && (
|
{openNote.todos && openNote.todos.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -1624,8 +1906,13 @@ const Index = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Todos & RSS Boxes side by side */}
|
{/* Right Column: Pomodoro Timer, Todos & RSS */}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
|
{/* Pomodoro Timer */}
|
||||||
|
<PomodoroTimer />
|
||||||
|
|
||||||
|
{/* Todos & RSS Boxes side by side */}
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
{/* Todos Box */}
|
{/* Todos Box */}
|
||||||
<Card className="shadow-lg">
|
<Card className="shadow-lg">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -1855,6 +2142,7 @@ const Index = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2270,6 +2558,13 @@ const Index = () => {
|
|||||||
confirmText="Ajouter"
|
confirmText="Ajouter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Template Selector */}
|
||||||
|
<TemplateSelector
|
||||||
|
open={templateSelectorOpen}
|
||||||
|
onOpenChange={setTemplateSelectorOpen}
|
||||||
|
onSelectTemplate={handleCreateNoteFromTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Add Event Modal */}
|
{/* Add Event Modal */}
|
||||||
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
|
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
@@ -2846,6 +3141,16 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Keyboard Shortcuts Help Modal */}
|
||||||
|
<KeyboardShortcutsHelp
|
||||||
|
open={showKeyboardHelp}
|
||||||
|
onOpenChange={setShowKeyboardHelp}
|
||||||
|
shortcuts={keyboardShortcuts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Quick Capture Widget */}
|
||||||
|
<QuickCaptureWidget onNoteCaptured={loadNotes} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Tag as TagIcon,
|
Tag as TagIcon,
|
||||||
X,
|
X,
|
||||||
MoreVertical
|
MoreVertical,
|
||||||
|
FileDown
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -28,6 +29,7 @@ import {
|
|||||||
import NotesService, { Note } from "@/services/NotesService";
|
import NotesService, { Note } from "@/services/NotesService";
|
||||||
import { showError, showSuccess } from "@/utils/toast";
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
import { compressImage, formatFileSize } from "@/utils/imageCompression";
|
import { compressImage, formatFileSize } from "@/utils/imageCompression";
|
||||||
|
import { downloadNoteAsMarkdown } from "@/utils/markdownExport";
|
||||||
|
|
||||||
export default function NoteDetailPage() {
|
export default function NoteDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -199,6 +201,15 @@ export default function NoteDetailPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<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 () => {
|
<DropdownMenuItem onClick={async () => {
|
||||||
if (note?.id) {
|
if (note?.id) {
|
||||||
const newArchived = !note.archived;
|
const newArchived = !note.archived;
|
||||||
|
|||||||
+195
-33
@@ -5,17 +5,33 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
||||||
import { MobileCard } from "@/components/mobile/MobileCard";
|
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 NotesService, { Note } from "@/services/NotesService";
|
||||||
import { showError, showSuccess } from "@/utils/toast";
|
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;
|
const NOTES_PER_PAGE = 10;
|
||||||
|
|
||||||
export default function NotesPage() {
|
export default function NotesPage() {
|
||||||
const [notes, setNotes] = useState<Note[]>([]);
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
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 [showArchived, setShowArchived] = useState(false);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
@@ -31,13 +47,71 @@ export default function NotesPage() {
|
|||||||
loadNotes();
|
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
|
const filteredNotes = notes
|
||||||
.filter(note => {
|
.filter(note => {
|
||||||
const matchArchived = showArchived ? note.archived : !note.archived;
|
// Archived filter
|
||||||
const matchSearch = searchQuery === "" ||
|
if (showArchived ? !note.archived : note.archived) return false;
|
||||||
note.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
note.content?.toLowerCase().includes(searchQuery.toLowerCase());
|
// Text search
|
||||||
return matchArchived && matchSearch;
|
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) => {
|
.sort((a, b) => {
|
||||||
if (a.priority && !b.priority) return -1;
|
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 totalPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE);
|
||||||
const paginatedNotes = filteredNotes.slice(page * NOTES_PER_PAGE, (page + 1) * 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 {
|
try {
|
||||||
const newNote = await NotesService.createNote("Nouvelle note", "");
|
const title = template.name === "Note vide" ? "Nouvelle note" : template.name;
|
||||||
navigate(`/mobile/notes/${newNote.id}`);
|
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) {
|
} catch (error) {
|
||||||
showError("Erreur lors de la création de la note");
|
showError("Erreur lors de la création de la note");
|
||||||
}
|
}
|
||||||
@@ -89,6 +177,16 @@ export default function NotesPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Filters */}
|
||||||
|
<AdvancedSearch
|
||||||
|
filters={searchFilters}
|
||||||
|
onFiltersChange={(newFilters) => {
|
||||||
|
setSearchFilters(newFilters);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
availableTags={availableTags}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -114,9 +212,32 @@ export default function NotesPage() {
|
|||||||
Archivées ({notes.filter(n => n.archived).length})
|
Archivées ({notes.filter(n => n.archived).length})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Notes List */}
|
{/* Notes View - List or Kanban */}
|
||||||
|
{viewMode === 'list' ? (
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
{paginatedNotes.length > 0 ? (
|
{paginatedNotes.length > 0 ? (
|
||||||
paginatedNotes.map(note => (
|
paginatedNotes.map(note => (
|
||||||
@@ -209,35 +330,76 @@ export default function NotesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-4 px-4 pb-4">
|
<div className="flex items-center justify-center gap-4 px-4 pb-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
disabled={page === 0}
|
disabled={page === 0}
|
||||||
>
|
>
|
||||||
‹ Précédent
|
‹ Précédent
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{page + 1} / {totalPages}
|
{page + 1} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={page === totalPages - 1}
|
disabled={page === totalPages - 1}
|
||||||
>
|
>
|
||||||
Suivant ›
|
Suivant ›
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* FAB */}
|
{/* FAB */}
|
||||||
<MobileFAB icon={Plus} onClick={handleCreateNote} label="Créer une note" />
|
<MobileFAB icon={Plus} onClick={handleCreateNote} label="Créer une note" />
|
||||||
|
|
||||||
|
{/* Template Selector */}
|
||||||
|
<TemplateSelector
|
||||||
|
open={templateSelectorOpen}
|
||||||
|
onOpenChange={setTemplateSelectorOpen}
|
||||||
|
onSelectTemplate={handleCreateNoteFromTemplate}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -53,10 +54,6 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
setTheme(theme === "dark" ? "light" : "dark");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-6 pb-24">
|
<div className="p-4 space-y-6 pb-24">
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
@@ -68,16 +65,29 @@ export default function SettingsPage() {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center justify-between">
|
<RadioGroup value={theme} onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}>
|
||||||
<Label htmlFor="dark-mode" className="text-base">
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
Mode sombre
|
<RadioGroupItem value="light" id="light" />
|
||||||
</Label>
|
<Label htmlFor="light" className="text-base font-normal cursor-pointer flex items-center gap-2">
|
||||||
<Switch
|
<Sun className="h-4 w-4" />
|
||||||
id="dark-mode"
|
Clair
|
||||||
checked={theme === "dark"}
|
</Label>
|
||||||
onCheckedChange={toggleTheme}
|
</div>
|
||||||
/>
|
<div className="flex items-center space-x-2 mb-3">
|
||||||
</div>
|
<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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ interface Todo {
|
|||||||
text: string;
|
text: string;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
position?: number;
|
position?: number;
|
||||||
|
due_date?: string;
|
||||||
|
parent_id?: number;
|
||||||
|
level?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Image {
|
interface Image {
|
||||||
@@ -221,12 +224,17 @@ class NotesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Todo management methods
|
// Todo management methods
|
||||||
async addTodo(noteId: number, text: string): Promise<Todo | null> {
|
async addTodo(noteId: number, text: string, dueDate?: string): Promise<Todo | null> {
|
||||||
try {
|
try {
|
||||||
|
const payload: any = { text };
|
||||||
|
if (dueDate) {
|
||||||
|
payload.due_date = dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/notes/${noteId}/todos`, {
|
const response = await fetch(`/api/notes/${noteId}/todos`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: AuthService.getHeaders(),
|
headers: AuthService.getHeaders(),
|
||||||
body: JSON.stringify({ text })
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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 {
|
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;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating todo:', 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