Merge pull request #28 from R0m1k3/claude/fix-npm-ci-build-error-011CV3c2K4ETHdBRQUs7tkJf

Feature: Implémentation complète de NoteFlow avec toutes les fonction…
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-12 09:10:26 +01:00
commit 68b8668968
8 files changed
+956 -143

No files matched your search

+2 -1
View File
@@ -2,8 +2,9 @@
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Notes App</title> <title>NoteFlow - Notes et gestion de tâches</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "noteflow", "name": "noteflow",
"version": "1.0.0", "version": "1.0.0",
"description": "Application web de notes et todo list moderne avec Docker", "description": "Application NoteFlow - Notes et gestion de tâches moderne avec Docker",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+173
View File
@@ -0,0 +1,173 @@
import { useRef, useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Bold,
Italic,
Underline,
List,
ListOrdered,
Heading1,
Heading2,
Heading3,
AlignLeft,
AlignCenter,
AlignRight,
Code,
Quote,
Link as LinkIcon
} from 'lucide-react';
interface RichTextEditorProps {
content: string;
onChange: (content: string) => void;
placeholder?: string;
}
export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const [selectedFormat, setSelectedFormat] = useState<string[]>([]);
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== content) {
editorRef.current.innerHTML = content;
}
}, [content]);
const execCommand = (command: string, value?: string) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
updateContent();
};
const updateContent = () => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
// Gestion des raccourcis clavier
if (e.ctrlKey || e.metaKey) {
switch (e.key.toLowerCase()) {
case 'b':
e.preventDefault();
execCommand('bold');
break;
case 'i':
e.preventDefault();
execCommand('italic');
break;
case 'u':
e.preventDefault();
execCommand('underline');
break;
}
}
};
const insertLink = () => {
const url = prompt('Entrez l\'URL:');
if (url) {
execCommand('createLink', url);
}
};
const formatButton = (
icon: React.ReactNode,
command: string,
value?: string,
tooltip?: string
) => (
<Button
variant="ghost"
size="sm"
onClick={() => execCommand(command, value)}
title={tooltip}
className="h-8 w-8 p-0"
>
{icon}
</Button>
);
return (
<div className="space-y-2">
{/* Barre d'outils */}
<Card>
<CardContent className="p-2">
<div className="flex flex-wrap items-center gap-1">
{/* Titres */}
<div className="flex items-center gap-1">
{formatButton(<Heading1 className="h-4 w-4" />, 'formatBlock', '<h1>', 'Titre 1')}
{formatButton(<Heading2 className="h-4 w-4" />, 'formatBlock', '<h2>', 'Titre 2')}
{formatButton(<Heading3 className="h-4 w-4" />, 'formatBlock', '<h3>', 'Titre 3')}
</div>
<Separator orientation="vertical" className="h-6" />
{/* Formatage de texte */}
<div className="flex items-center gap-1">
{formatButton(<Bold className="h-4 w-4" />, 'bold', undefined, 'Gras (Ctrl+B)')}
{formatButton(<Italic className="h-4 w-4" />, 'italic', undefined, 'Italique (Ctrl+I)')}
{formatButton(<Underline className="h-4 w-4" />, 'underline', undefined, 'Souligné (Ctrl+U)')}
</div>
<Separator orientation="vertical" className="h-6" />
{/* Listes */}
<div className="flex items-center gap-1">
{formatButton(<List className="h-4 w-4" />, 'insertUnorderedList', undefined, 'Liste à puces')}
{formatButton(<ListOrdered className="h-4 w-4" />, 'insertOrderedList', undefined, 'Liste numérotée')}
</div>
<Separator orientation="vertical" className="h-6" />
{/* Alignement */}
<div className="flex items-center gap-1">
{formatButton(<AlignLeft className="h-4 w-4" />, 'justifyLeft', undefined, 'Aligner à gauche')}
{formatButton(<AlignCenter className="h-4 w-4" />, 'justifyCenter', undefined, 'Centrer')}
{formatButton(<AlignRight className="h-4 w-4" />, 'justifyRight', undefined, 'Aligner à droite')}
</div>
<Separator orientation="vertical" className="h-6" />
{/* Autres */}
<div className="flex items-center gap-1">
{formatButton(<Quote className="h-4 w-4" />, 'formatBlock', '<blockquote>', 'Citation')}
{formatButton(<Code className="h-4 w-4" />, 'formatBlock', '<pre>', 'Code')}
<Button
variant="ghost"
size="sm"
onClick={insertLink}
title="Insérer un lien"
className="h-8 w-8 p-0"
>
<LinkIcon className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Zone d'édition */}
<Card>
<CardContent className="p-4">
<div
ref={editorRef}
contentEditable
onInput={updateContent}
onKeyDown={handleKeyDown}
className="min-h-[400px] outline-none prose prose-sm max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{ __html: content }}
data-placeholder={placeholder || 'Commencez à écrire...'}
style={{
fontSize: '16px',
lineHeight: '1.6'
}}
/>
</CardContent>
</Card>
</div>
);
}
+547 -141
View File
@@ -1,18 +1,29 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label";
import { RichTextEditor } from "@/components/RichTextEditor";
import { MadeWithDyad } from "@/components/made-with-dyad"; import { MadeWithDyad } from "@/components/made-with-dyad";
import { PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2, Image, CheckSquare } from "lucide-react"; import {
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
Image, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X
} from "lucide-react";
import AuthService from "@/services/AuthService"; import AuthService from "@/services/AuthService";
import AdminService from "@/services/AdminService"; import AdminService from "@/services/AdminService";
import NotesService, { Note } from "@/services/NotesService"; import NotesService, { Note } from "@/services/NotesService";
import TodosService, { Todo } from "@/services/TodosService";
import RssService, { RssFeed, RssArticle } from "@/services/RssService";
import SettingsService, { Settings as AppSettings } from "@/services/SettingsService";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { showError } from "@/utils/toast"; import { showError, showSuccess } from "@/utils/toast";
interface UserType { interface UserType {
id: number; id: number;
@@ -28,17 +39,21 @@ const Index = () => {
const [notes, setNotes] = useState<Note[]>([]); const [notes, setNotes] = useState<Note[]>([]);
const [currentNote, setCurrentNote] = useState<Note | null>(null); const [currentNote, setCurrentNote] = useState<Note | null>(null);
const [users, setUsers] = useState<UserType[]>([]); const [users, setUsers] = useState<UserType[]>([]);
const [todos, setTodos] = useState<Todo[]>([]);
const [rssFeeds, setRssFeeds] = useState<RssFeed[]>([]);
const [rssArticles, setRssArticles] = useState<RssArticle[]>([]);
const [settings, setSettings] = useState<AppSettings>({});
const [adminTab, setAdminTab] = useState("users");
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
// Récupérer l'utilisateur et vérifier l'authentification
const userFromAuth = AuthService.getUser(); const userFromAuth = AuthService.getUser();
if (userFromAuth) { if (userFromAuth) {
setUser(userFromAuth); setUser(userFromAuth);
loadNotes().catch(err => { loadNotes();
console.error("Erreur lors du chargement des notes:", err); loadTodos();
showError("Erreur lors du chargement des notes"); loadRssFeeds();
}); loadRssArticles();
} else { } else {
navigate("/login"); navigate("/login");
} }
@@ -55,7 +70,39 @@ const Index = () => {
} }
} catch (error) { } catch (error) {
console.error("Erreur lors du chargement des notes:", error); console.error("Erreur lors du chargement des notes:", error);
throw error; }
};
const loadTodos = async () => {
try {
const fetchedTodos = await TodosService.getTodos();
if (Array.isArray(fetchedTodos)) {
setTodos(fetchedTodos);
}
} catch (error) {
console.error("Erreur lors du chargement des todos:", error);
}
};
const loadRssFeeds = async () => {
try {
const feeds = await RssService.getFeeds();
if (Array.isArray(feeds)) {
setRssFeeds(feeds);
}
} catch (error) {
console.error("Erreur lors du chargement des flux RSS:", error);
}
};
const loadRssArticles = async () => {
try {
const articles = await RssService.getArticles(10);
if (Array.isArray(articles)) {
setRssArticles(articles);
}
} catch (error) {
console.error("Erreur lors du chargement des articles RSS:", error);
} }
}; };
@@ -68,11 +115,28 @@ const Index = () => {
} }
} catch (error) { } catch (error) {
console.error("Erreur lors du chargement des utilisateurs:", error); console.error("Erreur lors du chargement des utilisateurs:", error);
showError("Erreur lors du chargement des utilisateurs");
} }
} }
}; };
const loadSettings = async () => {
if (user?.is_admin) {
try {
const fetchedSettings = await SettingsService.getSettings();
setSettings(fetchedSettings || {});
} catch (error) {
console.error("Erreur lors du chargement des paramètres:", error);
}
}
};
useEffect(() => {
if (showAdmin) {
loadUsers();
loadSettings();
}
}, [showAdmin]);
const handleLogout = () => { const handleLogout = () => {
AuthService.logout(); AuthService.logout();
navigate("/login"); navigate("/login");
@@ -86,7 +150,6 @@ const Index = () => {
setCurrentNote(newNote); setCurrentNote(newNote);
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la création de la note:", error);
showError("Erreur lors de la création de la note"); showError("Erreur lors de la création de la note");
} }
}; };
@@ -99,7 +162,8 @@ const Index = () => {
...currentNote, ...currentNote,
...updatedFields, ...updatedFields,
todos: currentNote.todos || [], todos: currentNote.todos || [],
images: currentNote.images || [] images: currentNote.images || [],
files: currentNote.files || []
}; };
const success = await NotesService.updateNote(updatedNote); const success = await NotesService.updateNote(updatedNote);
@@ -109,7 +173,6 @@ const Index = () => {
setNotes(notes.map(note => note.id === updatedNote.id ? updatedNote : note)); setNotes(notes.map(note => note.id === updatedNote.id ? updatedNote : note));
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la mise à jour de la note:", error);
showError("Erreur lors de la mise à jour de la note"); showError("Erreur lors de la mise à jour de la note");
} }
}; };
@@ -125,58 +188,100 @@ const Index = () => {
const updatedNotes = notes.filter(note => note.id !== currentNote.id); const updatedNotes = notes.filter(note => note.id !== currentNote.id);
setNotes(updatedNotes); setNotes(updatedNotes);
setCurrentNote(updatedNotes.length > 0 ? updatedNotes[0] : null); setCurrentNote(updatedNotes.length > 0 ? updatedNotes[0] : null);
showSuccess("Note supprimée");
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la suppression de la note:", error); showError("Erreur lors de la suppression");
showError("Erreur lors de la suppression de la note");
} }
} }
}; };
const handleAddTodo = async () => {
const text = prompt("Nouvelle tâche:");
if (text) {
try {
const newTodo = await TodosService.createTodo(text);
if (newTodo) {
setTodos([newTodo, ...todos]);
showSuccess("Tâche ajoutée");
}
} catch (error) {
showError("Erreur lors de l'ajout de la tâche");
}
}
};
const handleToggleTodo = async (todoId: number) => {
try {
await TodosService.toggleComplete(todoId);
setTodos(todos.map(t => t.id === todoId ? { ...t, completed: !t.completed } : t));
} catch (error) {
showError("Erreur lors de la mise à jour");
}
};
const handleDeleteTodo = async (todoId: number) => {
try {
await TodosService.deleteTodo(todoId);
setTodos(todos.filter(t => t.id !== todoId));
showSuccess("Tâche supprimée");
} catch (error) {
showError("Erreur lors de la suppression");
}
};
const handleAddRssFeed = async () => {
const url = prompt("URL du flux RSS:");
if (url) {
try {
const newFeed = await RssService.addFeed(url);
if (newFeed) {
setRssFeeds([...rssFeeds, newFeed]);
showSuccess("Flux RSS ajouté");
loadRssArticles();
}
} catch (error) {
showError("Erreur lors de l'ajout du flux");
}
}
};
const handleDeleteRssFeed = async (feedId: number) => {
try {
await RssService.deleteFeed(feedId);
setRssFeeds(rssFeeds.filter(f => f.id !== feedId));
showSuccess("Flux supprimé");
loadRssArticles();
} catch (error) {
showError("Erreur lors de la suppression");
}
};
const handleRefreshRss = async () => {
try {
await RssService.refreshFeeds();
loadRssArticles();
showSuccess("Flux actualisés");
} catch (error) {
showError("Erreur lors de l'actualisation");
}
};
const handleAddUser = async () => { const handleAddUser = async () => {
const username = prompt("Nom d'utilisateur:"); const username = prompt("Nom d'utilisateur:");
if (!username) return; if (!username) return;
const password = prompt("Mot de passe:"); const password = prompt("Mot de passe:");
if (!password) return; if (!password) return;
const isAdmin = window.confirm("Donner les droits administrateur ?"); const isAdmin = window.confirm("Donner les droits administrateur ?");
try { try {
const newUser = await AdminService.createUser(username, password, isAdmin); const newUser = await AdminService.createUser(username, password, isAdmin);
if (newUser) { if (newUser) {
loadUsers(); loadUsers();
showSuccess("Utilisateur créé");
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la création de l'utilisateur:", error); showError("Erreur lors de la création");
showError("Erreur lors de la création de l'utilisateur");
}
};
const handleChangePassword = async (userId: number) => {
const password = prompt("Nouveau mot de passe:");
if (!password) return;
try {
const success = await AdminService.updateUserPassword(userId, password);
if (success) {
loadUsers();
}
} catch (error) {
console.error("Erreur lors de la modification du mot de passe:", error);
showError("Erreur lors de la modification du mot de passe");
}
};
const handleToggleAdmin = async (userId: number, currentStatus: boolean) => {
try {
const success = await AdminService.toggleAdminStatus(userId, currentStatus);
if (success) {
loadUsers();
}
} catch (error) {
console.error("Erreur lors de la modification des droits:", error);
showError("Erreur lors de la modification des droits");
} }
}; };
@@ -186,20 +291,50 @@ const Index = () => {
const success = await AdminService.deleteUser(userId); const success = await AdminService.deleteUser(userId);
if (success) { if (success) {
loadUsers(); loadUsers();
showSuccess("Utilisateur supprimé");
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la suppression de l'utilisateur:", error); showError("Erreur lors de la suppression");
showError("Erreur lors de la suppression de l'utilisateur");
} }
} }
}; };
// Charger les utilisateurs quand le panel admin s'ouvre const handleSaveSettings = async () => {
useEffect(() => { try {
if (showAdmin) { await SettingsService.updateSettings(settings);
loadUsers(); showSuccess("Paramètres enregistrés");
} catch (error) {
showError("Erreur lors de l'enregistrement");
} }
}, [showAdmin]); };
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0] && currentNote?.id) {
const formData = new FormData();
formData.append('file', e.target.files[0]);
try {
const response = await fetch(`/api/notes/${currentNote.id}/files`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: formData
});
if (response.ok) {
const file = await response.json();
const updatedFiles = [...(currentNote.files || []), file];
handleUpdateNote({ files: updatedFiles });
showSuccess("Fichier ajouté");
}
} catch (error) {
showError("Erreur lors de l'upload");
} finally {
e.target.value = '';
}
}
};
const filteredNotes = notes.filter(note => const filteredNotes = notes.filter(note =>
(activeTab === "all" || (activeTab === "archived" && note.archived)) && (activeTab === "all" || (activeTab === "archived" && note.archived)) &&
@@ -216,20 +351,9 @@ const Index = () => {
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"> <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<svg <FileText className="h-4 w-4 text-primary" />
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4 text-primary"
>
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"></path>
</svg>
</div> </div>
<span className="text-xl font-semibold">Notes App</span> <span className="text-xl font-semibold">NoteFlow</span>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -269,8 +393,8 @@ const Index = () => {
{/* Main Content */} {/* Main Content */}
<div className="max-w-screen-2xl mx-auto px-4 py-6"> <div className="max-w-screen-2xl mx-auto px-4 py-6">
<div className="grid grid-cols-1 md:grid-cols-[300px_1fr] gap-6"> <div className="grid grid-cols-1 lg:grid-cols-[350px_1fr_300px] gap-6">
{/* Sidebar */} {/* Left Sidebar - Notes */}
<div className="space-y-6"> <div className="space-y-6">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -299,7 +423,7 @@ const Index = () => {
</TabsList> </TabsList>
</Tabs> </Tabs>
<div className="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto"> <div className="space-y-2 max-h-[calc(100vh-340px)] overflow-y-auto">
{filteredNotes.length === 0 ? ( {filteredNotes.length === 0 ? (
<p className="text-center text-muted-foreground py-4">Aucune note trouvée</p> <p className="text-center text-muted-foreground py-4">Aucune note trouvée</p>
) : ( ) : (
@@ -310,10 +434,30 @@ const Index = () => {
onClick={() => setCurrentNote(note)} onClick={() => setCurrentNote(note)}
> >
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="font-medium">{note.title || "Sans titre"}</h3> <h3 className="font-medium truncate">{note.title || "Sans titre"}</h3>
<p className="text-sm text-muted-foreground line-clamp-2"> <p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{note.content ? note.content.replace(/<[^>]*>/g, '') : ""} {note.content ? note.content.replace(/<[^>]*>/g, '') : ""}
</p> </p>
<div className="flex gap-2 mt-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">
<Image 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>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
)) ))
@@ -321,7 +465,7 @@ const Index = () => {
</div> </div>
</div> </div>
{/* Note Editor */} {/* Center - Note Editor */}
{currentNote ? ( {currentNote ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -354,28 +498,37 @@ const Index = () => {
<Tabs defaultValue="content"> <Tabs defaultValue="content">
<TabsList> <TabsList>
<TabsTrigger value="content">Contenu</TabsTrigger> <TabsTrigger value="content">Contenu</TabsTrigger>
<TabsTrigger value="todos">Tâches</TabsTrigger> <TabsTrigger value="todos">
<TabsTrigger value="images">Images</TabsTrigger> Tâches
{currentNote.todos && currentNote.todos.length > 0 && (
<Badge variant="secondary" className="ml-2">{currentNote.todos.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="images">
Images
{currentNote.images && currentNote.images.length > 0 && (
<Badge variant="secondary" className="ml-2">{currentNote.images.length}</Badge>
)}
</TabsTrigger>
<TabsTrigger value="files">
Fichiers
{currentNote.files && currentNote.files.length > 0 && (
<Badge variant="secondary" className="ml-2">{currentNote.files.length}</Badge>
)}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="content" className="mt-4"> <TabsContent value="content" className="mt-4">
<Card> <RichTextEditor
<CardContent className="p-4"> content={currentNote.content || ""}
<div onChange={(content) => handleUpdateNote({ content })}
className="min-h-[500px] outline-none" placeholder="Commencez à écrire votre note..."
contentEditable
suppressContentEditableWarning
dangerouslySetInnerHTML={{ __html: currentNote.content || "" }}
onBlur={(e) => handleUpdateNote({ content: e.currentTarget.innerHTML })}
/> />
</CardContent>
</Card>
</TabsContent> </TabsContent>
<TabsContent value="todos" className="mt-4"> <TabsContent value="todos" className="mt-4">
<Card> <Card>
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -391,29 +544,26 @@ const Index = () => {
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Ajouter une tâche Ajouter une tâche
</Button> </Button>
</div>
<div className="space-y-2"> <div className="space-y-2">
{currentNote.todos && currentNote.todos.length > 0 ? ( {currentNote.todos && currentNote.todos.length > 0 ? (
currentNote.todos.map((todo, index) => ( currentNote.todos.map((todo, index) => (
<div key={index} className="flex items-center gap-2 p-2 border rounded-md"> <div key={index} className="flex items-center gap-2 p-2 border rounded-md">
<input <Checkbox
type="checkbox"
checked={todo.completed} checked={todo.completed}
onChange={() => { onCheckedChange={() => {
const updatedTodos = [...(currentNote.todos || [])]; const updatedTodos = [...(currentNote.todos || [])];
updatedTodos[index].completed = !updatedTodos[index].completed; updatedTodos[index].completed = !updatedTodos[index].completed;
handleUpdateNote({ todos: updatedTodos }); handleUpdateNote({ todos: updatedTodos });
}} }}
className="h-4 w-4"
/> />
<span className={todo.completed ? "line-through text-muted-foreground" : ""}> <span className={todo.completed ? "line-through text-muted-foreground flex-1" : "flex-1"}>
{todo.text} {todo.text}
</span> </span>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="ml-auto h-6 w-6" className="h-6 w-6"
onClick={() => { onClick={() => {
const updatedTodos = [...(currentNote.todos || [])]; const updatedTodos = [...(currentNote.todos || [])];
updatedTodos.splice(index, 1); updatedTodos.splice(index, 1);
@@ -425,7 +575,7 @@ const Index = () => {
</div> </div>
)) ))
) : ( ) : (
<p className="text-center text-muted-foreground py-4">Aucune tâche</p> <p className="text-center text-muted-foreground py-8">Aucune tâche dans cette note</p>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -435,13 +585,13 @@ const Index = () => {
<TabsContent value="images" className="mt-4"> <TabsContent value="images" className="mt-4">
<Card> <Card>
<CardContent className="p-4 space-y-4"> <CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2"> <label htmlFor="image-upload">
<label htmlFor="image-upload" className="cursor-pointer">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
type="button" type="button"
onClick={() => document.getElementById('image-upload')?.click()}
> >
<Image className="h-4 w-4" /> <Image className="h-4 w-4" />
Ajouter une image Ajouter une image
@@ -458,10 +608,10 @@ const Index = () => {
if (image) { if (image) {
const updatedImages = [...(currentNote.images || []), image]; const updatedImages = [...(currentNote.images || []), image];
handleUpdateNote({ images: updatedImages }); handleUpdateNote({ images: updatedImages });
showSuccess("Image ajoutée");
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de l'upload de l'image:", error); showError("Erreur lors de l'upload");
showError("Erreur lors de l'upload de l'image");
} finally { } finally {
e.target.value = ''; e.target.value = '';
} }
@@ -469,21 +619,20 @@ const Index = () => {
}} }}
/> />
</label> </label>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{currentNote.images && currentNote.images.length > 0 ? ( {currentNote.images && currentNote.images.length > 0 ? (
currentNote.images.map((image, index) => ( currentNote.images.map((image, index) => (
<div key={index} className="relative aspect-square rounded-md overflow-hidden border"> <div key={index} className="relative aspect-square rounded-md overflow-hidden border group">
<img <img
src={`/uploads/${image.filename}`} src={`/uploads/${image.filename}`}
alt={`Note image ${index}`} alt={`Image ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<Button <Button
variant="destructive" variant="destructive"
size="icon" size="icon"
className="absolute top-1 right-1 h-6 w-6 rounded-full" className="absolute top-1 right-1 h-6 w-6 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={async () => { onClick={async () => {
if (image.id && currentNote.id) { if (image.id && currentNote.id) {
try { try {
@@ -491,20 +640,99 @@ const Index = () => {
if (success) { if (success) {
const updatedImages = (currentNote.images || []).filter((_, i) => i !== index); const updatedImages = (currentNote.images || []).filter((_, i) => i !== index);
handleUpdateNote({ images: updatedImages }); handleUpdateNote({ images: updatedImages });
showSuccess("Image supprimée");
} }
} catch (error) { } catch (error) {
console.error("Erreur lors de la suppression de l'image:", error); showError("Erreur lors de la suppression");
showError("Erreur lors de la suppression de l'image");
} }
} }
}} }}
> >
<Trash2 className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
)) ))
) : ( ) : (
<p className="col-span-full text-center text-muted-foreground py-4">Aucune image</p> <p className="col-span-full text-center text-muted-foreground py-8">Aucune image</p>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="files" className="mt-4">
<Card>
<CardContent className="p-4 space-y-4">
<label htmlFor="file-upload">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
type="button"
onClick={() => document.getElementById('file-upload')?.click()}
>
<Paperclip className="h-4 w-4" />
Ajouter un fichier
</Button>
<input
id="file-upload"
type="file"
className="hidden"
onChange={handleFileUpload}
/>
</label>
<div className="space-y-2">
{currentNote.files && currentNote.files.length > 0 ? (
currentNote.files.map((file: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 border rounded-md hover:bg-accent/50 transition-colors group">
<div className="flex items-center gap-2 flex-1">
<Paperclip className="h-4 w-4 text-muted-foreground" />
<span className="text-sm truncate">{file.filename || file.original_name}</span>
<Badge variant="secondary" className="text-xs">
{file.size ? `${(file.size / 1024).toFixed(1)} KB` : ''}
</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => window.open(`/uploads/${file.filename}`, '_blank')}
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={async () => {
if (file.id && currentNote.id) {
try {
const response = await fetch(`/api/notes/${currentNote.id}/files/${file.id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
const updatedFiles = (currentNote.files || []).filter((_, i) => i !== index);
handleUpdateNote({ files: updatedFiles });
showSuccess("Fichier supprimé");
}
} catch (error) {
showError("Erreur lors de la suppression");
}
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
) : (
<p className="text-center text-muted-foreground py-8">Aucun fichier attaché</p>
)} )}
</div> </div>
</CardContent> </CardContent>
@@ -513,24 +741,126 @@ const Index = () => {
</Tabs> </Tabs>
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-[calc(100vh-200px)]">
<div className="text-center"> <div className="text-center">
<p className="text-muted-foreground">Aucune note sélectionnée</p> <FileText className="h-16 w-16 mx-auto text-muted-foreground mb-4" />
<Button <p className="text-muted-foreground mb-4">Aucune note sélectionnée</p>
className="mt-4" <Button onClick={handleCreateNote}>
onClick={handleCreateNote} <Plus className="h-4 w-4 mr-2" />
>
Créer une note Créer une note
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{/* Right Sidebar - Todos & RSS */}
<div className="space-y-6">
{/* Todos Box */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<CheckSquare className="h-5 w-5" />
Tâches
</CardTitle>
<Button size="sm" variant="ghost" onClick={handleAddTodo}>
<Plus className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{todos.length > 0 ? (
todos.map(todo => (
<div key={todo.id} className="flex items-center gap-2 p-2 border rounded-md hover:bg-accent/50 transition-colors group">
<Checkbox
checked={todo.completed}
onCheckedChange={() => todo.id && handleToggleTodo(todo.id)}
/>
<span className={`text-sm flex-1 ${todo.completed ? 'line-through text-muted-foreground' : ''}`}>
{todo.text}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => todo.id && handleDeleteTodo(todo.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))
) : (
<p className="text-sm text-center text-muted-foreground py-4">Aucune tâche</p>
)}
</div>
</CardContent>
</Card>
{/* RSS Box */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Rss className="h-5 w-5" />
Flux RSS
</CardTitle>
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={handleRefreshRss}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={handleAddRssFeed}>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{rssArticles.length > 0 ? (
rssArticles.map(article => (
<div
key={article.id}
className="p-2 border rounded-md hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => article.link && window.open(article.link, '_blank')}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium line-clamp-2 mb-1">{article.title}</h4>
{article.description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{article.description.replace(/<[^>]*>/g, '')}
</p>
)}
</div>
<ExternalLink className="h-3 w-3 text-muted-foreground flex-shrink-0 mt-1" />
</div>
</div>
))
) : (
<p className="text-sm text-center text-muted-foreground py-4">
Aucun article
<br />
<Button
variant="link"
size="sm"
className="mt-2"
onClick={handleAddRssFeed}
>
Ajouter un flux RSS
</Button>
</p>
)}
</div>
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
{/* Admin Modal */} {/* Admin Modal */}
<Dialog open={showAdmin} onOpenChange={setShowAdmin}> <Dialog open={showAdmin} onOpenChange={setShowAdmin}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Administration</DialogTitle> <DialogTitle>Administration</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -538,9 +868,11 @@ const Index = () => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="users"> <Tabs value={adminTab} onValueChange={setAdminTab}>
<TabsList className="grid grid-cols-2 w-full"> <TabsList className="grid grid-cols-4 w-full">
<TabsTrigger value="users">Utilisateurs</TabsTrigger> <TabsTrigger value="users">Utilisateurs</TabsTrigger>
<TabsTrigger value="rss">Flux RSS</TabsTrigger>
<TabsTrigger value="openrouter">OpenRouter</TabsTrigger>
<TabsTrigger value="settings">Paramètres</TabsTrigger> <TabsTrigger value="settings">Paramètres</TabsTrigger>
</TabsList> </TabsList>
@@ -554,13 +886,12 @@ const Index = () => {
</Button> </Button>
<div className="space-y-2"> <div className="space-y-2">
{users.length > 0 ? ( {users.map(user => (
users.map(user => (
<Card key={user.id}> <Card key={user.id}>
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar> <Avatar>
<AvatarFallback>{user.username ? user.username.charAt(0).toUpperCase() : "U"}</AvatarFallback> <AvatarFallback>{user.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<p className="font-medium">{user.username}</p> <p className="font-medium">{user.username}</p>
@@ -569,23 +900,7 @@ const Index = () => {
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
{user.id !== 1 && ( {user.id !== 1 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => handleChangePassword(user.id)}
>
Mot de passe
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleToggleAdmin(user.id, user.is_admin)}
>
{user.is_admin ? "Retirer admin" : "Rendre admin"}
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
@@ -593,22 +908,113 @@ const Index = () => {
> >
Supprimer Supprimer
</Button> </Button>
</>
)} )}
</div>
</CardContent> </CardContent>
</Card> </Card>
)) ))}
) : ( </div>
<p className="text-center text-muted-foreground py-4">Chargement des utilisateurs...</p> </TabsContent>
<TabsContent value="rss" className="mt-4 space-y-4">
<Button
className="flex items-center gap-2"
onClick={handleAddRssFeed}
>
<Rss className="h-4 w-4" />
Ajouter un flux RSS
</Button>
<div className="space-y-2">
{rssFeeds.map(feed => (
<Card key={feed.id}>
<CardContent className="p-4 flex items-center justify-between">
<div className="flex-1">
<p className="font-medium">{feed.title}</p>
<p className="text-sm text-muted-foreground truncate">{feed.url}</p>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => feed.id && handleDeleteRssFeed(feed.id)}
>
Supprimer
</Button>
</CardContent>
</Card>
))}
{rssFeeds.length === 0 && (
<p className="text-center text-muted-foreground py-8">Aucun flux RSS configuré</p>
)} )}
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="openrouter" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
Configuration OpenRouter
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">Clé API OpenRouter</Label>
<Input
id="api-key"
type="password"
placeholder="sk-or-..."
value={settings.openrouter_api_key || ''}
onChange={(e) => setSettings({ ...settings, openrouter_api_key: e.target.value })}
/>
<p className="text-sm text-muted-foreground">
Obtenez votre clé API sur{' '}
<a href="https://openrouter.ai" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
openrouter.ai
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">Modèle IA</Label>
<Input
id="ai-model"
placeholder="anthropic/claude-3-sonnet"
value={settings.ai_model || ''}
onChange={(e) => setSettings({ ...settings, ai_model: e.target.value })}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleSaveSettings} className="flex items-center gap-2">
<Zap className="h-4 w-4" />
Enregistrer
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="mt-4"> <TabsContent value="settings" className="mt-4">
<Card> <Card>
<CardContent className="p-4"> <CardHeader>
<p>Paramètres de l'application</p> <CardTitle>Paramètres généraux</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="rss-interval">Intervalle de rafraîchissement RSS (minutes)</Label>
<Input
id="rss-interval"
type="number"
min="5"
placeholder="30"
value={settings.rss_refresh_interval || ''}
onChange={(e) => setSettings({ ...settings, rss_refresh_interval: parseInt(e.target.value) })}
/>
</div>
<Button onClick={handleSaveSettings}>
Enregistrer
</Button>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
+8
View File
@@ -13,6 +13,13 @@ interface Image {
filename: string; filename: string;
} }
interface NoteFile {
id?: number;
filename: string;
original_name?: string;
size?: number;
}
export interface Note { export interface Note {
id?: number; id?: number;
title: string; title: string;
@@ -22,6 +29,7 @@ export interface Note {
updated_at?: string; updated_at?: string;
todos: Todo[]; todos: Todo[];
images: Image[]; images: Image[];
files?: NoteFile[];
} }
class NotesService { class NotesService {
+95
View File
@@ -0,0 +1,95 @@
import axios from 'axios';
export interface RssFeed {
id?: number;
title: string;
url: string;
category?: string;
created_at?: string;
}
export interface RssArticle {
id?: number;
feed_id: number;
title: string;
link: string;
description?: string;
pub_date?: string;
read: boolean;
}
class RssService {
private baseURL = '/api/rss';
private getAuthHeader() {
const token = localStorage.getItem('token');
return {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
}
async getFeeds(): Promise<RssFeed[]> {
try {
const response = await axios.get(`${this.baseURL}/feeds`, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error fetching RSS feeds:', error);
throw error;
}
}
async addFeed(url: string, title?: string): Promise<RssFeed> {
try {
const response = await axios.post(`${this.baseURL}/feeds`, { url, title }, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error adding RSS feed:', error);
throw error;
}
}
async deleteFeed(feedId: number): Promise<boolean> {
try {
await axios.delete(`${this.baseURL}/feeds/${feedId}`, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error deleting RSS feed:', error);
throw error;
}
}
async getArticles(limit: number = 20): Promise<RssArticle[]> {
try {
const response = await axios.get(`${this.baseURL}/articles?limit=${limit}`, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error fetching RSS articles:', error);
throw error;
}
}
async markAsRead(articleId: number): Promise<boolean> {
try {
await axios.patch(`${this.baseURL}/articles/${articleId}/read`, {}, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error marking article as read:', error);
throw error;
}
}
async refreshFeeds(): Promise<boolean> {
try {
await axios.post(`${this.baseURL}/refresh`, {}, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error refreshing RSS feeds:', error);
throw error;
}
}
}
export default new RssService();
+54
View File
@@ -0,0 +1,54 @@
import axios from 'axios';
export interface Settings {
openrouter_api_key?: string;
ai_model?: string;
rss_refresh_interval?: number;
theme?: 'light' | 'dark' | 'auto';
}
class SettingsService {
private baseURL = '/api/settings';
private getAuthHeader() {
const token = localStorage.getItem('token');
return {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
}
async getSettings(): Promise<Settings> {
try {
const response = await axios.get(this.baseURL, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error fetching settings:', error);
throw error;
}
}
async updateSettings(settings: Settings): Promise<boolean> {
try {
await axios.put(this.baseURL, settings, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error updating settings:', error);
throw error;
}
}
async testOpenRouterConnection(): Promise<boolean> {
try {
const response = await axios.post(`${this.baseURL}/test-openrouter`, {}, this.getAuthHeader());
return response.data.success;
} catch (error) {
console.error('Error testing OpenRouter connection:', error);
throw error;
}
}
}
export default new SettingsService();
+76
View File
@@ -0,0 +1,76 @@
import axios from 'axios';
export interface Todo {
id?: number;
text: string;
completed: boolean;
priority?: 'low' | 'medium' | 'high';
due_date?: string;
created_at?: string;
}
class TodosService {
private baseURL = '/api/todos';
private getAuthHeader() {
const token = localStorage.getItem('token');
return {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
};
}
async getTodos(): Promise<Todo[]> {
try {
const response = await axios.get(this.baseURL, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error fetching todos:', error);
throw error;
}
}
async createTodo(text: string, priority?: 'low' | 'medium' | 'high'): Promise<Todo> {
try {
const response = await axios.post(this.baseURL, { text, priority, completed: false }, this.getAuthHeader());
return response.data;
} catch (error) {
console.error('Error creating todo:', error);
throw error;
}
}
async updateTodo(todo: Todo): Promise<boolean> {
try {
await axios.put(`${this.baseURL}/${todo.id}`, todo, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error updating todo:', error);
throw error;
}
}
async deleteTodo(todoId: number): Promise<boolean> {
try {
await axios.delete(`${this.baseURL}/${todoId}`, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error deleting todo:', error);
throw error;
}
}
async toggleComplete(todoId: number): Promise<boolean> {
try {
await axios.patch(`${this.baseURL}/${todoId}/toggle`, {}, this.getAuthHeader());
return true;
} catch (error) {
console.error('Error toggling todo:', error);
throw error;
}
}
}
export default new TodosService();