mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-11 17:29:37 +02:00
Merge pull request #34 from R0m1k3/claude/fix-npm-ci-build-error-011CV3c2K4ETHdBRQUs7tkJf
Claude/fix npm ci build error 011 cv3c2 k4 et hd brq us7tk jf
This commit is contained in:
8 files changed
+590
-15
No files matched your search
@@ -167,12 +167,26 @@ function initDatabase() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Table note_tags (tags pour les notes)
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
|
||||
UNIQUE(note_id, tag)
|
||||
)
|
||||
`);
|
||||
|
||||
// Créer les index pour la performance
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_todos ON note_todos(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_global_todos_user ON global_todos(user_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_images ON note_images(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_files ON note_files(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_tags ON note_tags(note_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_feed ON rss_articles(feed_id)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_date ON rss_articles(pub_date)');
|
||||
|
||||
@@ -823,5 +823,77 @@ router.delete('/files/:fileId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== TAGS ====================
|
||||
|
||||
/**
|
||||
* GET /api/notes/:id/tags
|
||||
* Récupérer les tags d'une note
|
||||
*/
|
||||
router.get('/:id/tags', async (req, res) => {
|
||||
try {
|
||||
const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note non trouvée' });
|
||||
}
|
||||
|
||||
const tags = await getAll('SELECT id, tag FROM note_tags WHERE note_id = ? ORDER BY tag ASC', [req.params.id]);
|
||||
res.json(tags || []);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des tags:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/notes/:id/tags
|
||||
* Ajouter un tag à une note
|
||||
*/
|
||||
router.post('/:id/tags', async (req, res) => {
|
||||
try {
|
||||
const { tag } = req.body;
|
||||
if (!tag || !tag.trim()) {
|
||||
return res.status(400).json({ error: 'Tag invalide' });
|
||||
}
|
||||
|
||||
const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'Note non trouvée' });
|
||||
}
|
||||
|
||||
const result = await runQuery('INSERT INTO note_tags (note_id, tag) VALUES (?, ?)', [req.params.id, tag.trim().toLowerCase()]);
|
||||
res.json({ id: result.id, tag: tag.trim().toLowerCase() });
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('UNIQUE constraint')) {
|
||||
return res.status(409).json({ error: 'Ce tag existe déjà' });
|
||||
}
|
||||
logger.error('Erreur lors de l\'ajout du tag:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/notes/:noteId/tags/:tagId
|
||||
* Supprimer un tag d'une note
|
||||
*/
|
||||
router.delete('/:noteId/tags/:tagId', async (req, res) => {
|
||||
try {
|
||||
const tag = await getOne(`
|
||||
SELECT nt.id FROM note_tags nt
|
||||
JOIN notes n ON nt.note_id = n.id
|
||||
WHERE nt.id = ? AND nt.note_id = ? AND n.user_id = ?
|
||||
`, [req.params.tagId, req.params.noteId, req.user.id]);
|
||||
|
||||
if (!tag) {
|
||||
return res.status(404).json({ error: 'Tag non trouvé' });
|
||||
}
|
||||
|
||||
await runQuery('DELETE FROM note_tags WHERE id = ?', [req.params.tagId]);
|
||||
res.json({ message: 'Tag supprimé' });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la suppression du tag:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.searchNotes = searchNotes;
|
||||
@@ -0,0 +1,140 @@
|
||||
// Routes pour OpenRouter API
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||
const { getOne } = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
|
||||
// Cache pour les modèles (rafraîchi toutes les heures)
|
||||
let modelsCache = null;
|
||||
let modelsCacheTime = 0;
|
||||
const CACHE_DURATION = 60 * 60 * 1000; // 1 heure
|
||||
|
||||
/**
|
||||
* GET /api/openrouter/models
|
||||
* Récupérer la liste des modèles disponibles depuis OpenRouter
|
||||
*/
|
||||
router.get('/models', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Vérifier le cache
|
||||
const now = Date.now();
|
||||
if (modelsCache && (now - modelsCacheTime) < CACHE_DURATION) {
|
||||
return res.json(modelsCache);
|
||||
}
|
||||
|
||||
// Récupérer la clé API depuis les paramètres
|
||||
const setting = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
|
||||
|
||||
if (!setting || !setting.value) {
|
||||
return res.status(400).json({
|
||||
error: 'Clé API OpenRouter non configurée',
|
||||
models: []
|
||||
});
|
||||
}
|
||||
|
||||
// Appeler l'API OpenRouter pour récupérer les modèles
|
||||
const fetch = (await import('node-fetch')).default;
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${setting.value}`,
|
||||
'HTTP-Referer': process.env.APP_URL || 'http://localhost:5000',
|
||||
'X-Title': 'NoteFlow'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(`Erreur API OpenRouter: ${response.status} ${response.statusText}`);
|
||||
return res.status(response.status).json({
|
||||
error: 'Erreur lors de la récupération des modèles',
|
||||
models: []
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Extraire et formater les modèles
|
||||
const models = (data.data || []).map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
description: model.description || '',
|
||||
context_length: model.context_length || 0,
|
||||
pricing: model.pricing || {}
|
||||
}));
|
||||
|
||||
// Trier par nom
|
||||
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Mettre en cache
|
||||
modelsCache = models;
|
||||
modelsCacheTime = now;
|
||||
|
||||
logger.info(`✓ ${models.length} modèles OpenRouter récupérés`);
|
||||
res.json(models);
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors de la récupération des modèles OpenRouter:', error);
|
||||
res.status(500).json({
|
||||
error: 'Erreur serveur',
|
||||
models: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/openrouter/refresh-models
|
||||
* Forcer le rafraîchissement du cache des modèles
|
||||
*/
|
||||
router.post('/refresh-models', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Invalider le cache
|
||||
modelsCache = null;
|
||||
modelsCacheTime = 0;
|
||||
|
||||
// Rediriger vers la route GET pour récupérer les modèles
|
||||
const setting = await getOne('SELECT value FROM settings WHERE key = ?', ['openrouter_api_key']);
|
||||
|
||||
if (!setting || !setting.value) {
|
||||
return res.status(400).json({
|
||||
error: 'Clé API OpenRouter non configurée',
|
||||
models: []
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = (await import('node-fetch')).default;
|
||||
const response = await fetch('https://openrouter.ai/api/v1/models', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${setting.value}`,
|
||||
'HTTP-Referer': process.env.APP_URL || 'http://localhost:5000',
|
||||
'X-Title': 'NoteFlow'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return res.status(response.status).json({
|
||||
error: 'Erreur lors de la récupération des modèles',
|
||||
models: []
|
||||
});
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const models = (data.data || []).map(model => ({
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
description: model.description || '',
|
||||
context_length: model.context_length || 0,
|
||||
pricing: model.pricing || {}
|
||||
}));
|
||||
|
||||
models.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
modelsCache = models;
|
||||
modelsCacheTime = Date.now();
|
||||
|
||||
logger.info(`✓ Cache des modèles OpenRouter rafraîchi (${models.length} modèles)`);
|
||||
res.json({ message: 'Cache rafraîchi avec succès', models });
|
||||
} catch (error) {
|
||||
logger.error('Erreur lors du rafraîchissement des modèles:', error);
|
||||
res.status(500).json({ error: 'Erreur serveur' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -87,6 +87,7 @@ app.use('/api/todos', require('./routes/todos.routes'));
|
||||
app.use('/api/settings', require('./routes/settings.routes'));
|
||||
app.use('/api/rss', require('./routes/rss.routes'));
|
||||
app.use('/api/calendar', require('./routes/calendar.routes'));
|
||||
app.use('/api/openrouter', require('./routes/openrouter.routes'));
|
||||
|
||||
// Route de recherche
|
||||
app.get('/api/search', require('./routes/notes.routes').searchNotes);
|
||||
|
||||
@@ -29,11 +29,13 @@ export function RichTextEditor({ content, onChange, placeholder }: RichTextEdito
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedFormat, setSelectedFormat] = useState<string[]>([]);
|
||||
|
||||
// Set initial content only once on mount
|
||||
useEffect(() => {
|
||||
if (editorRef.current && editorRef.current.innerHTML !== content) {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.innerHTML = content;
|
||||
}
|
||||
}, [content]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Empty deps - only run once on mount
|
||||
|
||||
const execCommand = (command: string, value?: string) => {
|
||||
document.execCommand(command, false, value);
|
||||
|
||||
+223
-13
@@ -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
|
||||
@@ -217,9 +258,9 @@ const Index = () => {
|
||||
const handleContentChange = useCallback((content: string) => {
|
||||
if (!openNote) return;
|
||||
|
||||
// Update local state immediately for responsiveness
|
||||
// Don't update local state to avoid re-render during typing
|
||||
// Just save to API in background
|
||||
const updatedNote = { ...openNote, content };
|
||||
setOpenNote(updatedNote);
|
||||
|
||||
// Debounce the API call
|
||||
if (saveTimerRef.current) {
|
||||
@@ -229,11 +270,13 @@ const Index = () => {
|
||||
saveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await NotesService.updateNote(updatedNote);
|
||||
// Update notes list silently after save
|
||||
setNotes(prev => prev.map(note => note.id === updatedNote.id ? updatedNote : note));
|
||||
} catch (error) {
|
||||
showError("Erreur lors de la sauvegarde automatique");
|
||||
}
|
||||
}, 1000); // Save after 1 second of inactivity
|
||||
}, [openNote]);
|
||||
}, [openNote, setNotes]);
|
||||
|
||||
// Clean up timer on unmount
|
||||
useEffect(() => {
|
||||
@@ -374,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();
|
||||
@@ -520,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" />
|
||||
@@ -540,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>
|
||||
))
|
||||
@@ -601,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
|
||||
@@ -621,10 +701,17 @@ 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">
|
||||
<RichTextEditor
|
||||
key={openNote.id}
|
||||
content={openNote.content || ""}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
@@ -798,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>
|
||||
@@ -1214,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">
|
||||
@@ -1336,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