mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +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 { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import Index from "./pages/Index";
|
||||
import Login from "./pages/Login";
|
||||
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 "./globals.css";
|
||||
|
||||
@@ -22,6 +30,22 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
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 [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
@@ -56,14 +80,44 @@ const App = () => {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Auto-redirect root to mobile or desktop */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AutoRedirect />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Desktop route */}
|
||||
<Route
|
||||
path="/desktop"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Index />
|
||||
</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 />} />
|
||||
</Routes>
|
||||
</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