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:
LogiFlow authored and GitHub committed 2025-11-12 10:49:06 +01:00
commit e89e29e4b7
8 files changed
+590 -15

No files matched your search

+14
View File
@@ -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)');
+72
View File
@@ -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;
+140
View File
@@ -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;
+1
View File
@@ -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);
+4 -2
View File
@@ -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
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
@@ -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>
+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();