From 21d3f7057fed7c5341a19e2c22a5fa5290c98cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:31:19 +0000 Subject: [PATCH 01/15] feat: add dark/light/system theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented complete theme switching with three modes: - Light theme - Dark theme - System (auto-detect from OS) Changes: - Created ModeToggle component with dropdown menu (src/components/mode-toggle.tsx) - Added theme toggle to desktop header (Index.tsx) - Enhanced mobile settings with radio group for theme selection (SettingsPage.tsx) - Changed default theme from "light" to "system" for better UX (App.tsx) Features: ✅ Seamless theme switching without page reload ✅ Persists user preference in localStorage ✅ System theme auto-detects OS preference ✅ Smooth transitions between themes ✅ Works on both mobile and desktop Desktop: Theme toggle button in top navigation Mobile: Theme settings in Settings page with Light/Dark/System options --- src/App.tsx | 2 +- src/components/mode-toggle.tsx | 36 +++++++++++++++++++++++++++++ src/pages/Index.tsx | 3 +++ src/pages/mobile/SettingsPage.tsx | 38 +++++++++++++++++++------------ 4 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 src/components/mode-toggle.tsx diff --git a/src/App.tsx b/src/App.tsx index 42ed1da..06ed6d3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,7 @@ const App = () => { return ( - + diff --git a/src/components/mode-toggle.tsx b/src/components/mode-toggle.tsx new file mode 100644 index 0000000..f0a5d04 --- /dev/null +++ b/src/components/mode-toggle.tsx @@ -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 ( + + + + + + setTheme("light")}> + Clair + + setTheme("dark")}> + Sombre + + setTheme("system")}> + Système + + + + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index b553b3b..14a4138 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -32,6 +32,7 @@ import { ConfirmModal } from "@/components/modals/ConfirmModal"; import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; +import { ModeToggle } from "@/components/mode-toggle"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -923,6 +924,8 @@ const Index = () => { Nouvelle Note + + {user && ( diff --git a/src/pages/mobile/SettingsPage.tsx b/src/pages/mobile/SettingsPage.tsx index 8f528ad..be2bcb7 100644 --- a/src/pages/mobile/SettingsPage.tsx +++ b/src/pages/mobile/SettingsPage.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { User, LogOut, @@ -53,10 +54,6 @@ export default function SettingsPage() { } }; - const toggleTheme = () => { - setTheme(theme === "dark" ? "light" : "dark"); - }; - return (
{/* Appearance */} @@ -68,16 +65,29 @@ export default function SettingsPage() { -
- - -
+ setTheme(value as "light" | "dark" | "system")}> +
+ + +
+
+ + +
+
+ + +
+
From 94530246af925461361381341a9fd3eb2180c507 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:34:54 +0000 Subject: [PATCH 02/15] feat: add markdown export functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive markdown export for notes and todos with automatic HTML to Markdown conversion. New Features: - Export single note to Markdown file - Export all notes to combined Markdown - Export todos list to Markdown - Smart HTML to Markdown conversion - Preserves formatting, links, lists, and code blocks Files Created: - src/utils/markdownExport.ts - Complete export utilities Desktop (Index.tsx): - Added FileDown icon to imports - New export button in note editor toolbar - Downloads note as .md file with one click Mobile (NoteDetailPage.tsx): - Added "Exporter en Markdown" to dropdown menu - Same functionality as desktop version Markdown Export Features: ✅ Converts HTML formatting to Markdown syntax ✅ Includes note metadata (creation date, tags, priority) ✅ Exports todos as checkboxes [ ] or [x] ✅ Includes image references ✅ Automatic filename generation ✅ One-click download Export includes: - Note title as H1 - Creation date and metadata - Tags with # prefix - Priority indicator ⭐ - Full content with formatting preserved - Todo items with completion status - Image links Perfect for: - Backing up notes - Sharing notes externally - Version control (git) - Using notes in other markdown editors --- src/pages/Index.tsx | 16 +- src/pages/mobile/NoteDetailPage.tsx | 13 +- src/utils/markdownExport.ts | 241 ++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 src/utils/markdownExport.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 14a4138..325f07f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -33,6 +33,7 @@ import { InputModal } from "@/components/modals/InputModal"; import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; import { ModeToggle } from "@/components/mode-toggle"; +import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1278,6 +1279,19 @@ const Index = () => {
+ + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 325f07f..8103690 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -34,6 +34,8 @@ import { AddUserModal } from "@/components/modals/AddUserModal"; import { compressImage, formatFileSize } from "@/utils/imageCompression"; import { ModeToggle } from "@/components/mode-toggle"; import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMarkdown } from "@/utils/markdownExport"; +import { TemplateSelector } from "@/components/TemplateSelector"; +import type { NoteTemplate } from "@/utils/noteTemplates"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -147,6 +149,7 @@ const Index = () => { const [addUserModal, setAddUserModal] = useState(false); const [deleteUserModal, setDeleteUserModal] = useState<{ open: boolean, userId?: number }>({ open: false }); const [changePasswordModal, setChangePasswordModal] = useState<{ open: boolean, userId?: number }>({ open: false }); + const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); const [addNoteTodoModal, setAddNoteTodoModal] = useState(false); const [addTagModal, setAddTagModal] = useState(false); const [selectedImage, setSelectedImage] = useState(null); @@ -410,12 +413,32 @@ const Index = () => { navigate("/login"); }; - const handleCreateNote = async () => { + const handleCreateNote = () => { + setTemplateSelectorOpen(true); + }; + + const handleCreateNoteFromTemplate = async (template: NoteTemplate) => { try { - const newNote = await NotesService.createNote("Nouvelle note"); + const title = template.name === "Note vide" ? "Nouvelle note" : template.name; + const newNote = await NotesService.createNote(title, template.content); if (newNote) { - setNotes([newNote, ...notes]); - setOpenNote(newNote); + // Add todos if template has any + if (template.todos && template.todos.length > 0) { + for (const todo of template.todos) { + await NotesService.addTodo(newNote.id!, todo.text); + } + // Reload note to get the todos + const notes = await NotesService.getNotes(); + const updatedNote = notes.find(n => n.id === newNote.id); + if (updatedNote) { + setNotes([updatedNote, ...notes.filter(n => n.id !== newNote.id)]); + setOpenNote(updatedNote); + } + } else { + setNotes([newNote, ...notes]); + setOpenNote(newNote); + } + showSuccess("Note créée depuis le template"); } } catch (error) { showError("Erreur lors de la création de la note"); @@ -2287,6 +2310,13 @@ const Index = () => { confirmText="Ajouter" /> + {/* Template Selector */} + + {/* Add Event Modal */} diff --git a/src/pages/mobile/NotesPage.tsx b/src/pages/mobile/NotesPage.tsx index d42f085..be1a682 100644 --- a/src/pages/mobile/NotesPage.tsx +++ b/src/pages/mobile/NotesPage.tsx @@ -8,6 +8,8 @@ import { MobileCard } from "@/components/mobile/MobileCard"; import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText } from "lucide-react"; import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; +import { TemplateSelector } from "@/components/TemplateSelector"; +import type { NoteTemplate } from "@/utils/noteTemplates"; const NOTES_PER_PAGE = 10; @@ -16,6 +18,7 @@ export default function NotesPage() { const [searchQuery, setSearchQuery] = useState(""); const [showArchived, setShowArchived] = useState(false); const [page, setPage] = useState(0); + const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); const navigate = useNavigate(); const loadNotes = async () => { @@ -48,10 +51,24 @@ export default function NotesPage() { const totalPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); const paginatedNotes = filteredNotes.slice(page * NOTES_PER_PAGE, (page + 1) * NOTES_PER_PAGE); - const handleCreateNote = async () => { + const handleCreateNote = () => { + setTemplateSelectorOpen(true); + }; + + const handleCreateNoteFromTemplate = async (template: NoteTemplate) => { try { - const newNote = await NotesService.createNote("Nouvelle note", ""); - navigate(`/mobile/notes/${newNote.id}`); + const title = template.name === "Note vide" ? "Nouvelle note" : template.name; + const newNote = await NotesService.createNote(title, template.content); + if (newNote) { + // Add todos if template has any + if (template.todos && template.todos.length > 0) { + for (const todo of template.todos) { + await NotesService.addTodo(newNote.id!, todo.text); + } + } + showSuccess("Note créée depuis le template"); + navigate(`/mobile/notes/${newNote.id}`); + } } catch (error) { showError("Erreur lors de la création de la note"); } @@ -238,6 +255,13 @@ export default function NotesPage() { {/* FAB */} + + {/* Template Selector */} +
); } diff --git a/src/utils/noteTemplates.ts b/src/utils/noteTemplates.ts new file mode 100644 index 0000000..c31ea22 --- /dev/null +++ b/src/utils/noteTemplates.ts @@ -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: `

Réunion - [Titre]

+

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Participants:

+

Objectif:

+ +

Points discutés

+

1.

+

2.

+

3.

+ +

Décisions

+

•

+ +

Prochaines étapes

+

→

`, + 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: `

Journal - ${new Date().toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}

+ +

🌅 Ce matin je me sens...

+

+ +

🎯 Mes priorités aujourd'hui

+

1.

+

2.

+

3.

+ +

💡 Idées et réflexions

+

+ +

🙏 Gratitude

+

Aujourd'hui je suis reconnaissant(e) pour:

+

•

+ +

🌙 Bilan de la journée

+

`, + todos: [] + }, + { + id: 'project-planning', + name: 'Planification de projet', + description: 'Organiser un nouveau projet', + icon: '🎯', + category: 'Travail', + content: `

Projet - [Nom du projet]

+ +

📋 Vue d'ensemble

+

Objectif:

+

Deadline:

+

Responsable:

+ +

🎯 Objectifs

+

1.

+

2.

+

3.

+ +

📊 Phases du projet

+

Phase 1: Préparation

+

•

+ +

Phase 2: Développement

+

•

+ +

Phase 3: Finalisation

+

•

+ +

👥 Équipe

+

•

+ +

⚠️ Risques identifiés

+

•

+ +

📌 Ressources nécessaires

+

•

`, + 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: `

💭 Brainstorming - [Sujet]

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Question centrale:

+ +

💡 Idées

+

1.

+

2.

+

3.

+

4.

+

5.

+ +

⭐ Idées à creuser

+

•

+ +

🚀 Actions possibles

+

→

+ +

🔗 Connexions et insights

+

`, + todos: [] + }, + { + id: 'recipe', + name: 'Recette de cuisine', + description: 'Noter une recette', + icon: '👨‍🍳', + category: 'Personnel', + content: `

🍳 Recette - [Nom du plat]

+ +

⏱️ Temps de préparation:

+

👥 Portions:

+

😋 Difficulté: Facile / Moyen / Difficile

+ +

📝 Ingrédients

+

•

+ +

👨‍🍳 Préparation

+

1.

+

2.

+

3.

+ +

💡 Astuces

+

•

+ +

📌 Notes

+

`, + todos: [] + }, + { + id: 'weekly-review', + name: 'Revue hebdomadaire', + description: 'Bilan et planification de la semaine', + icon: '📊', + category: 'Productivité', + content: `

📊 Revue de la semaine

+

Semaine du: ${new Date().toLocaleDateString('fr-FR')}

+ +

✅ Accomplissements

+

Cette semaine j'ai réussi à:

+

1.

+

2.

+

3.

+ +

📈 Progrès

+

•

+ +

⚠️ Défis rencontrés

+

•

+ +

💡 Leçons apprises

+

•

+ +

🎯 Objectifs semaine prochaine

+

1.

+

2.

+

3.

+ +

🔄 Habitudes à améliorer

+

•

`, + 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: `

🐛 Bug Report

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Priorité: 🔴 Critique / 🟠 Haute / 🟡 Moyenne / 🟢 Basse

+

Statut: Nouveau / En cours / Résolu

+ +

📋 Description

+

+ +

🔄 Étapes pour reproduire

+

1.

+

2.

+

3.

+ +

✅ Résultat attendu

+

+ +

❌ Résultat actuel

+

+ +

💻 Environnement

+

OS:

+

Navigateur:

+

Version:

+ +

📌 Notes additionnelles

+

+ +

🔧 Solution proposée

+

`, + 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: `

📚 Notes d'apprentissage - [Sujet]

+ +

Date: ${new Date().toLocaleDateString('fr-FR')}

+

Source:

+ +

🎯 Objectifs d'apprentissage

+

•

+ +

📝 Concepts clés

+

Concept 1:

+

•

+ +

Concept 2:

+

•

+ +

💡 Exemples

+

+ +

❓ Questions

+

•

+ +

🔗 Ressources

+

•

+ +

📌 À retenir

+

•

+ +

✍️ Pratique

+

Exercices à faire:

+

1.

`, + 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: `

✈️ Voyage - [Destination]

+ +

📅 Dates: du [date] au [date]

+

👥 Voyageurs:

+

💰 Budget:

+ +

🎒 À emporter

+

Vêtements:

+

•

+ +

Documents:

+

• Passeport

+

• Billets

+

• Assurance

+ +

🏨 Hébergement

+

Hôtel/Airbnb:

+

Adresse:

+

Check-in:

+ +

✈️ Transport

+

Vol aller:

+

Vol retour:

+ +

📍 Lieux à visiter

+

1.

+

2.

+

3.

+ +

🍽️ Restaurants recommandés

+

•

+ +

💡 Conseils et infos pratiques

+

•

`, + 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 { + const categorized = new Map(); + + 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) + ); +} From a1addbacba44b40dde042e0886f04580b43503cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 13:48:02 +0000 Subject: [PATCH 05/15] feat: add advanced search with filters for notes - Create AdvancedSearch component with multiple filter options - Add filters for tags, date range, content type (todos/images/files), and priority - Integrate advanced search in desktop and mobile note pages - Extract unique tags from notes for filter dropdown - Support multiple simultaneous filters with clear visual feedback - Reset pagination when filters change --- src/components/AdvancedSearch.tsx | 283 ++++++++++++++++++++++++++++++ src/pages/Index.tsx | 106 +++++++++-- src/pages/mobile/NotesPage.tsx | 88 +++++++++- 3 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 src/components/AdvancedSearch.tsx diff --git a/src/components/AdvancedSearch.tsx b/src/components/AdvancedSearch.tsx new file mode 100644 index 0000000..34b365a --- /dev/null +++ b/src/components/AdvancedSearch.tsx @@ -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 ( +
+ + + + + +
+
+

Filtres avancés

+ {hasActiveFilters && ( + + )} +
+ + {/* Tags Filter */} +
+ + + + + + + + + + Aucun tag trouvé + + {availableTags.map((tag) => ( + toggleTag(tag.name)} + className="cursor-pointer" + > + + + {tag.name} + + ))} + + + + + + {selectedTags.length > 0 && ( +
+ {selectedTags.map((tag) => ( + + {tag.name} + toggleTag(tag.name)} + /> + + ))} +
+ )} +
+ + {/* Date Range Filter */} +
+ +
+ setDatePickerOpen(open ? 'from' : null)}> + + + + + { + onFiltersChange({ ...filters, dateFrom: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + + setDatePickerOpen(open ? 'to' : null)}> + + + + + { + onFiltersChange({ ...filters, dateTo: date }); + setDatePickerOpen(null); + }} + initialFocus + /> + + +
+ {(filters.dateFrom || filters.dateTo) && ( + + )} +
+ + {/* Content Type Filters */} +
+ +
+
+ + onFiltersChange({ ...filters, hasTodos: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasImages: checked ? true : undefined }) + } + /> + +
+
+ + onFiltersChange({ ...filters, hasFiles: checked ? true : undefined }) + } + /> + +
+
+
+ + {/* Priority Filter */} +
+
+ + onFiltersChange({ ...filters, priority: checked ? true : undefined }) + } + /> + +
+
+
+
+
+ + {hasActiveFilters && ( + + )} +
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 8103690..33f4907 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -36,6 +36,7 @@ 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"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -115,6 +116,15 @@ const Index = () => { const [user, setUser] = useState(null); const [showAdmin, setShowAdmin] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [searchFilters, setSearchFilters] = useState({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); const [notes, setNotes] = useState([]); const [openNote, setOpenNote] = useState(null); const [users, setUsers] = useState([]); @@ -828,12 +838,71 @@ const Index = () => { } }; - const filteredNotes = notes.filter(note => - (showArchived ? note.archived : !note.archived) && - (searchQuery === "" || - (note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) || - (note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) - ); + // Extract unique tags from all notes + const availableTags: TagOption[] = Array.from( + new Map( + notes.flatMap(note => + (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }]) + ) + ).values() + ).sort((a, b) => a.name.localeCompare(b.name)); + + const filteredNotes = notes.filter(note => { + // Archived filter + if (showArchived ? !note.archived : note.archived) return false; + + // Text search + if (searchQuery !== "" && + !(note.title && note.title.toLowerCase().includes(searchQuery.toLowerCase())) && + !(note.content && note.content.toLowerCase().includes(searchQuery.toLowerCase()))) { + return false; + } + + // Tags filter + if (searchFilters.tags.length > 0) { + const noteTags = (note.tags || []).map(t => t.name); + if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) { + return false; + } + } + + // Date range filter + if (searchFilters.dateFrom || searchFilters.dateTo) { + const noteDate = new Date(note.updated_at || note.created_at || 0); + if (searchFilters.dateFrom) { + const fromDate = new Date(searchFilters.dateFrom); + fromDate.setHours(0, 0, 0, 0); + if (noteDate < fromDate) return false; + } + if (searchFilters.dateTo) { + const toDate = new Date(searchFilters.dateTo); + toDate.setHours(23, 59, 59, 999); + if (noteDate > toDate) return false; + } + } + + // Has todos filter + if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) { + return false; + } + + // Has images filter + if (searchFilters.hasImages && (!note.images || note.images.length === 0)) { + return false; + } + + // Has files filter + if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) { + return false; + } + + // Priority filter + if (searchFilters.priority && !note.priority) { + return false; + } + + return true; + }); // Pagination for notes const totalNotesPages = Math.ceil(filteredNotes.length / NOTES_PER_PAGE); @@ -845,7 +914,7 @@ const Index = () => { // Reset page when changing filters or search useEffect(() => { setNotesPage(0); - }, [showArchived, searchQuery]); + }, [showArchived, searchQuery, searchFilters]); useEffect(() => { setTodosActivePage(0); @@ -1143,14 +1212,21 @@ const Index = () => {
-
- - setSearchQuery(e.target.value)} +
+
+ + setSearchQuery(e.target.value)} + /> +
+
diff --git a/src/pages/mobile/NotesPage.tsx b/src/pages/mobile/NotesPage.tsx index be1a682..83158a1 100644 --- a/src/pages/mobile/NotesPage.tsx +++ b/src/pages/mobile/NotesPage.tsx @@ -10,12 +10,22 @@ import NotesService, { Note } from "@/services/NotesService"; import { showError, showSuccess } from "@/utils/toast"; import { TemplateSelector } from "@/components/TemplateSelector"; import type { NoteTemplate } from "@/utils/noteTemplates"; +import { AdvancedSearch, type SearchFilters, type TagOption } from "@/components/AdvancedSearch"; const NOTES_PER_PAGE = 10; export default function NotesPage() { const [notes, setNotes] = useState([]); const [searchQuery, setSearchQuery] = useState(""); + const [searchFilters, setSearchFilters] = useState({ + tags: [], + dateFrom: undefined, + dateTo: undefined, + hasTodos: undefined, + hasImages: undefined, + hasFiles: undefined, + priority: undefined + }); const [showArchived, setShowArchived] = useState(false); const [page, setPage] = useState(0); const [templateSelectorOpen, setTemplateSelectorOpen] = useState(false); @@ -34,13 +44,71 @@ export default function NotesPage() { loadNotes(); }, []); + // Extract unique tags from all notes + const availableTags: TagOption[] = Array.from( + new Map( + notes.flatMap(note => + (note.tags || []).map(tag => [tag.name, { id: tag.id!, name: tag.name }]) + ) + ).values() + ).sort((a, b) => a.name.localeCompare(b.name)); + const filteredNotes = notes .filter(note => { - const matchArchived = showArchived ? note.archived : !note.archived; - const matchSearch = searchQuery === "" || - note.title?.toLowerCase().includes(searchQuery.toLowerCase()) || - note.content?.toLowerCase().includes(searchQuery.toLowerCase()); - return matchArchived && matchSearch; + // Archived filter + if (showArchived ? !note.archived : note.archived) return false; + + // Text search + if (searchQuery !== "" && + !(note.title?.toLowerCase().includes(searchQuery.toLowerCase())) && + !(note.content?.toLowerCase().includes(searchQuery.toLowerCase()))) { + return false; + } + + // Tags filter + if (searchFilters.tags.length > 0) { + const noteTags = (note.tags || []).map(t => t.name); + if (!searchFilters.tags.some(filterTag => noteTags.includes(filterTag))) { + return false; + } + } + + // Date range filter + if (searchFilters.dateFrom || searchFilters.dateTo) { + const noteDate = new Date(note.updated_at || note.created_at || 0); + if (searchFilters.dateFrom) { + const fromDate = new Date(searchFilters.dateFrom); + fromDate.setHours(0, 0, 0, 0); + if (noteDate < fromDate) return false; + } + if (searchFilters.dateTo) { + const toDate = new Date(searchFilters.dateTo); + toDate.setHours(23, 59, 59, 999); + if (noteDate > toDate) return false; + } + } + + // Has todos filter + if (searchFilters.hasTodos && (!note.todos || note.todos.length === 0)) { + return false; + } + + // Has images filter + if (searchFilters.hasImages && (!note.images || note.images.length === 0)) { + return false; + } + + // Has files filter + if (searchFilters.hasFiles && (!note.files || note.files.length === 0)) { + return false; + } + + // Priority filter + if (searchFilters.priority && !note.priority) { + return false; + } + + return true; }) .sort((a, b) => { if (a.priority && !b.priority) return -1; @@ -106,6 +174,16 @@ export default function NotesPage() { /> + {/* Advanced Filters */} + { + setSearchFilters(newFilters); + setPage(0); + }} + availableTags={availableTags} + /> + {/* Filter Tabs */}
+ + +
+ + {/* Timer Display */} +
+
+ {formatTime(timeLeft)} +
+ {/* Progress bar */} +
+
+
+
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + + )} + +
+ + {/* Session Info */} +
+ {mode === 'work' && ( +

Session {(sessionsCompleted % SESSIONS_BEFORE_LONG_BREAK) + 1} sur {SESSIONS_BEFORE_LONG_BREAK}

+ )} + {mode === 'shortBreak' &&

Pause courte - Profitez-en pour vous étirer

} + {mode === 'longBreak' &&

Pause longue - Temps de vraiment déconnecter

} +
+ + + ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 33f4907..ba5de9f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -37,6 +37,7 @@ import { downloadNoteAsMarkdown, downloadAllNotesAsMarkdown, downloadTodosAsMark 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"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1740,8 +1741,13 @@ const Index = () => { )}
- {/* Right Column: Todos & RSS Boxes side by side */} -
+ {/* Right Column: Pomodoro Timer, Todos & RSS */} +
+ {/* Pomodoro Timer */} + + + {/* Todos & RSS Boxes side by side */} +
{/* Todos Box */} @@ -1971,6 +1977,7 @@ const Index = () => {
+
From 712d3fd21a598ad611eaaad4b87ec3335ffa8f67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:01:14 +0000 Subject: [PATCH 08/15] feat: add Kanban board view for notes organization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create KanbanBoard component with drag-and-drop functionality - Support 5 columns: Backlog, À faire, En cours, En révision, Terminé - Use tag-based system to track note status in columns - Add view toggle between List and Kanban modes in desktop interface - Display note metadata (todos, images, files, tags) in Kanban cards - Support moving notes between columns by dragging - Auto-update tags when moving notes to different columns - Show task completion progress in Kanban cards --- src/components/KanbanBoard.tsx | 196 +++++++++++++++++++++++++++++++++ src/pages/Index.tsx | 67 ++++++++++- 2 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 src/components/KanbanBoard.tsx diff --git a/src/components/KanbanBoard.tsx b/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..4e5ac7b --- /dev/null +++ b/src/components/KanbanBoard.tsx @@ -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(null); + const [dragOverColumn, setDragOverColumn] = useState(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); + + 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 ( +
+ {DEFAULT_COLUMNS.map((column) => ( +
handleDragOver(e, column.id)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, column.id)} + > + + +
+ +
+ {column.title} + + {notesByColumn[column.id]?.length || 0} + + + +
+ + + +
+ {notesByColumn[column.id]?.map((note) => ( +
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' : ''}`} + > +
+ +
+

+ {note.title || "Sans titre"} +

+ {note.content && ( +

+ {note.content.replace(/<[^>]*>/g, '')} +

+ )} +
+
+ + {/* Metadata badges */} +
+ {note.todos && note.todos.length > 0 && ( + + + {note.todos.filter(t => t.completed).length}/{note.todos.length} + + )} + {note.images && note.images.length > 0 && ( + + + {note.images.length} + + )} + {note.files && note.files.length > 0 && ( + + + {note.files.length} + + )} + {note.tags && note.tags.length > 0 && ( +
+ {note.tags.slice(0, 2).map((tag) => ( + + + {tag.name} + + ))} + {note.tags.length > 2 && ( + + +{note.tags.length - 2} + + )} +
+ )} +
+ + {/* Date */} + {note.updated_at && ( +

+ {new Date(note.updated_at).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short' + })} +

+ )} +
+ ))} + {notesByColumn[column.id]?.length === 0 && ( +
+ Aucune note +
+ )} +
+
+
+ +
+ ))} +
+ ); +} diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ba5de9f..44bc16a 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown + 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 } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -38,6 +38,7 @@ 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"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -136,6 +137,7 @@ const Index = () => { const [settings, setSettings] = useState({}); const [adminTab, setAdminTab] = useState("users"); const [showArchived, setShowArchived] = useState(false); + const [notesViewMode, setNotesViewMode] = useState<'list' | 'kanban'>('list'); const [noteTags, setNoteTags] = useState([]); const [openRouterModels, setOpenRouterModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); @@ -1229,11 +1231,35 @@ const Index = () => { onFiltersChange={setSearchFilters} availableTags={availableTags} /> + + {/* View Mode Toggle */} +
+ + +
- {/* Notes Grid - 1 column for better visibility */} -
+ {/* Notes View - List or Kanban */} + {notesViewMode === 'list' ? ( + <> +
{filteredNotes.length > 0 ? ( paginatedNotes.map(note => ( {
)} + + ) : ( + /* Kanban View */ + { + 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 */ From e48bc1eaf1c5aeadd2ba9d13ed4e4257bbe6a942 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:03:41 +0000 Subject: [PATCH 09/15] feat: add smart tag auto-suggestions for notes - Create smartTags utility with keyword extraction algorithm - Filter out 100+ French stop words for better keyword quality - Implement category-based tag suggestions (travail, dev, design, etc.) - Suggest tags from similar notes based on content similarity - Display smart suggestions with one-click add in note editor - Show sparkle icon to indicate AI-assisted suggestions - Combine multiple suggestion strategies for best results - Limit to top 8 most relevant suggestions per note --- src/pages/Index.tsx | 31 ++++++- src/utils/smartTags.ts | 179 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 src/utils/smartTags.ts diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 44bc16a..6ed7891 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -15,7 +15,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { RichTextEditor } from "@/components/RichTextEditor"; import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, - Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List + Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon, MessageSquare, Send, Check, ChevronsUpDown, Star, Activity, FileDown, LayoutGrid, List, Sparkles } from "lucide-react"; import AuthService from "@/services/AuthService"; import AdminService from "@/services/AdminService"; @@ -39,6 +39,7 @@ 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"; // ===== FONCTIONS UTILITAIRES TIMEZONE EUROPE/PARIS ===== @@ -1603,6 +1604,34 @@ const Index = () => {
)} + {/* Smart Tag Suggestions */} + {openNote && (() => { + const existingTags = noteTags.map(t => t.tag); + const suggestions = getSmartTagSuggestions(openNote, notes, existingTags); + + return suggestions.length > 0 && ( +
+

+ + Suggestions de tags +

+
+ {suggestions.map((suggestion) => ( + confirmAddTag(suggestion)} + > + + {suggestion} + + ))} +
+
+ ); + })()} + {/* Todos */} {openNote.todos && openNote.todos.length > 0 && ( <> diff --git a/src/utils/smartTags.ts b/src/utils/smartTags.ts new file mode 100644 index 0000000..be45a24 --- /dev/null +++ b/src/utils/smartTags.ts @@ -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 = { + '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(); + 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(); + 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); +} From ea926cb1a13874e44b4868a6a892b46b08422cd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:04:33 +0000 Subject: [PATCH 10/15] feat: add note linking system with wiki-style syntax - Create noteLinks utility for wiki-style [[Note Title]] links - Support automatic link detection and conversion to clickable HTML - Show broken links (when target note doesn't exist) in gray strikethrough - Implement backlinks detection (find notes linking to current note) - Add autocomplete suggestions when typing [[ for quick note selection - Support cursor position detection for inline link editing - Prioritize exact matches and recent notes in suggestions - Convert links to HTML with custom styling and click handlers --- src/utils/noteLinks.ts | 187 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 src/utils/noteLinks.ts diff --git a/src/utils/noteLinks.ts b/src/utils/noteLinks.ts new file mode 100644 index 0000000..a556c2d --- /dev/null +++ b/src/utils/noteLinks.ts @@ -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 `${trimmedText}`; + } else { + // No matching note - show as grayed out + return `[[${trimmedText}]]`; + } + }); +} + +/** + * 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 + }; +} From 1e9795e5d9b5c6d768c27c6abe8d0170bdc6494c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Nov 2025 14:06:15 +0000 Subject: [PATCH 11/15] feat: add quick capture floating widget for fast note creation - Create QuickCaptureWidget with floating action button - Support quick note capture with optional title - Auto-generate timestamped title if none provided - Add keyboard shortcuts: Ctrl+Enter to save, Escape to cancel - Integrate into desktop interface with reload on capture - Display visual feedback with kbd tags for shortcuts - Position as floating FAB in bottom-right corner - Refresh notes list automatically after capture --- src/components/QuickCaptureWidget.tsx | 145 ++++++++++++++++++++++++++ src/pages/Index.tsx | 4 + 2 files changed, 149 insertions(+) create mode 100644 src/components/QuickCaptureWidget.tsx diff --git a/src/components/QuickCaptureWidget.tsx b/src/components/QuickCaptureWidget.tsx new file mode 100644 index 0000000..d69140d --- /dev/null +++ b/src/components/QuickCaptureWidget.tsx @@ -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 */} + + + {/* Quick Capture Modal */} + + + + + + Capture rapide + + + Capturez rapidement une idée ou une note. Appuyez sur Ctrl+Entrée pour enregistrer. + + + +
+ {/* Optional Title */} +
+ setTitle(e.target.value)} + className="text-lg font-medium" + autoFocus={false} + /> +

+ Si vide, un titre avec la date sera généré automatiquement +

+
+ + {/* Content */} +