mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
feat: Implement initial Noteflow application UI, foundational notes management, and mobile notes page.
This commit is contained in:
1 parent
ad3f415469
commit
4ba078bf4e
4 files changed
+416
-363
No files matched your search
@@ -38,6 +38,30 @@ const upload = multer({
|
|||||||
// Toutes les routes nécessitent authentification
|
// Toutes les routes nécessitent authentification
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notes/counts
|
||||||
|
* Récupérer le nombre de notes actives et archivées
|
||||||
|
*/
|
||||||
|
router.get('/counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const activeCount = await getAll(`
|
||||||
|
SELECT COUNT(*) as count FROM notes WHERE user_id = $1 AND archived = false
|
||||||
|
`, [req.user.id]);
|
||||||
|
|
||||||
|
const archivedCount = await getAll(`
|
||||||
|
SELECT COUNT(*) as count FROM notes WHERE user_id = $1 AND archived = true
|
||||||
|
`, [req.user.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
active: parseInt(activeCount[0]?.count || 0),
|
||||||
|
archived: parseInt(archivedCount[0]?.count || 0)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la récupération des compteurs:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/notes?archived=true/false
|
* GET /api/notes?archived=true/false
|
||||||
* Liste toutes les notes de l'utilisateur
|
* Liste toutes les notes de l'utilisateur
|
||||||
|
|||||||
+365
-359
@@ -159,6 +159,7 @@ const Index = () => {
|
|||||||
const [rssFeeds, setRssFeeds] = useState<RssFeed[]>([]);
|
const [rssFeeds, setRssFeeds] = useState<RssFeed[]>([]);
|
||||||
const [rssArticles, setRssArticles] = useState<RssArticle[]>([]);
|
const [rssArticles, setRssArticles] = useState<RssArticle[]>([]);
|
||||||
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [counts, setCounts] = useState({ active: 0, archived: 0 });
|
||||||
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);
|
||||||
@@ -303,10 +304,15 @@ const Index = () => {
|
|||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
try {
|
try {
|
||||||
const fetchedNotes = await NotesService.getNotes(showArchived);
|
const [fetchedNotes, fetchedCounts] = await Promise.all([
|
||||||
|
NotesService.getNotes(showArchived),
|
||||||
|
NotesService.getCounts()
|
||||||
|
]);
|
||||||
|
|
||||||
if (Array.isArray(fetchedNotes)) {
|
if (Array.isArray(fetchedNotes)) {
|
||||||
setNotes(fetchedNotes);
|
setNotes(fetchedNotes);
|
||||||
}
|
}
|
||||||
|
setCounts(fetchedCounts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors du chargement des notes:", error);
|
console.error("Erreur lors du chargement des notes:", error);
|
||||||
}
|
}
|
||||||
@@ -1352,13 +1358,13 @@ const Index = () => {
|
|||||||
variant={!showArchived ? "default" : "outline"}
|
variant={!showArchived ? "default" : "outline"}
|
||||||
onClick={() => setShowArchived(false)}
|
onClick={() => setShowArchived(false)}
|
||||||
>
|
>
|
||||||
Actives ({notes.filter(n => !n.archived).length})
|
Actives ({counts.active})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={showArchived ? "default" : "outline"}
|
variant={showArchived ? "default" : "outline"}
|
||||||
onClick={() => setShowArchived(true)}
|
onClick={() => setShowArchived(true)}
|
||||||
>
|
>
|
||||||
Archivées ({notes.filter(n => n.archived).length})
|
Archivées ({counts.archived})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1407,145 +1413,145 @@ const Index = () => {
|
|||||||
{/* Notes View - List or Kanban */}
|
{/* Notes View - List or Kanban */}
|
||||||
{notesViewMode === 'list' ? (
|
{notesViewMode === 'list' ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{filteredNotes.length > 0 ? (
|
{filteredNotes.length > 0 ? (
|
||||||
paginatedNotes.map(note => (
|
paginatedNotes.map(note => (
|
||||||
<Card
|
<Card
|
||||||
key={note.id}
|
key={note.id}
|
||||||
className="cursor-pointer hover:shadow-md transition-all group relative"
|
className="cursor-pointer hover:shadow-md transition-all group relative"
|
||||||
onClick={() => handleOpenNote(note)}
|
onClick={() => handleOpenNote(note)}
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
{/* Priority icon */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`absolute top-2 right-2 h-6 w-6 transition-opacity ${note.priority ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
|
||||||
}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const newPriority = !note.priority;
|
|
||||||
if (note.id) {
|
|
||||||
NotesService.togglePriority(note.id, newPriority);
|
|
||||||
setNotes(notes.map(n => n.id === note.id ? { ...n, priority: newPriority } : n));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{note.priority ? (
|
<CardContent className="p-4">
|
||||||
<span className="text-red-500 text-lg font-bold">!</span>
|
{/* Priority icon */}
|
||||||
) : (
|
<Button
|
||||||
<span className="text-muted-foreground text-lg">!</span>
|
variant="ghost"
|
||||||
)}
|
size="icon"
|
||||||
</Button>
|
className={`absolute top-2 right-2 h-6 w-6 transition-opacity ${note.priority ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newPriority = !note.priority;
|
||||||
|
if (note.id) {
|
||||||
|
NotesService.togglePriority(note.id, newPriority);
|
||||||
|
setNotes(notes.map(n => n.id === note.id ? { ...n, priority: newPriority } : n));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{note.priority ? (
|
||||||
|
<span className="text-red-500 text-lg font-bold">!</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-lg">!</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Archive icon */}
|
{/* Archive icon */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={`absolute top-2 right-10 h-6 w-6 transition-opacity ${note.archived ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
className={`absolute top-2 right-10 h-6 w-6 transition-opacity ${note.archived ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
onClick={async (e) => {
|
onClick={async (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (note.id) {
|
if (note.id) {
|
||||||
const newArchived = !note.archived;
|
const newArchived = !note.archived;
|
||||||
const success = await NotesService.archiveNote(note.id, newArchived);
|
const success = await NotesService.archiveNote(note.id, newArchived);
|
||||||
if (success) {
|
if (success) {
|
||||||
setNotes(notes.map(n => n.id === note.id ? { ...n, archived: newArchived } : n));
|
setNotes(notes.map(n => n.id === note.id ? { ...n, archived: newArchived } : n));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title={note.archived ? "Désarchiver" : "Archiver"}
|
title={note.archived ? "Désarchiver" : "Archiver"}
|
||||||
>
|
>
|
||||||
<Archive className="h-4 w-4 text-muted-foreground" />
|
<Archive className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<h3 className="font-semibold text-base mb-2 line-clamp-1 pr-8">
|
<h3 className="font-semibold text-base mb-2 line-clamp-1 pr-8">
|
||||||
{note.title || "Sans titre"}
|
{note.title || "Sans titre"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||||
{note.content ? cleanHtmlContent(note.content) : "Note vide"}
|
{note.content ? cleanHtmlContent(note.content) : "Note vide"}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 flex-wrap mb-2">
|
||||||
|
{note.todos && note.todos.length > 0 && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<CheckSquare className="h-3 w-3 mr-1" />
|
||||||
|
{note.todos.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<ImageIcon className="h-3 w-3 mr-1" />
|
||||||
|
{note.images.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.files && note.files.length > 0 && (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<Paperclip className="h-3 w-3 mr-1" />
|
||||||
|
{note.files.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
note.tags.map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline" className="text-xs">
|
||||||
|
<TagIcon className="h-3 w-3 mr-1" />
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{note.updated_at && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Modifiée le {new Date(note.updated_at).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="col-span-1 text-center py-16">
|
||||||
|
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground text-lg mb-4">
|
||||||
|
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 flex-wrap mb-2">
|
{!searchQuery && (
|
||||||
{note.todos && note.todos.length > 0 && (
|
<Button onClick={handleCreateNote} size="lg">
|
||||||
<Badge variant="secondary">
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
<CheckSquare className="h-3 w-3 mr-1" />
|
Créer votre première note
|
||||||
{note.todos.length}
|
</Button>
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{note.images && note.images.length > 0 && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<ImageIcon className="h-3 w-3 mr-1" />
|
|
||||||
{note.images.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{note.files && note.files.length > 0 && (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
<Paperclip className="h-3 w-3 mr-1" />
|
|
||||||
{note.files.length}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{note.tags && note.tags.length > 0 && (
|
|
||||||
note.tags.map((tag) => (
|
|
||||||
<Badge key={tag.id} variant="outline" className="text-xs">
|
|
||||||
<TagIcon className="h-3 w-3 mr-1" />
|
|
||||||
{tag.name}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{note.updated_at && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Modifiée le {new Date(note.updated_at).toLocaleDateString('fr-FR', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric'
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="col-span-1 text-center py-16">
|
|
||||||
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground text-lg mb-4">
|
|
||||||
{searchQuery ? "Aucune note trouvée" : "Aucune note"}
|
|
||||||
</p>
|
|
||||||
{!searchQuery && (
|
|
||||||
<Button onClick={handleCreateNote} size="lg">
|
|
||||||
<Plus className="h-5 w-5 mr-2" />
|
|
||||||
Créer votre première note
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination pour les notes */}
|
{/* Pagination pour les notes */}
|
||||||
{totalNotesPages > 1 && (
|
{totalNotesPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 mt-6">
|
<div className="flex items-center justify-center gap-2 mt-6">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setNotesPage(p => Math.max(0, p - 1))}
|
onClick={() => setNotesPage(p => Math.max(0, p - 1))}
|
||||||
disabled={notesPage === 0}
|
disabled={notesPage === 0}
|
||||||
>
|
>
|
||||||
Précédent
|
Précédent
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Page {notesPage + 1} sur {totalNotesPages}
|
Page {notesPage + 1} sur {totalNotesPages}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setNotesPage(p => Math.min(totalNotesPages - 1, p + 1))}
|
onClick={() => setNotesPage(p => Math.min(totalNotesPages - 1, p + 1))}
|
||||||
disabled={notesPage >= totalNotesPages - 1}
|
disabled={notesPage >= totalNotesPages - 1}
|
||||||
>
|
>
|
||||||
Suivant
|
Suivant
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
/* Kanban View */
|
/* Kanban View */
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
@@ -1982,235 +1988,235 @@ const Index = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Todos & RSS Boxes side by side */}
|
{/* Todos & RSS Boxes side by side */}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<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">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<CheckSquare className="h-5 w-5" />
|
<CheckSquare className="h-5 w-5" />
|
||||||
Tâches
|
Tâches
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddTodoModal(true)}>
|
<Button size="sm" variant="outline" onClick={() => setAddTodoModal(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Tabs defaultValue="active" className="w-full">
|
|
||||||
<TabsList className="grid grid-cols-2 w-full mb-3">
|
|
||||||
<TabsTrigger value="active" className="text-xs">Actives ({todos.filter(t => !t.completed).length})</TabsTrigger>
|
|
||||||
<TabsTrigger value="completed" className="text-xs">Complétées ({todos.filter(t => t.completed).length})</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="active" className="mt-0">
|
|
||||||
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
|
|
||||||
{activeTodos.length > 0 ? (
|
|
||||||
paginatedActiveTodos.map(todo => (
|
|
||||||
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
|
||||||
<Checkbox
|
|
||||||
checked={false}
|
|
||||||
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1 leading-snug">
|
|
||||||
{todo.text}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-7 w-7 transition-all ${todo.in_progress ? 'text-red-500 opacity-100' : 'text-muted-foreground opacity-0 group-hover:opacity-100'}`}
|
|
||||||
onClick={() => todo.id && handleToggleTodoInProgress(todo.id)}
|
|
||||||
title={todo.in_progress ? "Marquer comme non commencé" : "Marquer comme en cours"}
|
|
||||||
>
|
|
||||||
<Activity className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className={`h-7 w-7 ${todo.priority ? '' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}
|
|
||||||
onClick={() => todo.id && handleToggleTodoPriority(todo.id)}
|
|
||||||
title={todo.priority ? "Retirer la priorité" : "Marquer comme prioritaire"}
|
|
||||||
>
|
|
||||||
<Star className={`h-3.5 w-3.5 ${todo.priority ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche active</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalActiveTodosPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setTodosActivePage(p => Math.max(0, p - 1))}
|
|
||||||
disabled={todosActivePage === 0}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{todosActivePage + 1}/{totalActiveTodosPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setTodosActivePage(p => Math.min(totalActiveTodosPages - 1, p + 1))}
|
|
||||||
disabled={todosActivePage >= totalActiveTodosPages - 1}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="completed" className="mt-0">
|
|
||||||
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
|
|
||||||
{completedTodos.length > 0 ? (
|
|
||||||
paginatedCompletedTodos.map(todo => (
|
|
||||||
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
|
||||||
<Checkbox
|
|
||||||
checked={true}
|
|
||||||
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<span className="text-sm flex-1 line-through text-muted-foreground leading-snug">
|
|
||||||
{todo.text}
|
|
||||||
</span>
|
|
||||||
{todo.priority && (
|
|
||||||
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400 opacity-50" />
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche complétée</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalCompletedTodosPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setTodosCompletedPage(p => Math.max(0, p - 1))}
|
|
||||||
disabled={todosCompletedPage === 0}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{todosCompletedPage + 1}/{totalCompletedTodosPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setTodosCompletedPage(p => Math.min(totalCompletedTodosPages - 1, p + 1))}
|
|
||||||
disabled={todosCompletedPage >= totalCompletedTodosPages - 1}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* RSS Box */}
|
|
||||||
<Card className="shadow-lg">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<CardTitle className="text-xl flex items-center gap-2 flex-shrink-0">
|
|
||||||
<Rss className="h-5 w-5" />
|
|
||||||
Flux RSS
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex gap-1.5 flex-shrink-0">
|
|
||||||
<Button size="sm" variant="outline" onClick={handleRefreshRss}>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => setAddRssFeedModal(true)}>
|
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<Tabs defaultValue="active" className="w-full">
|
||||||
<div className="space-y-2 max-h-[calc(100vh-500px)] overflow-y-auto">
|
<TabsList className="grid grid-cols-2 w-full mb-3">
|
||||||
{rssArticles.length > 0 ? (
|
<TabsTrigger value="active" className="text-xs">Actives ({todos.filter(t => !t.completed).length})</TabsTrigger>
|
||||||
paginatedRssArticles.map(article => (
|
<TabsTrigger value="completed" className="text-xs">Complétées ({todos.filter(t => t.completed).length})</TabsTrigger>
|
||||||
<div
|
</TabsList>
|
||||||
key={article.id}
|
|
||||||
className="p-2.5 border rounded hover:bg-accent/50 transition-colors cursor-pointer"
|
|
||||||
onClick={() => article.link && window.open(article.link, '_blank')}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h4 className="font-medium text-sm line-clamp-2 mb-1">{article.title}</h4>
|
|
||||||
{article.description && (
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
||||||
{article.description.replace(/<[^>]*>/g, '')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-center text-muted-foreground text-sm py-6">
|
|
||||||
Aucun article
|
|
||||||
<br />
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="mt-2 text-xs"
|
|
||||||
onClick={() => setAddRssFeedModal(true)}
|
|
||||||
>
|
|
||||||
Ajouter un flux RSS
|
|
||||||
</Button>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{totalRssPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRssPage(p => Math.max(0, p - 1))}
|
|
||||||
disabled={rssPage === 0}
|
|
||||||
>
|
|
||||||
‹
|
|
||||||
</Button>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{rssPage + 1}/{totalRssPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setRssPage(p => Math.min(totalRssPages - 1, p + 1))}
|
|
||||||
disabled={rssPage >= totalRssPages - 1}
|
|
||||||
>
|
|
||||||
›
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
<TabsContent value="active" className="mt-0">
|
||||||
|
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
|
||||||
|
{activeTodos.length > 0 ? (
|
||||||
|
paginatedActiveTodos.map(todo => (
|
||||||
|
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
||||||
|
<Checkbox
|
||||||
|
checked={false}
|
||||||
|
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1 leading-snug">
|
||||||
|
{todo.text}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-7 w-7 transition-all ${todo.in_progress ? 'text-red-500 opacity-100' : 'text-muted-foreground opacity-0 group-hover:opacity-100'}`}
|
||||||
|
onClick={() => todo.id && handleToggleTodoInProgress(todo.id)}
|
||||||
|
title={todo.in_progress ? "Marquer comme non commencé" : "Marquer comme en cours"}
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`h-7 w-7 ${todo.priority ? '' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}
|
||||||
|
onClick={() => todo.id && handleToggleTodoPriority(todo.id)}
|
||||||
|
title={todo.priority ? "Retirer la priorité" : "Marquer comme prioritaire"}
|
||||||
|
>
|
||||||
|
<Star className={`h-3.5 w-3.5 ${todo.priority ? 'fill-yellow-400 text-yellow-400' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche active</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalActiveTodosPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTodosActivePage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={todosActivePage === 0}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{todosActivePage + 1}/{totalActiveTodosPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTodosActivePage(p => Math.min(totalActiveTodosPages - 1, p + 1))}
|
||||||
|
disabled={todosActivePage >= totalActiveTodosPages - 1}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="completed" className="mt-0">
|
||||||
|
<div className="space-y-1.5 max-h-[calc(100vh-400px)] overflow-y-auto">
|
||||||
|
{completedTodos.length > 0 ? (
|
||||||
|
paginatedCompletedTodos.map(todo => (
|
||||||
|
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded hover:bg-accent/50 transition-colors group">
|
||||||
|
<Checkbox
|
||||||
|
checked={true}
|
||||||
|
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm flex-1 line-through text-muted-foreground leading-snug">
|
||||||
|
{todo.text}
|
||||||
|
</span>
|
||||||
|
{todo.priority && (
|
||||||
|
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400 opacity-50" />
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => todo.id && handleDeleteTodo(todo.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground text-sm py-6">Aucune tâche complétée</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalCompletedTodosPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTodosCompletedPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={todosCompletedPage === 0}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{todosCompletedPage + 1}/{totalCompletedTodosPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setTodosCompletedPage(p => Math.min(totalCompletedTodosPages - 1, p + 1))}
|
||||||
|
disabled={todosCompletedPage >= totalCompletedTodosPages - 1}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RSS Box */}
|
||||||
|
<Card className="shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle className="text-xl flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Rss className="h-5 w-5" />
|
||||||
|
Flux RSS
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1.5 flex-shrink-0">
|
||||||
|
<Button size="sm" variant="outline" onClick={handleRefreshRss}>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddRssFeedModal(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2 max-h-[calc(100vh-500px)] overflow-y-auto">
|
||||||
|
{rssArticles.length > 0 ? (
|
||||||
|
paginatedRssArticles.map(article => (
|
||||||
|
<div
|
||||||
|
key={article.id}
|
||||||
|
className="p-2.5 border rounded hover:bg-accent/50 transition-colors cursor-pointer"
|
||||||
|
onClick={() => article.link && window.open(article.link, '_blank')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-sm line-clamp-2 mb-1">{article.title}</h4>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{article.description.replace(/<[^>]*>/g, '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-center text-muted-foreground text-sm py-6">
|
||||||
|
Aucun article
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="mt-2 text-xs"
|
||||||
|
onClick={() => setAddRssFeedModal(true)}
|
||||||
|
>
|
||||||
|
Ajouter un flux RSS
|
||||||
|
</Button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{totalRssPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 mt-3 pt-3 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRssPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={rssPage === 0}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{rssPage + 1}/{totalRssPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setRssPage(p => Math.min(totalRssPages - 1, p + 1))}
|
||||||
|
disabled={rssPage >= totalRssPages - 1}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,10 +31,16 @@ export default function NotesPage() {
|
|||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [counts, setCounts] = useState({ active: 0, archived: 0 });
|
||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await NotesService.getNotes(showArchived);
|
const [notesData, countsData] = await Promise.all([
|
||||||
setNotes(data);
|
NotesService.getNotes(showArchived),
|
||||||
|
NotesService.getCounts()
|
||||||
|
]);
|
||||||
|
setNotes(notesData);
|
||||||
|
setCounts(countsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError("Erreur lors du chargement des notes");
|
showError("Erreur lors du chargement des notes");
|
||||||
}
|
}
|
||||||
@@ -184,7 +190,7 @@ export default function NotesPage() {
|
|||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Actives ({notes.filter(n => !n.archived).length})
|
Actives ({counts.active})
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={showArchived ? "default" : "outline"}
|
variant={showArchived ? "default" : "outline"}
|
||||||
@@ -195,7 +201,7 @@ export default function NotesPage() {
|
|||||||
}}
|
}}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Archivées ({notes.filter(n => n.archived).length})
|
Archivées ({counts.archived})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,23 @@ class NotesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCounts(): Promise<{ active: number; archived: number }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/notes/counts", {
|
||||||
|
headers: AuthService.getHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Erreur lors de la récupération des compteurs");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur compteur:", error);
|
||||||
|
return { active: 0, archived: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createNote(title: string, content: string = ""): Promise<Note | null> {
|
async createNote(title: string, content: string = ""): Promise<Note | null> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/notes", {
|
const response = await fetch("/api/notes", {
|
||||||
|
|||||||
Reference in new issue
Block a user