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:
LogiFlow authored and GitHub committed 2025-11-17 14:30:29 +01:00
commit 0359ebe22a
11 files changed
+1664

No files matched your search

+54
View File
@@ -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>
+42
View File
@@ -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>
);
};
+21
View File
@@ -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>
);
};
+54
View File
@@ -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>
);
};
+186
View File
@@ -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>
);
}
+138
View File
@@ -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>
);
}
+326
View File
@@ -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>
);
}
+243
View File
@@ -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>
);
}
+189
View File
@@ -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>
);
}
+183
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}