mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-19 21:28:33 +02:00
Feature: Système de tags et liste automatique des modèles OpenRouter
Ajouts majeurs: - Système de tags complet pour les notes * Table note_tags en base de données avec indexes * API REST pour CRUD des tags (GET/POST/DELETE) * UI avec onglet Tags dans l'éditeur de notes * Badges cliquables avec icônes et suppression au survol * Modal pour ajouter des tags - Liste automatique des modèles OpenRouter * Nouvelle route API /api/openrouter/models * Cache des modèles avec rafraîchissement automatique (1h) * Dropdown dans l'admin avec tous les modèles disponibles * Bouton "Actualiser" pour forcer le refresh * Affichage du nom et description de chaque modèle * Fallback sur input manuel si pas de clé API Améliorations UI: - Ajout de dates de création et modification dans les notes - Affichage des dates dans les cartes et l'éditeur - Format français pour toutes les dates - Onglets passés de 4 à 5 colonnes pour inclure Tags - Animation de chargement sur le bouton refresh des modèles Architecture: - Nouveau service TagsService.ts pour la gestion des tags - Nouveau service OpenRouterService.ts pour l'API OpenRouter - Nouvelles routes openrouter.routes.js - Support Select component pour les dropdowns - Modals InputModal utilisés pour les tags
This commit is contained in:
7 files changed
+580
-10
No files matched your search
+217
-10
@@ -9,11 +9,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||
import { MadeWithDyad } from "@/components/made-with-dyad";
|
||||
import {
|
||||
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
|
||||
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon
|
||||
Image as ImageIcon, CheckSquare, FileText, Rss, ExternalLink, RefreshCw, Key, Zap, Paperclip, X, Edit, Calendar as CalendarIcon, Tag as TagIcon
|
||||
} from "lucide-react";
|
||||
import AuthService from "@/services/AuthService";
|
||||
import AdminService from "@/services/AdminService";
|
||||
@@ -22,6 +23,8 @@ import TodosService, { Todo } from "@/services/TodosService";
|
||||
import RssService, { RssFeed, RssArticle } from "@/services/RssService";
|
||||
import SettingsService, { Settings as AppSettings } from "@/services/SettingsService";
|
||||
import CalendarService, { CalendarEvent } from "@/services/CalendarService";
|
||||
import TagsService, { Tag } from "@/services/TagsService";
|
||||
import OpenRouterService, { OpenRouterModel } from "@/services/OpenRouterService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { showError, showSuccess } from "@/utils/toast";
|
||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||
@@ -48,6 +51,9 @@ const Index = () => {
|
||||
const [settings, setSettings] = useState<AppSettings>({});
|
||||
const [adminTab, setAdminTab] = useState("users");
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
||||
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Modal states
|
||||
@@ -58,6 +64,7 @@ const Index = () => {
|
||||
const [deleteUserModal, setDeleteUserModal] = useState<{open: boolean, userId?: number}>({open: false});
|
||||
const [changePasswordModal, setChangePasswordModal] = useState<{open: boolean, userId?: number}>({open: false});
|
||||
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
||||
const [addTagModal, setAddTagModal] = useState(false);
|
||||
|
||||
// Debounce timer for auto-save
|
||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -155,6 +162,20 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadOpenRouterModels = async () => {
|
||||
if (user?.is_admin && settings.openrouter_api_key) {
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const models = await OpenRouterService.getModels();
|
||||
setOpenRouterModels(models);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des modèles:", error);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showAdmin) {
|
||||
loadUsers();
|
||||
@@ -162,6 +183,12 @@ const Index = () => {
|
||||
}
|
||||
}, [showAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showAdmin && adminTab === 'openrouter' && settings.openrouter_api_key && openRouterModels.length === 0) {
|
||||
loadOpenRouterModels();
|
||||
}
|
||||
}, [showAdmin, adminTab, settings.openrouter_api_key]);
|
||||
|
||||
const handleLogout = () => {
|
||||
AuthService.logout();
|
||||
navigate("/login");
|
||||
@@ -179,12 +206,26 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadNoteTags = async (noteId: number) => {
|
||||
try {
|
||||
const tags = await TagsService.getTags(noteId);
|
||||
setNoteTags(tags || []);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du chargement des tags:", error);
|
||||
setNoteTags([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenNote = (note: Note) => {
|
||||
setOpenNote(note);
|
||||
if (note.id) {
|
||||
loadNoteTags(note.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseNote = () => {
|
||||
setOpenNote(null);
|
||||
setNoteTags([]);
|
||||
};
|
||||
|
||||
// Update note with immediate local state update
|
||||
@@ -376,6 +417,34 @@ const Index = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAddTag = async (tagText: string) => {
|
||||
if (!openNote?.id) return;
|
||||
|
||||
try {
|
||||
const newTag = await TagsService.addTag(openNote.id, tagText);
|
||||
if (newTag) {
|
||||
setNoteTags([...noteTags, newTag]);
|
||||
showSuccess("Tag ajouté");
|
||||
}
|
||||
} catch (error) {
|
||||
showError("Erreur lors de l'ajout du tag");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tagId: number) => {
|
||||
if (!openNote?.id) return;
|
||||
|
||||
try {
|
||||
const success = await TagsService.deleteTag(openNote.id, tagId);
|
||||
if (success) {
|
||||
setNoteTags(noteTags.filter(t => t.id !== tagId));
|
||||
showSuccess("Tag supprimé");
|
||||
}
|
||||
} catch (error) {
|
||||
showError("Erreur lors de la suppression du tag");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0] && openNote?.id) {
|
||||
const formData = new FormData();
|
||||
@@ -522,7 +591,7 @@ const Index = () => {
|
||||
<p className="text-sm text-muted-foreground line-clamp-4 mb-4 min-h-[80px]">
|
||||
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
|
||||
</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap mb-3">
|
||||
{note.todos && note.todos.length > 0 && (
|
||||
<Badge variant="secondary">
|
||||
<CheckSquare className="h-3 w-3 mr-1" />
|
||||
@@ -542,6 +611,15 @@ const Index = () => {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{note.updated_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Modifiée le {new Date(note.updated_at).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
@@ -603,7 +681,7 @@ const Index = () => {
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="content" className="mt-4">
|
||||
<TabsList className="grid grid-cols-4 w-full max-w-md">
|
||||
<TabsList className="grid grid-cols-5 w-full max-w-2xl">
|
||||
<TabsTrigger value="content">Contenu</TabsTrigger>
|
||||
<TabsTrigger value="todos">
|
||||
Tâches
|
||||
@@ -623,6 +701,12 @@ const Index = () => {
|
||||
<Badge variant="secondary" className="ml-2">{openNote.files.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags">
|
||||
Tags
|
||||
{noteTags.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">{noteTags.length}</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="content" className="mt-4">
|
||||
@@ -801,7 +885,66 @@ const Index = () => {
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags" className="mt-4 space-y-4">
|
||||
<Button
|
||||
onClick={() => setAddTagModal(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter un tag
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{noteTags.length > 0 ? (
|
||||
noteTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="secondary"
|
||||
className="text-base px-3 py-2 gap-2 cursor-pointer hover:bg-destructive hover:text-destructive-foreground transition-colors group"
|
||||
>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
{tag.tag}
|
||||
<X
|
||||
className="h-4 w-4 opacity-50 group-hover:opacity-100"
|
||||
onClick={() => tag.id && handleDeleteTag(tag.id)}
|
||||
/>
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8 w-full">Aucun tag</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Note metadata */}
|
||||
<Card className="mt-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="text-sm text-muted-foreground space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Créée le:</span>
|
||||
<span>{openNote.created_at ? new Date(openNote.created_at).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Modifiée le:</span>
|
||||
<span>{openNote.updated_at ? new Date(openNote.updated_at).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1217,13 +1360,67 @@ const Index = () => {
|
||||
</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 className="flex items-center justify-between">
|
||||
<Label htmlFor="ai-model">Modèle IA</Label>
|
||||
{settings.openrouter_api_key && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setLoadingModels(true);
|
||||
const models = await OpenRouterService.refreshModels();
|
||||
setOpenRouterModels(models);
|
||||
showSuccess(`${models.length} modèles chargés`);
|
||||
} catch (error) {
|
||||
showError("Erreur lors du chargement des modèles");
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}}
|
||||
disabled={loadingModels}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loadingModels ? 'animate-spin' : ''}`} />
|
||||
Actualiser les modèles
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{settings.openrouter_api_key && openRouterModels.length > 0 ? (
|
||||
<Select
|
||||
value={settings.ai_model || ''}
|
||||
onValueChange={(value) => setSettings({ ...settings, ai_model: value })}
|
||||
>
|
||||
<SelectTrigger id="ai-model">
|
||||
<SelectValue placeholder="Sélectionnez un modèle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openRouterModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
{model.description && (
|
||||
<span className="text-xs text-muted-foreground line-clamp-1">
|
||||
{model.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
id="ai-model"
|
||||
placeholder="anthropic/claude-3-sonnet"
|
||||
value={settings.ai_model || ''}
|
||||
onChange={(e) => setSettings({ ...settings, ai_model: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{settings.openrouter_api_key
|
||||
? `${openRouterModels.length} modèles disponibles. Cliquez sur "Actualiser" pour charger la liste.`
|
||||
: "Ajoutez d'abord une clé API pour voir les modèles disponibles."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -1339,6 +1536,16 @@ const Index = () => {
|
||||
confirmText="Ajouter"
|
||||
/>
|
||||
|
||||
<InputModal
|
||||
open={addTagModal}
|
||||
onOpenChange={setAddTagModal}
|
||||
title="Nouveau tag"
|
||||
label="Tag"
|
||||
placeholder="Entrez le nom du tag..."
|
||||
onConfirm={confirmAddTag}
|
||||
confirmText="Ajouter"
|
||||
/>
|
||||
|
||||
<div className="fixed bottom-4 right-4">
|
||||
<MadeWithDyad />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import AuthService from './AuthService';
|
||||
|
||||
export interface OpenRouterModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
context_length: number;
|
||||
pricing: {
|
||||
prompt?: string;
|
||||
completion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class OpenRouterService {
|
||||
/**
|
||||
* Get available models from OpenRouter API
|
||||
*/
|
||||
async getModels(): Promise<OpenRouterModel[]> {
|
||||
try {
|
||||
const response = await fetch('/api/openrouter/models', {
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération des modèles');
|
||||
}
|
||||
|
||||
const models = await response.json();
|
||||
return Array.isArray(models) ? models : [];
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des modèles OpenRouter:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh models cache
|
||||
*/
|
||||
async refreshModels(): Promise<OpenRouterModel[]> {
|
||||
try {
|
||||
const response = await fetch('/api/openrouter/refresh-models', {
|
||||
method: 'POST',
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du rafraîchissement des modèles');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return Array.isArray(data.models) ? data.models : [];
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du rafraîchissement des modèles:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new OpenRouterService();
|
||||
@@ -0,0 +1,77 @@
|
||||
import AuthService from './AuthService';
|
||||
|
||||
export interface Tag {
|
||||
id: number;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
class TagsService {
|
||||
/**
|
||||
* Get all tags for a note
|
||||
*/
|
||||
async getTags(noteId: number): Promise<Tag[]> {
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}/tags`, {
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la récupération des tags');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des tags:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a tag to a note
|
||||
*/
|
||||
async addTag(noteId: number, tag: string): Promise<Tag> {
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}/tags`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...AuthService.getHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ tag })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Erreur lors de l\'ajout du tag');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'ajout du tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tag from a note
|
||||
*/
|
||||
async deleteTag(noteId: number, tagId: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`/api/notes/${noteId}/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
headers: AuthService.getHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la suppression du tag');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la suppression du tag:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new TagsService();
|
||||
Reference in new issue
Block a user