mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-23 07:08:17 +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
|
// 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_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_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_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_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_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_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_feed ON rss_articles(feed_id)');
|
||||||
db.run('CREATE INDEX IF NOT EXISTS idx_rss_articles_date ON rss_articles(pub_date)');
|
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 = router;
|
||||||
module.exports.searchNotes = searchNotes;
|
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/settings', require('./routes/settings.routes'));
|
||||||
app.use('/api/rss', require('./routes/rss.routes'));
|
app.use('/api/rss', require('./routes/rss.routes'));
|
||||||
app.use('/api/calendar', require('./routes/calendar.routes'));
|
app.use('/api/calendar', require('./routes/calendar.routes'));
|
||||||
|
app.use('/api/openrouter', require('./routes/openrouter.routes'));
|
||||||
|
|
||||||
// Route de recherche
|
// Route de recherche
|
||||||
app.get('/api/search', require('./routes/notes.routes').searchNotes);
|
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 editorRef = useRef<HTMLDivElement>(null);
|
||||||
const [selectedFormat, setSelectedFormat] = useState<string[]>([]);
|
const [selectedFormat, setSelectedFormat] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Set initial content only once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorRef.current && editorRef.current.innerHTML !== content) {
|
if (editorRef.current) {
|
||||||
editorRef.current.innerHTML = content;
|
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) => {
|
const execCommand = (command: string, value?: string) => {
|
||||||
document.execCommand(command, false, value);
|
document.execCommand(command, false, value);
|
||||||
|
|||||||
+223
-13
@@ -9,11 +9,12 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { RichTextEditor } from "@/components/RichTextEditor";
|
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
import { MadeWithDyad } from "@/components/made-with-dyad";
|
import { MadeWithDyad } from "@/components/made-with-dyad";
|
||||||
import {
|
import {
|
||||||
PlusCircle, Search, User, LogOut, Settings, ChevronDown, Plus, Archive, Trash2,
|
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";
|
} from "lucide-react";
|
||||||
import AuthService from "@/services/AuthService";
|
import AuthService from "@/services/AuthService";
|
||||||
import AdminService from "@/services/AdminService";
|
import AdminService from "@/services/AdminService";
|
||||||
@@ -22,6 +23,8 @@ import TodosService, { Todo } from "@/services/TodosService";
|
|||||||
import RssService, { RssFeed, RssArticle } from "@/services/RssService";
|
import RssService, { RssFeed, RssArticle } from "@/services/RssService";
|
||||||
import SettingsService, { Settings as AppSettings } from "@/services/SettingsService";
|
import SettingsService, { Settings as AppSettings } from "@/services/SettingsService";
|
||||||
import CalendarService, { CalendarEvent } from "@/services/CalendarService";
|
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 { useNavigate } from "react-router-dom";
|
||||||
import { showError, showSuccess } from "@/utils/toast";
|
import { showError, showSuccess } from "@/utils/toast";
|
||||||
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
import { ConfirmModal } from "@/components/modals/ConfirmModal";
|
||||||
@@ -48,6 +51,9 @@ const Index = () => {
|
|||||||
const [settings, setSettings] = useState<AppSettings>({});
|
const [settings, setSettings] = useState<AppSettings>({});
|
||||||
const [adminTab, setAdminTab] = useState("users");
|
const [adminTab, setAdminTab] = useState("users");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [noteTags, setNoteTags] = useState<Tag[]>([]);
|
||||||
|
const [openRouterModels, setOpenRouterModels] = useState<OpenRouterModel[]>([]);
|
||||||
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
@@ -58,6 +64,7 @@ const Index = () => {
|
|||||||
const [deleteUserModal, setDeleteUserModal] = useState<{open: boolean, userId?: number}>({open: false});
|
const [deleteUserModal, setDeleteUserModal] = useState<{open: boolean, userId?: number}>({open: false});
|
||||||
const [changePasswordModal, setChangePasswordModal] = useState<{open: boolean, userId?: number}>({open: false});
|
const [changePasswordModal, setChangePasswordModal] = useState<{open: boolean, userId?: number}>({open: false});
|
||||||
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
const [addNoteTodoModal, setAddNoteTodoModal] = useState(false);
|
||||||
|
const [addTagModal, setAddTagModal] = useState(false);
|
||||||
|
|
||||||
// Debounce timer for auto-save
|
// Debounce timer for auto-save
|
||||||
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (showAdmin) {
|
if (showAdmin) {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -162,6 +183,12 @@ const Index = () => {
|
|||||||
}
|
}
|
||||||
}, [showAdmin]);
|
}, [showAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showAdmin && adminTab === 'openrouter' && settings.openrouter_api_key && openRouterModels.length === 0) {
|
||||||
|
loadOpenRouterModels();
|
||||||
|
}
|
||||||
|
}, [showAdmin, adminTab, settings.openrouter_api_key]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
AuthService.logout();
|
AuthService.logout();
|
||||||
navigate("/login");
|
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) => {
|
const handleOpenNote = (note: Note) => {
|
||||||
setOpenNote(note);
|
setOpenNote(note);
|
||||||
|
if (note.id) {
|
||||||
|
loadNoteTags(note.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseNote = () => {
|
const handleCloseNote = () => {
|
||||||
setOpenNote(null);
|
setOpenNote(null);
|
||||||
|
setNoteTags([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update note with immediate local state update
|
// Update note with immediate local state update
|
||||||
@@ -217,9 +258,9 @@ const Index = () => {
|
|||||||
const handleContentChange = useCallback((content: string) => {
|
const handleContentChange = useCallback((content: string) => {
|
||||||
if (!openNote) return;
|
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 };
|
const updatedNote = { ...openNote, content };
|
||||||
setOpenNote(updatedNote);
|
|
||||||
|
|
||||||
// Debounce the API call
|
// Debounce the API call
|
||||||
if (saveTimerRef.current) {
|
if (saveTimerRef.current) {
|
||||||
@@ -229,11 +270,13 @@ const Index = () => {
|
|||||||
saveTimerRef.current = setTimeout(async () => {
|
saveTimerRef.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
await NotesService.updateNote(updatedNote);
|
await NotesService.updateNote(updatedNote);
|
||||||
|
// Update notes list silently after save
|
||||||
|
setNotes(prev => prev.map(note => note.id === updatedNote.id ? updatedNote : note));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError("Erreur lors de la sauvegarde automatique");
|
showError("Erreur lors de la sauvegarde automatique");
|
||||||
}
|
}
|
||||||
}, 1000); // Save after 1 second of inactivity
|
}, 1000); // Save after 1 second of inactivity
|
||||||
}, [openNote]);
|
}, [openNote, setNotes]);
|
||||||
|
|
||||||
// Clean up timer on unmount
|
// Clean up timer on unmount
|
||||||
useEffect(() => {
|
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>) => {
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files[0] && openNote?.id) {
|
if (e.target.files && e.target.files[0] && openNote?.id) {
|
||||||
const formData = new FormData();
|
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]">
|
<p className="text-sm text-muted-foreground line-clamp-4 mb-4 min-h-[80px]">
|
||||||
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
|
{note.content ? note.content.replace(/<[^>]*>/g, '') : "Note vide"}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap mb-3">
|
||||||
{note.todos && note.todos.length > 0 && (
|
{note.todos && note.todos.length > 0 && (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<CheckSquare className="h-3 w-3 mr-1" />
|
<CheckSquare className="h-3 w-3 mr-1" />
|
||||||
@@ -540,6 +611,15 @@ const Index = () => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))
|
||||||
@@ -601,7 +681,7 @@ const Index = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="content" className="mt-4">
|
<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="content">Contenu</TabsTrigger>
|
||||||
<TabsTrigger value="todos">
|
<TabsTrigger value="todos">
|
||||||
Tâches
|
Tâches
|
||||||
@@ -621,10 +701,17 @@ const Index = () => {
|
|||||||
<Badge variant="secondary" className="ml-2">{openNote.files.length}</Badge>
|
<Badge variant="secondary" className="ml-2">{openNote.files.length}</Badge>
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tags">
|
||||||
|
Tags
|
||||||
|
{noteTags.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="ml-2">{noteTags.length}</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="content" className="mt-4">
|
<TabsContent value="content" className="mt-4">
|
||||||
<RichTextEditor
|
<RichTextEditor
|
||||||
|
key={openNote.id}
|
||||||
content={openNote.content || ""}
|
content={openNote.content || ""}
|
||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
/>
|
/>
|
||||||
@@ -798,7 +885,66 @@ const Index = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1214,13 +1360,67 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="ai-model">Modèle IA</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Label htmlFor="ai-model">Modèle IA</Label>
|
||||||
id="ai-model"
|
{settings.openrouter_api_key && (
|
||||||
placeholder="anthropic/claude-3-sonnet"
|
<Button
|
||||||
value={settings.ai_model || ''}
|
variant="outline"
|
||||||
onChange={(e) => setSettings({ ...settings, ai_model: e.target.value })}
|
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>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -1336,6 +1536,16 @@ const Index = () => {
|
|||||||
confirmText="Ajouter"
|
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">
|
<div className="fixed bottom-4 right-4">
|
||||||
<MadeWithDyad />
|
<MadeWithDyad />
|
||||||
</div>
|
</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