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:
Claude committed 2025-11-12 09:47:47 +00:00
1 parent ece7232f0d
commit 4425695b4f
7 files changed
+580 -10

No files matched your search

+217 -10
View File
@@ -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>
+59
View File
@@ -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();
+77
View File
@@ -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();