mirror of
https://github.com/R0m1k3/noteflow.git
synced 2026-10-21 22:28:18 +02:00
UX: Correction angles carte + Input inline todos + Système de fichiers complet
## Corrections UX: - Fix coins arrondis en bas des notes étendues (overflow: hidden) - Remplacement prompt() par input inline pour ajout de tâches - Bouton "Ajouter" avec validation au clic ou touche Entrée - Expérience utilisateur fluide sans popups intrusifs ## Système de fichiers complet: ### Backend: - Création table note_files (id, note_id, filename, original_name, file_size, mime_type, created_at) - POST /api/notes/:id/files - Upload fichier (multer, 10MB max) - GET /api/notes/:id/files - Liste fichiers d'une note - GET /api/notes/files/:fileId/download - Téléchargement sécurisé - DELETE /api/notes/files/:fileId - Suppression fichier - Stockage dans public/uploads/files/ - Auto-création dossier si nécessaire ### Frontend: - Bouton "Ajouter un fichier" dans footer des notes - Input hidden fileInput pour sélection - Affichage liste fichiers avec icône, nom, taille - Boutons télécharger/supprimer par fichier - Fonction formatFileSize() pour affichage lisible - Event delegation pour tous les boutons fichiers - Confirmation avant suppression ### Intégration: - GET /api/notes charge maintenant files[] et files_count - renderInlineFiles() génère HTML des fichiers - downloadFile() ouvre download dans nouvel onglet - deleteFile() avec confirmation dialog - Reload automatique après upload/delete - Conservation état étendu après opérations ### CSS moderne: - .note-file-item avec hover effect - .note-file-icon, .note-file-info, .note-file-actions - Buttons download/delete avec transitions - Design cohérent avec reste de l'app - Truncate nom fichier avec ellipsis Tous types de fichiers acceptés (PDF, DOC, ZIP, etc.)
This commit is contained in:
5 files changed
+526
-16
No files matched your search
@@ -74,10 +74,25 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Table note_files (fichiers attachés aux notes)
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS note_files (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
note_id INTEGER NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
file_size INTEGER,
|
||||||
|
mime_type TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 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_files ON note_files(note_id)');
|
||||||
|
|
||||||
logger.info('✓ Tables de base de données créées avec succès');
|
logger.info('✓ Tables de base de données créées avec succès');
|
||||||
|
|
||||||
|
|||||||
+152
-8
@@ -28,6 +28,7 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-card.expanded:hover {
|
.note-card.expanded:hover {
|
||||||
@@ -312,26 +313,54 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-todo-inline {
|
.add-todo-inline-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-inline-input {
|
||||||
|
flex: 1;
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: transparent;
|
background: var(--color-background);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-inline-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-todo-inline-input::placeholder {
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-todo-inline {
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition-fast);
|
transition: all var(--transition-fast);
|
||||||
width: 100%;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-todo-inline:hover {
|
.btn-add-todo-inline:hover {
|
||||||
background: var(--color-hover);
|
background: var(--color-accent-hover);
|
||||||
border-color: var(--color-accent);
|
box-shadow: var(--shadow-sm);
|
||||||
color: var(--color-text-primary);
|
}
|
||||||
|
|
||||||
|
.btn-add-todo-inline:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-footer-inline {
|
.note-footer-inline {
|
||||||
@@ -375,6 +404,121 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-add-file-inline {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-file-inline:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-file-inline svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-files-container-inline {
|
||||||
|
padding: 0 var(--spacing-lg) var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-files-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
background: var(--color-background);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-item:hover {
|
||||||
|
background: var(--color-hover);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-size {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-file-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-file,
|
||||||
|
.btn-delete-file {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition-fast);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-file:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-file:hover {
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-file svg,
|
||||||
|
.btn-delete-file svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
TODO BOX
|
TODO BOX
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
|
|
||||||
<!-- Hidden file input -->
|
<!-- Hidden file input -->
|
||||||
<input type="file" id="imageInput" accept="image/*" style="display: none;">
|
<input type="file" id="imageInput" accept="image/*" style="display: none;">
|
||||||
|
<input type="file" id="fileInput" style="display: none;">
|
||||||
|
|
||||||
<!-- JavaScript -->
|
<!-- JavaScript -->
|
||||||
<script src="/js/complete-app.js"></script>
|
<script src="/js/complete-app.js"></script>
|
||||||
|
|||||||
+182
-7
@@ -327,21 +327,37 @@ function createNoteCard(note) {
|
|||||||
<button class="btn-remove-image-inline" data-note-id="${note.id}">✕</button>
|
<button class="btn-remove-image-inline" data-note-id="${note.id}">✕</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
${note.files && note.files.length > 0 ? `
|
||||||
|
<div class="note-files-container-inline">
|
||||||
|
<div class="note-files-list" id="files-${note.id}">
|
||||||
|
${renderInlineFiles(note.files || [])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="note-todos-inline">
|
<div class="note-todos-inline">
|
||||||
<h4>Todos</h4>
|
<h4>Todos</h4>
|
||||||
<div class="todos-list-inline" id="todos-${note.id}">
|
<div class="todos-list-inline" id="todos-${note.id}">
|
||||||
${renderInlineTodos(note.todos || [])}
|
${renderInlineTodos(note.todos || [])}
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-add-todo-inline" data-note-id="${note.id}">+ Ajouter une tâche</button>
|
<div class="add-todo-inline-container">
|
||||||
|
<input type="text" class="add-todo-inline-input" placeholder="Ajouter une tâche..." data-note-id="${note.id}">
|
||||||
|
<button class="btn-add-todo-inline" data-note-id="${note.id}">Ajouter</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="note-footer-inline">
|
<div class="note-footer-inline">
|
||||||
<button class="btn-add-image-inline" data-note-id="${note.id}">
|
<button class="btn-add-image-inline" data-note-id="${note.id}" title="Ajouter une image">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||||
<polyline points="21 15 16 10 5 21"></polyline>
|
<polyline points="21 15 16 10 5 21"></polyline>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-add-file-inline" data-note-id="${note.id}" title="Ajouter un fichier">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||||
|
<polyline points="13 2 13 9 20 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<span class="note-meta-inline">${utils.formatDate(note.updated_at)}</span>
|
<span class="note-meta-inline">${utils.formatDate(note.updated_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -409,6 +425,49 @@ function renderInlineTodos(todos) {
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInlineFiles(files) {
|
||||||
|
if (!files || files.length === 0) return '';
|
||||||
|
|
||||||
|
return files.map(file => {
|
||||||
|
const sizeStr = formatFileSize(file.file_size);
|
||||||
|
return `
|
||||||
|
<div class="note-file-item" data-file-id="${file.id}">
|
||||||
|
<svg class="note-file-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||||
|
<polyline points="13 2 13 9 20 9"></polyline>
|
||||||
|
</svg>
|
||||||
|
<div class="note-file-info">
|
||||||
|
<div class="note-file-name">${escapeHtml(file.original_name)}</div>
|
||||||
|
<div class="note-file-size">${sizeStr}</div>
|
||||||
|
</div>
|
||||||
|
<div class="note-file-actions">
|
||||||
|
<button class="btn-download-file" data-file-id="${file.id}" title="Télécharger">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||||
|
<polyline points="7 10 12 15 17 10"></polyline>
|
||||||
|
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-delete-file" data-file-id="${file.id}" title="Supprimer">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
@@ -484,12 +543,12 @@ async function deleteNoteInline(noteId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNoteTodoInline(noteId) {
|
async function addNoteTodoInline(noteId, inputElement) {
|
||||||
const text = prompt('Texte du todo:');
|
const text = inputElement.value.trim();
|
||||||
if (!text || !text.trim()) return;
|
if (!text) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/notes/${noteId}/todos`, { text: text.trim() });
|
await api.post(`/api/notes/${noteId}/todos`, { text });
|
||||||
|
|
||||||
// Recharger seulement cette note
|
// Recharger seulement cette note
|
||||||
const note = await api.get(`/api/notes/${noteId}`);
|
const note = await api.get(`/api/notes/${noteId}`);
|
||||||
@@ -503,6 +562,10 @@ async function addNoteTodoInline(noteId) {
|
|||||||
if (todosList) {
|
if (todosList) {
|
||||||
todosList.innerHTML = renderInlineTodos(note.todos || []);
|
todosList.innerHTML = renderInlineTodos(note.todos || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
inputElement.value = '';
|
||||||
|
inputElement.focus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur ajout todo:', error);
|
console.error('Erreur ajout todo:', error);
|
||||||
alert('Erreur lors de l\'ajout du todo');
|
alert('Erreur lors de l\'ajout du todo');
|
||||||
@@ -559,6 +622,14 @@ function triggerImageUpload(noteId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function triggerFileUpload(noteId) {
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.dataset.noteId = noteId;
|
||||||
|
fileInput.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function removeImageInline(noteId) {
|
async function removeImageInline(noteId) {
|
||||||
const confirmed = await confirmDialog.show({
|
const confirmed = await confirmDialog.show({
|
||||||
icon: '🖼️',
|
icon: '🖼️',
|
||||||
@@ -616,6 +687,70 @@ async function handleImageUploadInline(event) {
|
|||||||
event.target.dataset.noteId = '';
|
event.target.dataset.noteId = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleFileUploadInline(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
const noteId = event.target.dataset.noteId;
|
||||||
|
|
||||||
|
if (!file || !noteId) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.uploadFile(`/api/notes/${noteId}/files`, formData);
|
||||||
|
|
||||||
|
// Reload notes to get updated files list
|
||||||
|
await loadNotes();
|
||||||
|
|
||||||
|
// Keep the note expanded
|
||||||
|
state.expandedNoteId = parseInt(noteId);
|
||||||
|
renderNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur upload fichier:', error);
|
||||||
|
alert('Erreur lors de l\'upload du fichier: ' + (error.message || 'Erreur inconnue'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
event.target.value = '';
|
||||||
|
event.target.dataset.noteId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(fileId) {
|
||||||
|
try {
|
||||||
|
// Ouvrir le lien de téléchargement dans un nouvel onglet
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(fileId) {
|
||||||
|
const confirmed = await confirmDialog.show({
|
||||||
|
icon: '📎',
|
||||||
|
title: 'Supprimer ce fichier',
|
||||||
|
message: 'Êtes-vous sûr de vouloir supprimer ce fichier ?',
|
||||||
|
okText: 'Supprimer'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/notes/files/${fileId}`);
|
||||||
|
|
||||||
|
// Reload notes to get updated files list
|
||||||
|
await loadNotes();
|
||||||
|
|
||||||
|
// Keep the note expanded
|
||||||
|
if (state.expandedNoteId) {
|
||||||
|
renderNotes();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur suppression fichier:', error);
|
||||||
|
alert('Erreur lors de la suppression du fichier');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createNewNote() {
|
async function createNewNote() {
|
||||||
try {
|
try {
|
||||||
const newNote = await api.post('/api/notes', {
|
const newNote = await api.post('/api/notes', {
|
||||||
@@ -1234,6 +1369,11 @@ async function init() {
|
|||||||
imageInput.addEventListener('change', handleImageUploadInline);
|
imageInput.addEventListener('change', handleImageUploadInline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.addEventListener('change', handleFileUploadInline);
|
||||||
|
}
|
||||||
|
|
||||||
const removeImage = document.getElementById('removeImage');
|
const removeImage = document.getElementById('removeImage');
|
||||||
if (removeImage) {
|
if (removeImage) {
|
||||||
removeImage.addEventListener('click', removeNoteImage);
|
removeImage.addEventListener('click', removeNoteImage);
|
||||||
@@ -1381,7 +1521,18 @@ async function init() {
|
|||||||
const addTodoBtn = e.target.closest('.btn-add-todo-inline');
|
const addTodoBtn = e.target.closest('.btn-add-todo-inline');
|
||||||
if (addTodoBtn) {
|
if (addTodoBtn) {
|
||||||
const noteId = parseInt(addTodoBtn.dataset.noteId);
|
const noteId = parseInt(addTodoBtn.dataset.noteId);
|
||||||
addNoteTodoInline(noteId);
|
const inputElement = document.querySelector(`.add-todo-inline-input[data-note-id="${noteId}"]`);
|
||||||
|
if (inputElement) {
|
||||||
|
addNoteTodoInline(noteId, inputElement);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file button
|
||||||
|
const addFileBtn = e.target.closest('.btn-add-file-inline');
|
||||||
|
if (addFileBtn) {
|
||||||
|
const noteId = parseInt(addFileBtn.dataset.noteId);
|
||||||
|
triggerFileUpload(noteId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,6 +1542,22 @@ async function init() {
|
|||||||
deleteNoteTodoInline(todoId);
|
deleteNoteTodoInline(todoId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download file button
|
||||||
|
const downloadBtn = e.target.closest('.btn-download-file');
|
||||||
|
if (downloadBtn) {
|
||||||
|
const fileId = parseInt(downloadBtn.dataset.fileId);
|
||||||
|
downloadFile(fileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file button
|
||||||
|
const deleteFileBtn = e.target.closest('.btn-delete-file');
|
||||||
|
if (deleteFileBtn) {
|
||||||
|
const fileId = parseInt(deleteFileBtn.dataset.fileId);
|
||||||
|
deleteFile(fileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Checkbox change for inline todos
|
// Checkbox change for inline todos
|
||||||
@@ -1400,6 +1567,14 @@ async function init() {
|
|||||||
toggleNoteTodoInline(todoId, e.target.checked);
|
toggleNoteTodoInline(todoId, e.target.checked);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enter key on add todo input
|
||||||
|
notesGrid.addEventListener('keypress', (e) => {
|
||||||
|
if (e.target.classList.contains('add-todo-inline-input') && e.key === 'Enter') {
|
||||||
|
const noteId = parseInt(e.target.dataset.noteId);
|
||||||
|
addNoteTodoInline(noteId, e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close modal on Escape
|
// Close modal on Escape
|
||||||
|
|||||||
+176
-1
@@ -58,7 +58,7 @@ router.get('/', async (req, res) => {
|
|||||||
ORDER BY n.updated_at DESC
|
ORDER BY n.updated_at DESC
|
||||||
`, [req.user.id, archivedFilter]);
|
`, [req.user.id, archivedFilter]);
|
||||||
|
|
||||||
// Charger les todos pour chaque note
|
// Charger les todos et fichiers pour chaque note
|
||||||
for (const note of notes) {
|
for (const note of notes) {
|
||||||
const todos = await getAll(`
|
const todos = await getAll(`
|
||||||
SELECT id, text, completed, position
|
SELECT id, text, completed, position
|
||||||
@@ -67,6 +67,15 @@ router.get('/', async (req, res) => {
|
|||||||
ORDER BY position ASC, id ASC
|
ORDER BY position ASC, id ASC
|
||||||
`, [note.id]);
|
`, [note.id]);
|
||||||
note.todos = todos || [];
|
note.todos = todos || [];
|
||||||
|
|
||||||
|
const files = await getAll(`
|
||||||
|
SELECT id, filename, original_name, file_size, mime_type, created_at
|
||||||
|
FROM note_files
|
||||||
|
WHERE note_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [note.id]);
|
||||||
|
note.files = files || [];
|
||||||
|
note.files_count = files?.length || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(notes || []);
|
res.json(notes || []);
|
||||||
@@ -478,5 +487,171 @@ const searchNotes = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== FILE ATTACHMENTS ====================
|
||||||
|
|
||||||
|
// Configuration multer pour tous types de fichiers
|
||||||
|
const fileStorage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
const filesDir = path.join(__dirname, '../public/uploads/files');
|
||||||
|
// Créer le dossier s'il n'existe pas
|
||||||
|
if (!fs.existsSync(filesDir)) {
|
||||||
|
fs.mkdirSync(filesDir, { recursive: true });
|
||||||
|
}
|
||||||
|
cb(null, filesDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueName = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${file.originalname}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileUpload = multer({
|
||||||
|
storage: fileStorage,
|
||||||
|
limits: { fileSize: parseInt(process.env.MAX_FILE_SIZE) || 10 * 1024 * 1024 } // 10MB
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/notes/:id/files
|
||||||
|
* Ajouter un fichier à une note
|
||||||
|
*/
|
||||||
|
router.post('/:id/files', fileUpload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Aucun fichier fourni' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que la note appartient à l'utilisateur
|
||||||
|
const note = await getOne('SELECT id FROM notes WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||||||
|
if (!note) {
|
||||||
|
// Supprimer le fichier uploadé
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
return res.status(404).json({ error: 'Note non trouvée' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enregistrer le fichier dans la base de données
|
||||||
|
const result = await runQuery(`
|
||||||
|
INSERT INTO note_files (note_id, filename, original_name, file_size, mime_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`, [req.params.id, req.file.filename, req.file.originalname, req.file.size, req.file.mimetype]);
|
||||||
|
|
||||||
|
// Mettre à jour la date de modification de la note
|
||||||
|
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]);
|
||||||
|
|
||||||
|
logger.info(`Fichier ajouté à la note ${req.params.id}: ${req.file.originalname}`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Fichier ajouté avec succès',
|
||||||
|
file: {
|
||||||
|
id: result.id,
|
||||||
|
filename: req.file.filename,
|
||||||
|
original_name: req.file.originalname,
|
||||||
|
file_size: req.file.size,
|
||||||
|
mime_type: req.file.mimetype
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de l\'ajout du fichier:', error);
|
||||||
|
if (req.file && fs.existsSync(req.file.path)) {
|
||||||
|
fs.unlinkSync(req.file.path);
|
||||||
|
}
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notes/:id/files
|
||||||
|
* Liste les fichiers d'une note
|
||||||
|
*/
|
||||||
|
router.get('/:id/files', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Vérifier que la note appartient à l'utilisateur
|
||||||
|
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 files = await getAll(`
|
||||||
|
SELECT id, filename, original_name, file_size, mime_type, created_at
|
||||||
|
FROM note_files
|
||||||
|
WHERE note_id = ?
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
res.json(files);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la récupération des fichiers:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/notes/files/:fileId/download
|
||||||
|
* Télécharger un fichier
|
||||||
|
*/
|
||||||
|
router.get('/files/:fileId/download', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Vérifier que le fichier appartient à une note de l'utilisateur
|
||||||
|
const file = await getOne(`
|
||||||
|
SELECT nf.id, nf.filename, nf.original_name, nf.mime_type
|
||||||
|
FROM note_files nf
|
||||||
|
JOIN notes n ON nf.note_id = n.id
|
||||||
|
WHERE nf.id = ? AND n.user_id = ?
|
||||||
|
`, [req.params.fileId, req.user.id]);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return res.status(404).json({ error: 'Fichier non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '../public/uploads/files', file.filename);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'Fichier physique non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.download(filePath, file.original_name);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors du téléchargement du fichier:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/notes/files/:fileId
|
||||||
|
* Supprimer un fichier
|
||||||
|
*/
|
||||||
|
router.delete('/files/:fileId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Vérifier que le fichier appartient à une note de l'utilisateur
|
||||||
|
const file = await getOne(`
|
||||||
|
SELECT nf.id, nf.note_id, nf.filename
|
||||||
|
FROM note_files nf
|
||||||
|
JOIN notes n ON nf.note_id = n.id
|
||||||
|
WHERE nf.id = ? AND n.user_id = ?
|
||||||
|
`, [req.params.fileId, req.user.id]);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return res.status(404).json({ error: 'Fichier non trouvé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer le fichier physique
|
||||||
|
const filePath = path.join(__dirname, '../public/uploads/files', file.filename);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supprimer de la base de données
|
||||||
|
await runQuery('DELETE FROM note_files WHERE id = ?', [req.params.fileId]);
|
||||||
|
|
||||||
|
// Mettre à jour la date de modification de la note
|
||||||
|
await runQuery('UPDATE notes SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [file.note_id]);
|
||||||
|
|
||||||
|
logger.info(`Fichier supprimé: ${file.filename}`);
|
||||||
|
|
||||||
|
res.json({ message: 'Fichier supprimé avec succès' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Erreur lors de la suppression du fichier:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
module.exports.searchNotes = searchNotes;
|
module.exports.searchNotes = searchNotes;
|
||||||
Reference in new issue
Block a user