Merge pull request #18 from R0m1k3/claude/dockerized-notes-todo-app-011CV1roGRbnm21wgtB2LMxP

Claude/dockerized notes todo app 011 cv1ro g rbnm21wgt b2 l mx p
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-11 20:42:50 +01:00
commit 980bd2a160
6 files changed
+411 -48

No files matched your search

+21
View File
@@ -149,6 +149,27 @@ function initDatabase() {
logger.info('✓ Tables de base de données créées avec succès');
// Migrations automatiques
// Migration pour rss_summaries - ajouter feed_title
db.all("PRAGMA table_info(rss_summaries)", (err, columns) => {
if (err) {
logger.error('Erreur lors de la vérification de la structure de la table rss_summaries:', err);
return;
}
const hasFeedTitle = columns.some(col => col.name === 'feed_title');
if (!hasFeedTitle) {
logger.info('Migration: Ajout de la colonne feed_title à rss_summaries...');
db.run('ALTER TABLE rss_summaries ADD COLUMN feed_title TEXT', (err) => {
if (err) {
logger.error('Erreur lors de l\'ajout de la colonne feed_title:', err);
} else {
logger.info('✓ Colonne feed_title ajoutée avec succès');
}
});
}
});
db.all("PRAGMA table_info(notes)", (err, columns) => {
if (err) {
logger.error('Erreur lors de la vérification de la structure de la table notes:', err);
+15 -1
View File
@@ -84,6 +84,13 @@
object-fit: cover;
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.note-card-image:hover {
opacity: 0.9;
transform: scale(1.02);
}
.note-todos-preview {
@@ -840,9 +847,16 @@
.note-image {
width: 100%;
max-height: 300px;
max-height: 200px;
object-fit: cover;
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
}
.note-image:hover {
opacity: 0.9;
transform: scale(1.02);
}
.btn-remove-image {
+184
View File
@@ -174,3 +174,187 @@
font-size: var(--font-size-sm);
opacity: 0.7;
}
/* Système de notifications */
.notifications-container {
position: fixed;
top: calc(var(--header-height) + 20px);
right: 20px;
z-index: 300;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
.notification {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 16px 20px;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
transform: translateX(400px);
animation: slideInRight 0.3s ease-out forwards;
pointer-events: auto;
min-width: 300px;
position: relative;
}
.notification.closing {
animation: slideOutRight 0.3s ease-in forwards;
}
.notification-icon {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.notification-success {
border-left: 4px solid var(--color-success);
}
.notification-success .notification-icon {
color: var(--color-success);
}
.notification-error {
border-left: 4px solid var(--color-danger);
}
.notification-error .notification-icon {
color: var(--color-danger);
}
.notification-warning {
border-left: 4px solid var(--color-warning);
}
.notification-warning .notification-icon {
color: var(--color-warning);
}
.notification-info {
border-left: 4px solid var(--color-info);
}
.notification-info .notification-icon {
color: var(--color-info);
}
.notification-content {
flex: 1;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
line-height: 1.4;
}
.notification-close {
width: 24px;
height: 24px;
padding: 4px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
border-radius: 50%;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.notification-close:hover {
background: var(--color-hover);
color: var(--color-text-primary);
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(400px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOutRight {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(400px);
}
}
/* Modal pour agrandir les images */
.image-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 400;
display: none;
align-items: center;
justify-content: center;
padding: 40px;
cursor: pointer;
animation: fadeIn 0.2s ease-in;
}
.image-modal.active {
display: flex;
}
.image-modal-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
cursor: default;
}
.image-modal-img {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-md);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.image-modal-close {
position: fixed;
top: 20px;
right: 20px;
width: 48px;
height: 48px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 32px;
cursor: pointer;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
backdrop-filter: blur(10px);
}
.image-modal-close:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.image-modal-close svg {
width: 24px;
height: 24px;
}
+17 -1
View File
@@ -123,7 +123,7 @@
<!-- Todo Box -->
<div class="todo-box">
<div class="todo-box-header">
<h2 class="todo-box-title">Quick Tasks</h2>
<h2 class="todo-box-title">À faire</h2>
<span class="todo-counter" id="todoCounter">0 tâches</span>
</div>
@@ -302,6 +302,22 @@
<span>Mise à jour...</span>
</div>
<!-- Conteneur de notifications -->
<div class="notifications-container" id="notificationsContainer"></div>
<!-- Modal pour agrandir les images -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<img src="" alt="" class="image-modal-img" id="imageModalImg">
</div>
<button class="image-modal-close" id="imageModalClose">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<!-- JavaScript -->
<script src="/js/complete-app.js"></script>
</body>
+170 -42
View File
@@ -474,6 +474,87 @@ function escapeHtml(text) {
return div.innerHTML;
}
// Système de notifications internes
function showNotification(message, type = 'info', duration = 4000) {
const container = document.getElementById('notificationsContainer');
if (!container) return;
// Créer la notification
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
// Icône selon le type
let iconSvg = '';
switch (type) {
case 'success':
iconSvg = '<svg class="notification-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>';
break;
case 'error':
iconSvg = '<svg class="notification-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>';
break;
case 'warning':
iconSvg = '<svg class="notification-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>';
break;
default: // info
iconSvg = '<svg class="notification-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>';
}
notification.innerHTML = `
${iconSvg}
<div class="notification-content">${escapeHtml(message)}</div>
<button class="notification-close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
`;
// Bouton de fermeture
const closeBtn = notification.querySelector('.notification-close');
closeBtn.addEventListener('click', () => {
closeNotification(notification);
});
// Ajouter au container
container.appendChild(notification);
// Auto-fermeture
if (duration > 0) {
setTimeout(() => {
closeNotification(notification);
}, duration);
}
}
function closeNotification(notification) {
notification.classList.add('closing');
setTimeout(() => {
notification.remove();
}, 300);
}
// ==================== MODAL IMAGE ====================
function openImageModal(imageSrc) {
const modal = document.getElementById('imageModal');
const img = document.getElementById('imageModalImg');
if (modal && img) {
img.src = imageSrc;
modal.classList.add('active');
document.body.style.overflow = 'hidden'; // Désactiver le scroll
}
}
function closeImageModal() {
const modal = document.getElementById('imageModal');
if (modal) {
modal.classList.remove('active');
document.body.style.overflow = ''; // Réactiver le scroll
}
}
// ==================== INLINE NOTE EXPANSION ====================
function expandNote(noteId) {
state.expandedNoteId = noteId;
@@ -519,7 +600,7 @@ async function archiveNote(noteId, archived) {
await loadNotes();
} catch (error) {
console.error('Erreur archivage note:', error);
alert('Erreur lors de l\'archivage de la note');
showNotification('Erreur lors de l\'archivage de la note');
}
}
@@ -539,7 +620,7 @@ async function deleteNoteInline(noteId) {
await loadNotes();
} catch (error) {
console.error('Erreur suppression note:', error);
alert('Erreur lors de la suppression de la note');
showNotification('Erreur lors de la suppression de la note', 'error');
}
}
@@ -568,7 +649,7 @@ async function addNoteTodoInline(noteId, inputElement) {
inputElement.focus();
} catch (error) {
console.error('Erreur ajout todo:', error);
alert('Erreur lors de l\'ajout du todo');
showNotification('Erreur lors de l\'ajout du todo');
}
}
@@ -653,7 +734,7 @@ async function removeImageInline(noteId) {
renderNotes();
} catch (error) {
console.error('Erreur suppression image:', error);
alert('Erreur lors de la suppression de l\'image');
showNotification('Erreur lors de la suppression de l\'image');
}
}
@@ -679,7 +760,7 @@ async function handleImageUploadInline(event) {
renderNotes();
} catch (error) {
console.error('Erreur upload image:', error);
alert('Erreur lors de l\'upload de l\'image');
showNotification('Erreur lors de l\'upload de l\'image');
}
// Reset input
@@ -707,7 +788,7 @@ async function handleFileUploadInline(event) {
renderNotes();
} catch (error) {
console.error('Erreur upload fichier:', error);
alert('Erreur lors de l\'upload du fichier: ' + (error.message || 'Erreur inconnue'));
showNotification('Erreur lors de l\'upload du fichier: ' + (error.message || 'Erreur inconnue'));
}
// Reset input
@@ -721,7 +802,7 @@ async function downloadFile(fileId) {
window.open(`/api/notes/files/${fileId}/download`, '_blank');
} catch (error) {
console.error('Erreur téléchargement fichier:', error);
alert('Erreur lors du téléchargement du fichier');
showNotification('Erreur lors du téléchargement du fichier', 'error');
}
}
@@ -747,7 +828,7 @@ async function deleteFile(fileId) {
}
} catch (error) {
console.error('Erreur suppression fichier:', error);
alert('Erreur lors de la suppression du fichier');
showNotification('Erreur lors de la suppression du fichier', 'error');
}
}
@@ -775,7 +856,7 @@ async function createNewNote() {
}, 100);
} catch (error) {
console.error('Erreur création note:', error);
alert('Erreur lors de la création de la note');
showNotification('Erreur lors de la création de la note', 'error');
}
}
@@ -1010,7 +1091,7 @@ async function handleImageUpload(event) {
await loadNotes();
} catch (error) {
console.error('Erreur upload image:', error);
alert('Erreur lors de l\'upload de l\'image');
showNotification('Erreur lors de l\'upload de l\'image');
}
}
@@ -1057,6 +1138,16 @@ function renderTodos() {
return true;
});
// Tri : actives en premier, puis complétées
filtered.sort((a, b) => {
// Les actives (completed = false) avant les complétées (completed = true)
if (a.completed !== b.completed) {
return a.completed ? 1 : -1;
}
// Si même statut, tri par ID décroissant (plus récentes en premier)
return b.id - a.id;
});
filtered.forEach(todo => {
const item = document.createElement('div');
item.className = 'todo-item' + (todo.completed ? ' completed' : '');
@@ -1194,7 +1285,7 @@ async function loadUsers() {
renderUsersTable(users);
} catch (error) {
console.error('Erreur chargement utilisateurs:', error);
alert('Erreur lors du chargement des utilisateurs');
showNotification('Erreur lors du chargement des utilisateurs', 'error');
}
}
@@ -1246,13 +1337,13 @@ function closeAdminModal() {
async function createUser() {
const username = prompt('Nom d\'utilisateur:');
if (!username || username.trim().length < 3) {
alert('Le nom d\'utilisateur doit contenir au moins 3 caractères');
showNotification('Le nom d\'utilisateur doit contenir au moins 3 caractères');
return;
}
const password = prompt('Mot de passe:');
if (!password || password.length < 4) {
alert('Le mot de passe doit contenir au moins 4 caractères');
showNotification('Le mot de passe doit contenir au moins 4 caractères', 'warning');
return;
}
@@ -1270,11 +1361,11 @@ async function createUser() {
password,
is_admin: isAdmin
});
alert('Utilisateur créé avec succès');
showNotification('Utilisateur créé avec succès', 'success');
await loadUsers();
} catch (error) {
console.error('Erreur création utilisateur:', error);
alert('Erreur lors de la création de l\'utilisateur. Il existe peut-être déjà.');
showNotification('Erreur lors de la création de l\'utilisateur. Il existe peut-être déjà.');
}
}
@@ -1323,22 +1414,22 @@ async function confirmPasswordChange() {
const confirmPassword = confirmPasswordInput.value;
if (!newPassword || newPassword.length < 6) {
alert('Le mot de passe doit contenir au moins 6 caractères');
showNotification('Le mot de passe doit contenir au moins 6 caractères', 'warning');
return;
}
if (newPassword !== confirmPassword) {
alert('Les mots de passe ne correspondent pas');
showNotification('Les mots de passe ne correspondent pas', 'warning');
return;
}
try {
await api.put(`/api/users/${currentPasswordChangeUserId}`, { password: newPassword });
alert('Mot de passe modifié avec succès');
showNotification('Mot de passe modifié avec succès', 'success');
closePasswordChangeModal();
} catch (error) {
console.error('Erreur modification mot de passe:', error);
alert('Erreur lors de la modification du mot de passe');
showNotification('Erreur lors de la modification du mot de passe', 'error');
}
}
@@ -1358,11 +1449,11 @@ async function deleteUser(id, username) {
try {
await api.delete(`/api/users/${id}`);
alert('Utilisateur supprimé avec succès');
showNotification('Utilisateur supprimé avec succès', 'success');
await loadUsers();
} catch (error) {
console.error('Erreur suppression utilisateur:', error);
alert('Erreur lors de la suppression de l\'utilisateur');
showNotification('Erreur lors de la suppression de l\'utilisateur');
}
}
@@ -1373,7 +1464,7 @@ async function loadRssFeeds() {
renderRssFeeds(feeds);
} catch (error) {
console.error('Erreur chargement flux RSS:', error);
alert('Erreur lors du chargement des flux RSS');
showNotification('Erreur lors du chargement des flux RSS', 'error');
}
}
@@ -1414,23 +1505,23 @@ async function addRssFeed() {
const url = input.value.trim();
if (!url) {
alert('Veuillez entrer une URL de flux RSS');
showNotification('Veuillez entrer une URL de flux RSS', 'warning');
return;
}
if (!url.startsWith('http://') && !url.startsWith('https://')) {
alert('L\'URL doit commencer par http:// ou https://');
showNotification('L\'URL doit commencer par http:// ou https://', 'warning');
return;
}
try {
await api.post('/api/rss/feeds', { url });
input.value = '';
alert('Flux RSS ajouté avec succès');
showNotification('Flux RSS ajouté avec succès', 'success');
await loadRssFeeds();
} catch (error) {
console.error('Erreur ajout flux RSS:', error);
alert('Erreur lors de l\'ajout du flux RSS. Vérifiez l\'URL.');
showNotification('Erreur lors de l\'ajout du flux RSS. Vérifiez l\'URL.');
}
}
@@ -1439,7 +1530,7 @@ async function toggleRssFeed(feedId, enabled) {
await api.put(`/api/rss/feeds/${feedId}`, { enabled });
} catch (error) {
console.error('Erreur toggle flux RSS:', error);
alert('Erreur lors de la modification du flux RSS');
showNotification('Erreur lors de la modification du flux RSS', 'error');
}
}
@@ -1455,11 +1546,11 @@ async function deleteRssFeed(feedId) {
try {
await api.delete(`/api/rss/feeds/${feedId}`);
alert('Flux RSS supprimé avec succès');
showNotification('Flux RSS supprimé avec succès', 'success');
await loadRssFeeds();
} catch (error) {
console.error('Erreur suppression flux RSS:', error);
alert('Erreur lors de la suppression du flux RSS');
showNotification('Erreur lors de la suppression du flux RSS', 'error');
}
}
@@ -1473,11 +1564,11 @@ async function fetchRssArticles() {
try {
const result = await api.post('/api/rss/fetch', {});
alert(result.message || 'Articles récupérés avec succès');
showNotification(result.message || 'Articles récupérés avec succès', 'success');
await loadRssArticles();
} catch (error) {
console.error('Erreur fetch articles RSS:', error);
alert('Erreur lors de la récupération des articles RSS');
showNotification('Erreur lors de la récupération des articles RSS', 'error');
} finally {
btn.disabled = false;
btn.textContent = originalText;
@@ -1574,13 +1665,13 @@ function renderRssSummaries(summaries) {
// Le résumé peut être soit l'ancien format (global) soit le nouveau (par article depuis DB)
// Si c'est un résumé DB, il contient déjà le titre et le lien formatés
if (summary.summary && summary.summary.includes('🔗')) {
// Ancien format sauvegardé en DB avec titre et lien
// Nouveau format : résumé par article avec titre et lien
return `
<div class="rss-summary rss-summary-article">
<div class="rss-summary-content">${formatSummary(summary.summary)}</div>
<div class="rss-summary-meta">
${summary.created_at ? `<span>${new Date(summary.created_at).toLocaleDateString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
${summary.model ? `<span>${escapeHtml(summary.model.split('/')[1] || summary.model)}</span>` : ''}
${summary.feed_title ? `<span>📰 ${escapeHtml(summary.feed_title)}</span>` : ''}
${summary.created_at ? `<span>📅 ${new Date(summary.created_at).toLocaleDateString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>` : ''}
</div>
</div>
`;
@@ -1653,7 +1744,7 @@ async function summarizeRss() {
if (summaryEnabled) {
// Si les résumés sont activés, juste recharger le bloc RSS
alert(`${result.articles_count} résumés générés avec succès !`);
showNotification(`${result.articles_count} résumés générés avec succès !`, 'success');
await loadRssArticles();
} else {
// Sinon créer une note avec TOUS les résumés par article
@@ -1667,22 +1758,22 @@ async function summarizeRss() {
content: allSummaries
});
alert(`${result.articles_count} résumés générés et sauvegardés dans une nouvelle note !`);
showNotification(`${result.articles_count} résumés générés et sauvegardés dans une nouvelle note !`, 'success');
await loadNotes();
// Expand the new note
state.expandedNoteId = newNote.id;
renderNotes();
} else {
alert('Aucun résumé généré');
showNotification('Aucun résumé généré', 'warning');
}
}
} catch (error) {
console.error('Erreur génération résumé RSS:', error);
if (error.message && error.message.includes('400')) {
alert('Veuillez configurer votre clé API OpenRouter dans l\'administration');
showNotification('Veuillez configurer votre clé API OpenRouter dans l\'administration');
} else {
alert('Erreur lors de la génération du résumé RSS');
showNotification('Erreur lors de la génération du résumé RSS', 'error');
}
} finally {
btn.disabled = false;
@@ -1785,10 +1876,10 @@ async function saveOpenRouterSettings() {
await api.put('/api/settings/rss_summary_enabled', { value: summaryEnabled.checked ? '1' : '0' });
await api.put('/api/settings/rss_summary_prompt', { value: summaryPrompt.value });
alert('Paramètres OpenRouter sauvegardés avec succès');
showNotification('Paramètres OpenRouter sauvegardés avec succès', 'success');
} catch (error) {
console.error('Erreur sauvegarde paramètres OpenRouter:', error);
alert('Erreur lors de la sauvegarde des paramètres');
showNotification('Erreur lors de la sauvegarde des paramètres', 'error');
}
}
@@ -1854,7 +1945,7 @@ async function init() {
saveNoteBtn.addEventListener('click', async () => {
if (currentNoteId) {
await saveCurrentNote();
alert('Note enregistrée !');
showNotification('Note enregistrée !', 'success');
}
});
}
@@ -2193,6 +2284,43 @@ async function init() {
if (saveOpenRouterBtn) {
saveOpenRouterBtn.addEventListener('click', saveOpenRouterSettings);
}
// ==================== MODAL IMAGE ====================
// Fermer le modal avec le bouton X
const imageModalClose = document.getElementById('imageModalClose');
if (imageModalClose) {
imageModalClose.addEventListener('click', closeImageModal);
}
// Fermer le modal en cliquant sur le fond
const imageModal = document.getElementById('imageModal');
if (imageModal) {
imageModal.addEventListener('click', (e) => {
if (e.target === imageModal) {
closeImageModal();
}
});
}
// Fermer le modal avec la touche Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeImageModal();
}
});
// Délégation d'événements pour les clics sur les images
// Cela permettra de gérer les images même après un re-render
document.addEventListener('click', (e) => {
const target = e.target;
// Si on clique sur une image de note (miniature)
if (target.classList.contains('note-card-image') || target.classList.contains('note-image')) {
e.preventDefault();
e.stopPropagation();
openImageModal(target.src);
}
});
}
// Démarrer l'application
+4 -4
View File
@@ -241,10 +241,10 @@ Résumé (100 mots max):`;
const summary = response.data.choices[0].message.content;
// Sauvegarder le résumé individuel
// Sauvegarder le résumé individuel avec feed_title
await runQuery(
'INSERT INTO rss_summaries (summary, model, articles_count, created_at) VALUES (?, ?, ?, datetime("now"))',
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1]
'INSERT INTO rss_summaries (summary, model, articles_count, feed_title, created_at) VALUES (?, ?, ?, ?, datetime("now"))',
[`**${article.title}**\n\n${summary}\n\n🔗 [Lire l'article](${article.link})`, selectedModel, 1, article.feed_title]
);
return {
@@ -300,7 +300,7 @@ Résumé (100 mots max):`;
router.get('/summaries', async (req, res) => {
try {
const summaries = await getAll(`
SELECT id, summary, model, articles_count, created_at
SELECT id, summary, model, articles_count, feed_title, created_at
FROM rss_summaries
ORDER BY created_at DESC
LIMIT 5