mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-24 07:38:17 +02:00
Merge pull request #111 from R0m1k3/claude/mobile-page-boxes-017ThBhZv7USsPZZGHt55R9s
Feat: Ajout de la version mobile complète avec navigation sidebar
This commit is contained in:
11 files changed
+1664
No files matched your search
+54
@@ -5,9 +5,17 @@ import { ThemeProvider } from "@/components/theme-provider";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import MobileDashboard from "./pages/mobile/MobileDashboard";
|
||||||
|
import CalendarPage from "./pages/mobile/CalendarPage";
|
||||||
|
import NotesPage from "./pages/mobile/NotesPage";
|
||||||
|
import NoteDetailPage from "./pages/mobile/NoteDetailPage";
|
||||||
|
import TodosPage from "./pages/mobile/TodosPage";
|
||||||
|
import RssPage from "./pages/mobile/RssPage";
|
||||||
|
import SettingsPage from "./pages/mobile/SettingsPage";
|
||||||
import AuthService from "./services/AuthService";
|
import AuthService from "./services/AuthService";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -22,6 +30,22 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-redirect component based on device
|
||||||
|
const AutoRedirect = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile === undefined) {
|
||||||
|
// Still detecting, show loading
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Navigate to={isMobile ? "/mobile/notes" : "/desktop"} replace />;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
@@ -56,14 +80,44 @@ const App = () => {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
{/* Auto-redirect root to mobile or desktop */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AutoRedirect />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Desktop route */}
|
||||||
|
<Route
|
||||||
|
path="/desktop"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<Index />
|
<Index />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mobile routes */}
|
||||||
|
<Route
|
||||||
|
path="/mobile"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MobileDashboard />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="calendar" element={<CalendarPage />} />
|
||||||
|
<Route path="notes" element={<NotesPage />} />
|
||||||
|
<Route path="notes/:id" element={<NoteDetailPage />} />
|
||||||
|
<Route path="todos" element={<TodosPage />} />
|
||||||
|
<Route path="rss" element={<RssPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface MobileCardProps {
|
||||||
|
title?: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
headerAction?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileCard = ({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
headerAction,
|
||||||
|
onClick
|
||||||
|
}: MobileCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`shadow-sm ${onClick ? 'cursor-pointer active:scale-[0.98] transition-transform' : ''} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{(title || Icon) && (
|
||||||
|
<CardHeader className="pb-3 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="h-5 w-5" />}
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
{headerAction}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
)}
|
||||||
|
<CardContent className={`px-4 ${title ? 'py-3' : 'py-4'}`}>
|
||||||
|
{children}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
interface MobileFABProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
onClick: () => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileFAB = ({ icon: Icon, onClick, label }: MobileFABProps) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
size="lg"
|
||||||
|
className="fixed bottom-6 right-6 h-14 w-14 rounded-full shadow-lg z-40"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Menu, ArrowLeft } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface MobileHeaderProps {
|
||||||
|
title: string;
|
||||||
|
onMenuClick?: () => void;
|
||||||
|
onBackClick?: () => void;
|
||||||
|
showBack?: boolean;
|
||||||
|
rightAction?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileHeader = ({
|
||||||
|
title,
|
||||||
|
onMenuClick,
|
||||||
|
onBackClick,
|
||||||
|
showBack = false,
|
||||||
|
rightAction
|
||||||
|
}: MobileHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b">
|
||||||
|
<div className="flex items-center justify-between h-14 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{showBack ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onBackClick}
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onMenuClick}
|
||||||
|
className="h-9 w-9"
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-semibold truncate flex-1 text-center">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{rightAction || <div className="w-9" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
||||||
|
import { MobileCard } from "@/components/mobile/MobileCard";
|
||||||
|
import { Plus, RefreshCw, ExternalLink, Edit } from "lucide-react";
|
||||||
|
import CalendarService, { CalendarEvent } from "@/services/CalendarService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const EVENTS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [addEventModal, setAddEventModal] = useState(false);
|
||||||
|
const [editEventModal, setEditEventModal] = useState<{ open: boolean; event?: CalendarEvent }>({ open: false });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const loadEvents = async () => {
|
||||||
|
try {
|
||||||
|
const data = await CalendarService.getEvents(100);
|
||||||
|
const sorted = data.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
|
setEvents(sorted);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors du chargement des événements");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await CalendarService.sync();
|
||||||
|
showSuccess(`${result.syncedCount} événements synchronisés`);
|
||||||
|
await loadEvents();
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la synchronisation");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(events.length / EVENTS_PER_PAGE);
|
||||||
|
const paginatedEvents = events.slice(page * EVENTS_PER_PAGE, (page + 1) * EVENTS_PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 pb-24">
|
||||||
|
{/* Header Actions */}
|
||||||
|
<div className="flex justify-end gap-2 mb-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSync}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Synchroniser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Events List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{events.length > 0 ? (
|
||||||
|
paginatedEvents.map(event => {
|
||||||
|
const startDate = new Date(event.start_time);
|
||||||
|
const endDate = new Date(event.end_time);
|
||||||
|
const isToday = startDate.toDateString() === new Date().toDateString();
|
||||||
|
const isSoon = startDate.getTime() - new Date().getTime() < 30 * 60 * 1000 && startDate > new Date();
|
||||||
|
const isAllDay = event.all_day ||
|
||||||
|
(startDate.getHours() === 0 && startDate.getMinutes() === 0) ||
|
||||||
|
(startDate.getHours() === 1 && startDate.getMinutes() === 0 && endDate.getHours() === 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileCard
|
||||||
|
key={event.id}
|
||||||
|
className={`${
|
||||||
|
isSoon ? 'border-red-500 bg-red-50 dark:bg-red-900/20' :
|
||||||
|
isToday ? 'bg-red-50/50 dark:bg-red-900/10' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 cursor-pointer"
|
||||||
|
onClick={() => event.html_link && window.open(event.html_link, '_blank')}
|
||||||
|
>
|
||||||
|
<h4 className="font-medium line-clamp-2 mb-2">{event.title}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{isToday ? "Aujourd'hui" : startDate.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', timeZone: 'Europe/Paris' })}
|
||||||
|
{isAllDay ? ' - Toute la journée' : ` à ${startDate.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })}`}
|
||||||
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 truncate">
|
||||||
|
📍 {event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{isSoon && (
|
||||||
|
<Badge variant="destructive" className="mt-2">
|
||||||
|
Dans moins de 30 min
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 flex-shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditEventModal({ open: true, event });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<p className="mb-4">Aucun rendez-vous à venir</p>
|
||||||
|
<Button variant="outline" onClick={() => setAddEventModal(true)}>
|
||||||
|
Ajouter un événement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
‹ Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page === totalPages - 1}
|
||||||
|
>
|
||||||
|
Suivant ›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<MobileFAB icon={Plus} onClick={() => setAddEventModal(true)} label="Ajouter un événement" />
|
||||||
|
|
||||||
|
{/* Add Event Modal - Placeholder for now */}
|
||||||
|
<Dialog open={addEventModal} onOpenChange={setAddEventModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nouvel événement</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Cette fonctionnalité sera disponible prochainement
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit Event Modal - Placeholder for now */}
|
||||||
|
<Dialog open={editEventModal.open} onOpenChange={(open) => setEditEventModal({ open })}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Modifier l'événement</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Cette fonctionnalité sera disponible prochainement
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Outlet, useNavigate, useLocation } from "react-router-dom";
|
||||||
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
Calendar,
|
||||||
|
FileText,
|
||||||
|
CheckSquare,
|
||||||
|
Rss,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
User
|
||||||
|
} from "lucide-react";
|
||||||
|
import AuthService from "@/services/AuthService";
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ icon: Calendar, label: "Calendrier", path: "/mobile/calendar" },
|
||||||
|
{ icon: FileText, label: "Notes", path: "/mobile/notes" },
|
||||||
|
{ icon: CheckSquare, label: "Tâches", path: "/mobile/todos" },
|
||||||
|
{ icon: Rss, label: "RSS", path: "/mobile/rss" },
|
||||||
|
{ icon: Settings, label: "Paramètres", path: "/mobile/settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MobileDashboard() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
AuthService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = (path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentTitle = () => {
|
||||||
|
const item = menuItems.find(item => location.pathname.startsWith(item.path));
|
||||||
|
return item?.label || "NoteFlow";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b">
|
||||||
|
<div className="flex items-center justify-between h-14 px-4">
|
||||||
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent side="left" className="w-64 p-0">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="p-6 border-b">
|
||||||
|
<h2 className="text-xl font-bold">NoteFlow</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
|
<nav className="flex-1 p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{menuItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = location.pathname.startsWith(item.path);
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={item.path}
|
||||||
|
variant={isActive ? "secondary" : "ghost"}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={() => handleMenuClick(item.path)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 mr-3" />
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full justify-start text-red-600"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5 mr-3" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-semibold truncate flex-1 text-center">
|
||||||
|
{getCurrentTitle()}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-9 w-9">
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Mon compte</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => handleMenuClick("/mobile/settings")}>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Paramètres
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout} className="text-red-600">
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="h-[calc(100vh-3.5rem)] overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { MobileHeader } from "@/components/mobile/MobileHeader";
|
||||||
|
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Archive,
|
||||||
|
Trash2,
|
||||||
|
CheckSquare,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Paperclip,
|
||||||
|
Tag as TagIcon,
|
||||||
|
X,
|
||||||
|
MoreVertical
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import NotesService, { Note } from "@/services/NotesService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
|
||||||
|
export default function NoteDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [note, setNote] = useState<Note | null>(null);
|
||||||
|
const [addTodoModal, setAddTodoModal] = useState(false);
|
||||||
|
const [todoText, setTodoText] = useState("");
|
||||||
|
const [deleteModal, setDeleteModal] = useState(false);
|
||||||
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNote();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadNote = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const notes = await NotesService.getNotes();
|
||||||
|
const foundNote = notes.find(n => n.id === parseInt(id));
|
||||||
|
if (foundNote) {
|
||||||
|
setNote(foundNote);
|
||||||
|
} else {
|
||||||
|
showError("Note introuvable");
|
||||||
|
navigate("/mobile/notes");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors du chargement de la note");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNote = async (updates: Partial<Note>) => {
|
||||||
|
if (!note) return;
|
||||||
|
try {
|
||||||
|
const updated = { ...note, ...updates };
|
||||||
|
await NotesService.updateNote(updated);
|
||||||
|
setNote(updated);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la sauvegarde");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!note?.id) return;
|
||||||
|
try {
|
||||||
|
await NotesService.deleteNote(note.id);
|
||||||
|
showSuccess("Note supprimée");
|
||||||
|
navigate("/mobile/notes");
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddTodo = async () => {
|
||||||
|
if (!note || !todoText.trim()) return;
|
||||||
|
const newTodo = { text: todoText, completed: false };
|
||||||
|
const updatedTodos = [...(note.todos || []), newTodo];
|
||||||
|
await updateNote({ todos: updatedTodos });
|
||||||
|
setTodoText("");
|
||||||
|
setAddTodoModal(false);
|
||||||
|
showSuccess("Tâche ajoutée");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleTodo = async (index: number) => {
|
||||||
|
if (!note?.todos) return;
|
||||||
|
const updatedTodos = [...note.todos];
|
||||||
|
updatedTodos[index] = { ...updatedTodos[index], completed: !updatedTodos[index].completed };
|
||||||
|
await updateNote({ todos: updatedTodos });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTodo = async (index: number) => {
|
||||||
|
if (!note?.todos) return;
|
||||||
|
const updatedTodos = [...note.todos];
|
||||||
|
updatedTodos.splice(index, 1);
|
||||||
|
await updateNote({ todos: updatedTodos });
|
||||||
|
showSuccess("Tâche supprimée");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.files || !e.target.files[0] || !note?.id) return;
|
||||||
|
try {
|
||||||
|
const image = await NotesService.uploadImage(note.id, e.target.files[0]);
|
||||||
|
if (image) {
|
||||||
|
const updatedImages = [...(note.images || []), image];
|
||||||
|
setNote({ ...note, images: updatedImages });
|
||||||
|
showSuccess("Image ajoutée");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de l'upload");
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteImage = async (imageId: number, index: number) => {
|
||||||
|
if (!note?.id) return;
|
||||||
|
try {
|
||||||
|
await NotesService.deleteImage(note.id, imageId);
|
||||||
|
const updatedImages = (note.images || []).filter((_, i) => i !== index);
|
||||||
|
setNote({ ...note, images: updatedImages });
|
||||||
|
showSuccess("Image supprimée");
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!note) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||||
|
{/* Fixed Header */}
|
||||||
|
<div className="sticky top-0 z-20 bg-white dark:bg-gray-800 border-b">
|
||||||
|
<div className="flex items-center justify-between h-14 px-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate("/mobile/notes")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h1 className="text-lg font-semibold truncate flex-1 text-center">
|
||||||
|
{note.title || "Sans titre"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<MoreVertical className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => updateNote({ archived: !note.archived })}>
|
||||||
|
<Archive className="h-4 w-4 mr-2" />
|
||||||
|
{note.archived ? "Désarchiver" : "Archiver"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setDeleteModal(true)} className="text-red-600">
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Supprimer
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Titre de la note"
|
||||||
|
className="text-xl font-semibold border-none shadow-none px-0 focus-visible:ring-0"
|
||||||
|
value={note.title || ""}
|
||||||
|
onChange={(e) => updateNote({ title: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAddTodoModal(true)}
|
||||||
|
>
|
||||||
|
<CheckSquare className="h-4 w-4 mr-2" />
|
||||||
|
Tâche
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => document.getElementById('image-upload')?.click()}
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-4 w-4 mr-2" />
|
||||||
|
Image
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
id="image-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Todos */}
|
||||||
|
{note.todos && note.todos.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4 space-y-2">
|
||||||
|
{note.todos.map((todo, index) => (
|
||||||
|
<div key={index} className="flex items-center gap-3 p-2 border rounded">
|
||||||
|
<Checkbox
|
||||||
|
checked={todo.completed}
|
||||||
|
onCheckedChange={() => handleToggleTodo(index)}
|
||||||
|
/>
|
||||||
|
<span className={`flex-1 ${todo.completed ? 'line-through text-muted-foreground' : ''}`}>
|
||||||
|
{todo.text}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => handleDeleteTodo(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Images */}
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{note.images.map((image, index) => (
|
||||||
|
<div key={index} className="relative group">
|
||||||
|
<img
|
||||||
|
src={`/uploads/images/${image.filename}`}
|
||||||
|
alt={image.original_name}
|
||||||
|
className="w-full h-32 object-cover rounded-lg cursor-pointer"
|
||||||
|
onClick={() => setSelectedImage(`/uploads/images/${image.filename}`)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 h-7 w-7"
|
||||||
|
onClick={() => image.id && handleDeleteImage(image.id, index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rich Text Editor */}
|
||||||
|
<div className="min-h-[300px]">
|
||||||
|
<RichTextEditor
|
||||||
|
content={note.content || ""}
|
||||||
|
onChange={(content) => updateNote({ content })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Todo Modal */}
|
||||||
|
<Dialog open={addTodoModal} onOpenChange={setAddTodoModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajouter une tâche</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Texte de la tâche"
|
||||||
|
value={todoText}
|
||||||
|
onChange={(e) => setTodoText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAddTodo()}
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddTodoModal(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddTodo}>Ajouter</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={deleteModal} onOpenChange={setDeleteModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Supprimer la note ?</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteModal(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>
|
||||||
|
Supprimer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Image Preview */}
|
||||||
|
<Dialog open={!!selectedImage} onOpenChange={() => setSelectedImage(null)}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<img src={selectedImage || ''} alt="Preview" className="w-full h-auto" />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
||||||
|
import { MobileCard } from "@/components/mobile/MobileCard";
|
||||||
|
import { Plus, Search, CheckSquare, Image as ImageIcon, Paperclip, Tag as TagIcon, FileText } from "lucide-react";
|
||||||
|
import NotesService, { Note } from "@/services/NotesService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
|
||||||
|
const NOTES_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export default function NotesPage() {
|
||||||
|
const [notes, setNotes] = useState<Note[]>([]);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const loadNotes = async () => {
|
||||||
|
try {
|
||||||
|
const data = await NotesService.getNotes();
|
||||||
|
setNotes(data);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors du chargement des notes");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNotes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.priority && !b.priority) return -1;
|
||||||
|
if (!a.priority && b.priority) return 1;
|
||||||
|
return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const newNote = await NotesService.createNote("Nouvelle note", "");
|
||||||
|
navigate(`/mobile/notes/${newNote.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la création de la note");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePriority = async (note: Note, e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
const newPriority = !note.priority;
|
||||||
|
if (note.id) {
|
||||||
|
await NotesService.togglePriority(note.id, newPriority);
|
||||||
|
setNotes(notes.map(n => n.id === note.id ? { ...n, priority: newPriority } : n));
|
||||||
|
showSuccess(newPriority ? "Note marquée comme prioritaire" : "Priorité retirée");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la modification");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-24">
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="sticky top-0 z-20 bg-white dark:bg-gray-900 border-b p-4 space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher..."
|
||||||
|
className="pl-10"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Tabs */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant={!showArchived ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowArchived(false);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Actives ({notes.filter(n => !n.archived).length})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={showArchived ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowArchived(true);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Archivées ({notes.filter(n => n.archived).length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes List */}
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{paginatedNotes.length > 0 ? (
|
||||||
|
paginatedNotes.map(note => (
|
||||||
|
<MobileCard
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => navigate(`/mobile/notes/${note.id}`)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Priority Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={`absolute -top-1 -right-1 h-8 w-8 ${
|
||||||
|
note.priority ? 'opacity-100' : 'opacity-50'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => handleTogglePriority(note, e)}
|
||||||
|
>
|
||||||
|
<span className={`text-lg font-bold ${note.priority ? 'text-red-500' : 'text-muted-foreground'}`}>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h3 className="font-semibold mb-2 line-clamp-1 pr-8">
|
||||||
|
{note.title || "Sans titre"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||||
|
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex gap-2 flex-wrap mb-2">
|
||||||
|
{note.todos && note.todos.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<CheckSquare className="h-3 w-3 mr-1" />
|
||||||
|
{note.todos.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.images && note.images.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<ImageIcon className="h-3 w-3 mr-1" />
|
||||||
|
{note.images.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.files && note.files.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
<Paperclip className="h-3 w-3 mr-1" />
|
||||||
|
{note.files.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{note.tags && note.tags.length > 0 && (
|
||||||
|
note.tags.slice(0, 2).map((tag) => (
|
||||||
|
<Badge key={tag.id} variant="outline" className="text-xs">
|
||||||
|
<TagIcon className="h-3 w-3 mr-1" />
|
||||||
|
{tag.name}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{note.tags && note.tags.length > 2 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{note.tags.length - 2}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
{note.updated_at && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(note.updated_at).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground 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>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 px-4 pb-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
‹ Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page === totalPages - 1}
|
||||||
|
>
|
||||||
|
Suivant ›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<MobileFAB icon={Plus} onClick={handleCreateNote} label="Créer une note" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
||||||
|
import { MobileCard } from "@/components/mobile/MobileCard";
|
||||||
|
import { Plus, RefreshCw, ExternalLink, Rss as RssIcon } from "lucide-react";
|
||||||
|
import RssService, { RssArticle } from "@/services/RssService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
|
||||||
|
const ARTICLES_PER_PAGE = 10;
|
||||||
|
|
||||||
|
export default function RssPage() {
|
||||||
|
const [articles, setArticles] = useState<RssArticle[]>([]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [addFeedModal, setAddFeedModal] = useState(false);
|
||||||
|
const [feedUrl, setFeedUrl] = useState("");
|
||||||
|
const [feedTitle, setFeedTitle] = useState("");
|
||||||
|
|
||||||
|
const loadArticles = async () => {
|
||||||
|
try {
|
||||||
|
const data = await RssService.getArticles(100);
|
||||||
|
setArticles(data);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors du chargement des articles");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadArticles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await RssService.refreshFeeds();
|
||||||
|
await loadArticles();
|
||||||
|
showSuccess("Flux RSS actualisés");
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de l'actualisation");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddFeed = async () => {
|
||||||
|
if (!feedUrl.trim()) {
|
||||||
|
showError("L'URL est requise");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await RssService.addFeed(feedUrl, feedTitle || "Nouveau flux");
|
||||||
|
showSuccess("Flux RSS ajouté");
|
||||||
|
setFeedUrl("");
|
||||||
|
setFeedTitle("");
|
||||||
|
setAddFeedModal(false);
|
||||||
|
await handleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de l'ajout du flux");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(articles.length / ARTICLES_PER_PAGE);
|
||||||
|
const paginatedArticles = articles.slice(page * ARTICLES_PER_PAGE, (page + 1) * ARTICLES_PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 pb-24">
|
||||||
|
{/* Header Actions */}
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Actualiser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Articles List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{paginatedArticles.length > 0 ? (
|
||||||
|
paginatedArticles.map(article => (
|
||||||
|
<MobileCard
|
||||||
|
key={article.id}
|
||||||
|
onClick={() => article.link && window.open(article.link, '_blank')}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium mb-2 line-clamp-2">{article.title}</h4>
|
||||||
|
{article.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mb-2">
|
||||||
|
{article.description.replace(/<[^>]*>/g, '')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{article.pub_date && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{new Date(article.pub_date).toLocaleDateString('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ExternalLink className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<RssIcon className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="mb-4">Aucun article</p>
|
||||||
|
<Button onClick={() => setAddFeedModal(true)}>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Ajouter un flux RSS
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
‹ Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{page + 1} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page === totalPages - 1}
|
||||||
|
>
|
||||||
|
Suivant ›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<MobileFAB icon={Plus} onClick={() => setAddFeedModal(true)} label="Ajouter un flux RSS" />
|
||||||
|
|
||||||
|
{/* Add Feed Modal */}
|
||||||
|
<Dialog open={addFeedModal} onOpenChange={setAddFeedModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Ajouter un flux RSS</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="feed-url">URL du flux *</Label>
|
||||||
|
<Input
|
||||||
|
id="feed-url"
|
||||||
|
placeholder="https://example.com/feed.xml"
|
||||||
|
value={feedUrl}
|
||||||
|
onChange={(e) => setFeedUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="feed-title">Titre (optionnel)</Label>
|
||||||
|
<Input
|
||||||
|
id="feed-title"
|
||||||
|
placeholder="Nom du flux"
|
||||||
|
value={feedTitle}
|
||||||
|
onChange={(e) => setFeedTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddFeedModal(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddFeed}>Ajouter</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
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 {
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Calendar,
|
||||||
|
Rss,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
Zap,
|
||||||
|
ChevronRight
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTheme } from "@/components/theme-provider";
|
||||||
|
import AuthService from "@/services/AuthService";
|
||||||
|
import CalendarService from "@/services/CalendarService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [calendarStatus, setCalendarStatus] = useState<{ connected: boolean; email?: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCalendarStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadCalendarStatus = async () => {
|
||||||
|
try {
|
||||||
|
const status = await CalendarService.getAuthStatus();
|
||||||
|
setCalendarStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading calendar status:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
AuthService.logout();
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConnectCalendar = async () => {
|
||||||
|
try {
|
||||||
|
const authUrl = await CalendarService.getAuthUrl();
|
||||||
|
if (authUrl) {
|
||||||
|
window.location.href = authUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la connexion à Google Calendar");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-6 pb-24">
|
||||||
|
{/* Appearance */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
{theme === "dark" ? <Moon className="h-5 w-5" /> : <Sun className="h-5 w-5" />}
|
||||||
|
Apparence
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="dark-mode" className="text-base">
|
||||||
|
Mode sombre
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="dark-mode"
|
||||||
|
checked={theme === "dark"}
|
||||||
|
onCheckedChange={toggleTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Google Calendar */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Calendar className="h-5 w-5" />
|
||||||
|
Google Calendar
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{calendarStatus?.connected ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Connecté en tant que:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium mb-3">
|
||||||
|
{calendarStatus.email}
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="sm" className="w-full">
|
||||||
|
Déconnecter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Connectez votre compte Google pour synchroniser vos événements.
|
||||||
|
</p>
|
||||||
|
<Button onClick={handleConnectCalendar} className="w-full">
|
||||||
|
Connecter Google Calendar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RSS Feeds */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Rss className="h-5 w-5" />
|
||||||
|
Flux RSS
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-between"
|
||||||
|
onClick={() => navigate("/mobile/rss")}
|
||||||
|
>
|
||||||
|
Gérer les flux RSS
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* OpenRouter AI */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Zap className="h-5 w-5" />
|
||||||
|
OpenRouter AI
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
Configuration de l'intégration OpenRouter pour les fonctionnalités IA.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" className="w-full" disabled>
|
||||||
|
Configuration (bientôt disponible)
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Account */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<User className="h-5 w-5" />
|
||||||
|
Compte
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Déconnexion
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<div className="text-center text-sm text-muted-foreground pt-4">
|
||||||
|
<p>NoteFlow Mobile v1.0</p>
|
||||||
|
<p className="text-xs mt-1">© 2024 NoteFlow</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { MobileFAB } from "@/components/mobile/MobileFAB";
|
||||||
|
import { MobileCard } from "@/components/mobile/MobileCard";
|
||||||
|
import { Plus, Trash2, CheckSquare } from "lucide-react";
|
||||||
|
import TodosService, { Todo } from "@/services/TodosService";
|
||||||
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
|
|
||||||
|
const TODOS_PER_PAGE = 15;
|
||||||
|
|
||||||
|
export default function TodosPage() {
|
||||||
|
const [todos, setTodos] = useState<Todo[]>([]);
|
||||||
|
const [activePage, setActivePage] = useState(0);
|
||||||
|
const [completedPage, setCompletedPage] = useState(0);
|
||||||
|
const [addModal, setAddModal] = useState(false);
|
||||||
|
const [todoText, setTodoText] = useState("");
|
||||||
|
|
||||||
|
const loadTodos = async () => {
|
||||||
|
try {
|
||||||
|
const data = await TodosService.getTodos();
|
||||||
|
setTodos(data);
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors du chargement des tâches");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTodos();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await TodosService.toggleComplete(id);
|
||||||
|
setTodos(todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t));
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la modification");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await TodosService.deleteTodo(id);
|
||||||
|
setTodos(todos.filter(t => t.id !== id));
|
||||||
|
showSuccess("Tâche supprimée");
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la suppression");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!todoText.trim()) return;
|
||||||
|
try {
|
||||||
|
const newTodo = await TodosService.createTodo(todoText, 'medium');
|
||||||
|
setTodos([newTodo, ...todos]);
|
||||||
|
setTodoText("");
|
||||||
|
setAddModal(false);
|
||||||
|
showSuccess("Tâche ajoutée");
|
||||||
|
} catch (error) {
|
||||||
|
showError("Erreur lors de la création");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const activeTodos = todos.filter(t => !t.completed);
|
||||||
|
const completedTodos = todos.filter(t => t.completed);
|
||||||
|
|
||||||
|
const totalActivePages = Math.ceil(activeTodos.length / TODOS_PER_PAGE);
|
||||||
|
const totalCompletedPages = Math.ceil(completedTodos.length / TODOS_PER_PAGE);
|
||||||
|
|
||||||
|
const paginatedActive = activeTodos.slice(activePage * TODOS_PER_PAGE, (activePage + 1) * TODOS_PER_PAGE);
|
||||||
|
const paginatedCompleted = completedTodos.slice(completedPage * TODOS_PER_PAGE, (completedPage + 1) * TODOS_PER_PAGE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 pb-24">
|
||||||
|
<Tabs defaultValue="active" className="w-full">
|
||||||
|
<TabsList className="grid grid-cols-2 w-full mb-4">
|
||||||
|
<TabsTrigger value="active">
|
||||||
|
Actives ({activeTodos.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="completed">
|
||||||
|
Complétées ({completedTodos.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Active Todos */}
|
||||||
|
<TabsContent value="active" className="space-y-3 mt-0">
|
||||||
|
{paginatedActive.length > 0 ? (
|
||||||
|
paginatedActive.map(todo => (
|
||||||
|
<MobileCard key={todo.id}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={false}
|
||||||
|
onCheckedChange={() => todo.id && handleToggle(todo.id)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">{todo.text}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 flex-shrink-0"
|
||||||
|
onClick={() => todo.id && handleDelete(todo.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<CheckSquare className="h-16 w-16 mx-auto mb-4 opacity-50" />
|
||||||
|
<p className="mb-4">Aucune tâche active</p>
|
||||||
|
<Button onClick={() => setAddModal(true)}>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Ajouter une tâche
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalActivePages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActivePage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={activePage === 0}
|
||||||
|
>
|
||||||
|
‹ Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{activePage + 1} / {totalActivePages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActivePage(p => Math.min(totalActivePages - 1, p + 1))}
|
||||||
|
disabled={activePage === totalActivePages - 1}
|
||||||
|
>
|
||||||
|
Suivant ›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Completed Todos */}
|
||||||
|
<TabsContent value="completed" className="space-y-3 mt-0">
|
||||||
|
{paginatedCompleted.length > 0 ? (
|
||||||
|
paginatedCompleted.map(todo => (
|
||||||
|
<MobileCard key={todo.id}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={true}
|
||||||
|
onCheckedChange={() => todo.id && handleToggle(todo.id)}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 line-through text-muted-foreground">
|
||||||
|
{todo.text}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 flex-shrink-0"
|
||||||
|
onClick={() => todo.id && handleDelete(todo.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</MobileCard>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-muted-foreground py-16">
|
||||||
|
<p>Aucune tâche complétée</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalCompletedPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-4 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCompletedPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={completedPage === 0}
|
||||||
|
>
|
||||||
|
‹ Précédent
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{completedPage + 1} / {totalCompletedPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCompletedPage(p => Math.min(totalCompletedPages - 1, p + 1))}
|
||||||
|
disabled={completedPage === totalCompletedPages - 1}
|
||||||
|
>
|
||||||
|
Suivant ›
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* FAB */}
|
||||||
|
<MobileFAB icon={Plus} onClick={() => setAddModal(true)} label="Ajouter une tâche" />
|
||||||
|
|
||||||
|
{/* Add Modal */}
|
||||||
|
<Dialog open={addModal} onOpenChange={setAddModal}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Nouvelle tâche</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<Input
|
||||||
|
placeholder="Texte de la tâche"
|
||||||
|
value={todoText}
|
||||||
|
onChange={(e) => setTodoText(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddModal(false)}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAdd}>Ajouter</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user