adding file upload & tags

This commit is contained in:
2026-05-12 09:52:15 +02:00
parent 494f30d6c3
commit 988da11f80
10 changed files with 366 additions and 9 deletions
+24
View File
@@ -256,4 +256,28 @@ kbd {
font-size: .65rem;
text-transform: uppercase;
letter-spacing: .03em;
}
/* Attachments */
.attachments-list {
border-top: 1px solid var(--neptune-border);
padding-top: .35rem;
margin-top: .35rem;
}
.attachment-item {
border-bottom: 1px solid rgba(30, 42, 69, 0.5);
}
.attachment-item:last-child {
border-bottom: none;
}
.attachment-item a {
color: var(--neptune-accent);
}
.attachment-item a:hover {
color: #60a5fa;
text-decoration: underline !important;
}
+145 -3
View File
@@ -152,6 +152,7 @@ function initApp() {
document.getElementById('addUserBtn').addEventListener('click', addUser);
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
document.getElementById('tagFilter').addEventListener('change', renderTimeline);
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
document.getElementById('saveDocument').addEventListener('click', saveDocument);
document.getElementById('documentModal').addEventListener('hidden.bs.modal', () => {
@@ -166,6 +167,22 @@ function initApp() {
document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2);
});
document.getElementById('eventFileInput').addEventListener('change', (e) => {
const preview = document.getElementById('eventFilePreview');
const files = e.target.files;
if (!files.length) { preview.innerHTML = ''; return; }
preview.innerHTML = Array.from(files).map(f =>
`<div class="small text-secondary"><i class="fas ${getFileIcon(f.type, f.name)} me-1"></i>${esc(f.name)} (${formatFileSize(f.size)})</div>`
).join('');
});
document.getElementById('eventTagsInput').addEventListener('input', renderEventTagsPreview);
document.getElementById('eventModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('eventTagsDisplay').innerHTML = '';
document.getElementById('eventTagsInput').value = '';
});
canvas.addEventListener('mousedown', onMouseDown);
canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp);
@@ -250,19 +267,36 @@ async function loadTeams() {
async function loadEvents() {
events = await apiFetch('events');
loadAllTags();
renderTimeline();
}
let allTags = [];
async function loadAllTags() {
try {
allTags = await apiFetch('tags');
const sel = document.getElementById('tagFilter');
if (!sel) return;
const current = sel.value;
sel.innerHTML = '<option value="">All Tags</option>' + allTags.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
sel.value = current;
} catch (e) {}
}
function renderTimeline() {
const container = document.getElementById('timelineContainer');
const teamFilter = document.getElementById('teamFilter').value;
const tagFilter = document.getElementById('tagFilter').value;
const search = document.getElementById('searchEvents').value.toLowerCase();
let filtered = events;
if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter);
if (tagFilter) filtered = filtered.filter(e => e.tags && e.tags.includes(tagFilter));
if (search) filtered = filtered.filter(e =>
e.title.toLowerCase().includes(search) ||
(e.description && e.description.toLowerCase().includes(search))
(e.description && e.description.toLowerCase().includes(search)) ||
(e.tags && e.tags.some(t => t.includes(search)))
);
if (!filtered.length) {
@@ -287,6 +321,7 @@ function renderTimeline() {
</div>
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
${e.description ? '<p class="mb-1 small text-secondary">' + renderDocLinks(e.description) + '</p>' : ''}
${renderTimelineTags(e.tags)}
<div class="d-flex justify-content-between align-items-center mt-1">
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
<div>
@@ -294,6 +329,7 @@ function renderTimeline() {
<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="deleteEvent(${e.id}, this)" title="Delete event" style="font-size:.7rem;"><i class="fas fa-trash"></i></button>
</div>
</div>
<div class="attachments-list mt-1" id="attachments-${e.id}" data-event-id="${e.id}"></div>
<div class="mt-2" id="comments-${e.id}">
<div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold"><i class="fas fa-comment-dots me-1"></i>Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}</small></div>
<div class="comment-log">${renderComments(e)}</div>
@@ -306,6 +342,11 @@ function renderTimeline() {
</div>
</div>`;
}).join('');
filtered.forEach(e => {
const container = document.getElementById('attachments-' + e.id);
if (container) renderAttachments(e.id, container);
});
}
function renderDocLinks(text) {
@@ -450,22 +491,123 @@ async function deleteEvent(id, btn) {
loadEvents();
}
async function uploadAttachment(eventId, file) {
const formData = new FormData();
formData.append('file', file);
formData.append('event_id', eventId);
await fetch('/api/attachments', { method: 'POST', body: formData });
}
async function loadAttachments(eventId) {
try {
const res = await fetch('/api/attachments?event_id=' + eventId);
return await res.json();
} catch (e) {
return [];
}
}
async function deleteAttachment(id, el) {
if (!confirm('Delete this file?')) return;
await apiFetch('attachments/' + id, { method: 'DELETE' });
const container = el.closest('.attachments-list');
if (container) {
const eventId = container.dataset.eventId;
renderAttachments(eventId, container);
}
}
async function renderAttachments(eventId, container) {
container.innerHTML = '<small class="text-secondary"><i class="fas fa-spinner fa-spin me-1"></i>Loading...</small>';
const attachments = await loadAttachments(eventId);
if (!attachments.length) {
container.innerHTML = '';
return;
}
container.innerHTML = attachments.map(a => {
const icon = getFileIcon(a.mime_type, a.original_name);
const size = formatFileSize(a.file_size);
return `<div class="attachment-item d-flex align-items-center justify-content-between py-1">
<div class="d-flex align-items-center">
<i class="fas ${icon} me-2" style="font-size:.85rem;"></i>
<a href="/uploads/${a.stored_name}" target="_blank" class="small text-decoration-none" style="word-break:break-all;">${esc(a.original_name)}</a>
<small class="text-secondary ms-2">(${size})</small>
</div>
<button class="btn btn-outline-danger btn-sm py-0 px-1 ms-2" onclick="deleteAttachment(${a.id}, this)" title="Delete file" style="font-size:.65rem;"><i class="fas fa-times"></i></button>
</div>`;
}).join('');
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let size = bytes;
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
return size.toFixed(1) + ' ' + units[i];
}
function getFileIcon(mime, name) {
if (mime.startsWith('image/')) return 'fa-file-image';
if (mime === 'application/pdf') return 'fa-file-pdf';
if (mime.startsWith('text/')) return 'fa-file-alt';
if (mime.includes('spreadsheet') || mime.includes('excel') || name.endsWith('.csv') || name.endsWith('.xls') || name.endsWith('.xlsx')) return 'fa-file-excel';
if (mime.includes('document') || mime.includes('word') || name.endsWith('.doc') || name.endsWith('.docx')) return 'fa-file-word';
if (name.endsWith('.md')) return 'fa-file-alt';
return 'fa-file';
}
async function saveEvent() {
const tags = parseEventTags();
const data = {
team_id: document.getElementById('eventTeam').value,
title: document.getElementById('eventTitle').value,
description: document.getElementById('eventDescription').value,
severity: document.getElementById('eventSeverity').value,
event_type: document.getElementById('eventType').value,
occurred_at: new Date().toISOString().slice(0, 16)
occurred_at: new Date().toISOString().slice(0, 16),
tags: tags
};
if (!data.title) return alert('Title required');
await apiFetch('events', { method: 'POST', body: JSON.stringify(data) });
const result = await apiFetch('events', { method: 'POST', body: JSON.stringify(data) });
const eventId = result.id;
const fileInput = document.getElementById('eventFileInput');
if (fileInput.files.length > 0) {
for (const file of fileInput.files) {
await uploadAttachment(eventId, file);
}
}
bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide();
document.getElementById('eventForm').reset();
document.getElementById('eventFilePreview').innerHTML = '';
document.getElementById('eventTagsDisplay').innerHTML = '';
document.getElementById('eventTagsInput').value = '';
fileInput.value = '';
loadEvents();
}
function parseEventTags() {
const input = document.getElementById('eventTagsInput');
if (!input) return [];
return input.value.split(',').map(t => t.trim().toLowerCase()).filter(t => t.length > 0);
}
function renderEventTagsPreview() {
const tags = parseEventTags();
const container = document.getElementById('eventTagsDisplay');
if (!container) return;
container.innerHTML = tags.map(t => `<span class="badge bg-secondary me-1" style="font-size:.7rem;">${esc(t)}</span>`).join('');
}
function renderTimelineTags(tags) {
if (!tags || !tags.length) return '';
return '<div class="d-flex flex-wrap gap-1 mb-1">' + tags.map(t =>
`<span class="badge" style="font-size:.6rem;background:#1e2a45;color:#94a3b8;border:1px solid #2a3a60;">${esc(t)}</span>`
).join('') + '</div>';
}
// ==================== NETWORK MAP DATA ====================
async function loadNetworkData() {
const [n, l, s] = await Promise.all([