adding file upload & tags
This commit is contained in:
+128
-2
@@ -73,6 +73,16 @@ try {
|
|||||||
case 'registration':
|
case 'registration':
|
||||||
handleRegistration($method, $db);
|
handleRegistration($method, $db);
|
||||||
break;
|
break;
|
||||||
|
case 'attachments':
|
||||||
|
handleAttachments($method, $id, $db);
|
||||||
|
break;
|
||||||
|
case 'tags':
|
||||||
|
if ($method === 'GET') {
|
||||||
|
echo json_encode($db->query("SELECT DISTINCT tag FROM event_tags ORDER BY tag")->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
} else {
|
||||||
|
http_response_code(405);
|
||||||
|
}
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo json_encode(['error' => 'Not found']);
|
echo json_encode(['error' => 'Not found']);
|
||||||
@@ -315,6 +325,23 @@ function handleTeams($method, $id, $db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadEventTags($eventId, $db) {
|
||||||
|
$tstmt = $db->prepare("SELECT tag FROM event_tags WHERE event_id = ? ORDER BY tag");
|
||||||
|
$tstmt->execute([$eventId]);
|
||||||
|
return array_column($tstmt->fetchAll(PDO::FETCH_ASSOC), 'tag');
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEventTags($eventId, $tags, $db) {
|
||||||
|
$db->prepare("DELETE FROM event_tags WHERE event_id = ?")->execute([$eventId]);
|
||||||
|
if (!empty($tags)) {
|
||||||
|
$istmt = $db->prepare("INSERT IGNORE INTO event_tags (event_id, tag) VALUES (?, ?)");
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$tag = trim($tag);
|
||||||
|
if ($tag !== '') $istmt->execute([$eventId, $tag]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleEvents($method, $id, $db) {
|
function handleEvents($method, $id, $db) {
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
@@ -330,19 +357,28 @@ function handleEvents($method, $id, $db) {
|
|||||||
$cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC");
|
$cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC");
|
||||||
$cstmt->execute([$id]);
|
$cstmt->execute([$id]);
|
||||||
$event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC);
|
$event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$event['tags'] = loadEventTags($id, $db);
|
||||||
}
|
}
|
||||||
echo json_encode($event);
|
echo json_encode($event);
|
||||||
} else {
|
} else {
|
||||||
$teamFilter = $_GET['team_id'] ?? null;
|
$teamFilter = $_GET['team_id'] ?? null;
|
||||||
|
$tagFilter = $_GET['tag'] ?? null;
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT e.*, t.name AS team_name, t.color AS team_color
|
SELECT e.*, t.name AS team_name, t.color AS team_color
|
||||||
FROM events e JOIN teams t ON e.team_id = t.id
|
FROM events e JOIN teams t ON e.team_id = t.id
|
||||||
";
|
";
|
||||||
$params = [];
|
$params = [];
|
||||||
|
$conditions = [];
|
||||||
if ($teamFilter) {
|
if ($teamFilter) {
|
||||||
$sql .= " WHERE e.team_id = ?";
|
$conditions[] = "e.team_id = ?";
|
||||||
$params[] = $teamFilter;
|
$params[] = $teamFilter;
|
||||||
}
|
}
|
||||||
|
if ($tagFilter) {
|
||||||
|
$sql .= " JOIN event_tags et ON e.id = et.event_id";
|
||||||
|
$conditions[] = "et.tag = ?";
|
||||||
|
$params[] = $tagFilter;
|
||||||
|
}
|
||||||
|
if ($conditions) $sql .= " WHERE " . implode(' AND ', $conditions);
|
||||||
$sql .= " ORDER BY e.occurred_at DESC";
|
$sql .= " ORDER BY e.occurred_at DESC";
|
||||||
$stmt = $db->prepare($sql);
|
$stmt = $db->prepare($sql);
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
@@ -351,6 +387,7 @@ function handleEvents($method, $id, $db) {
|
|||||||
$cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC");
|
$cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC");
|
||||||
$cstmt->execute([$event['id']]);
|
$cstmt->execute([$event['id']]);
|
||||||
$event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC);
|
$event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$event['tags'] = loadEventTags($event['id'], $db);
|
||||||
}
|
}
|
||||||
echo json_encode($events);
|
echo json_encode($events);
|
||||||
}
|
}
|
||||||
@@ -369,7 +406,11 @@ function handleEvents($method, $id, $db) {
|
|||||||
$data['event_type'] ?? 'general',
|
$data['event_type'] ?? 'general',
|
||||||
$data['occurred_at'] ?? date('Y-m-d H:i:s')
|
$data['occurred_at'] ?? date('Y-m-d H:i:s')
|
||||||
]);
|
]);
|
||||||
echo json_encode(['id' => $db->lastInsertId()]);
|
$eventId = $db->lastInsertId();
|
||||||
|
if (isset($data['tags']) && is_array($data['tags'])) {
|
||||||
|
saveEventTags($eventId, $data['tags'], $db);
|
||||||
|
}
|
||||||
|
echo json_encode(['id' => $eventId]);
|
||||||
break;
|
break;
|
||||||
case 'PUT':
|
case 'PUT':
|
||||||
if ($id) {
|
if ($id) {
|
||||||
@@ -387,11 +428,21 @@ function handleEvents($method, $id, $db) {
|
|||||||
$stmt = $db->prepare("UPDATE events SET " . implode(', ', $fields) . " WHERE id = ?");
|
$stmt = $db->prepare("UPDATE events SET " . implode(', ', $fields) . " WHERE id = ?");
|
||||||
$stmt->execute($params);
|
$stmt->execute($params);
|
||||||
}
|
}
|
||||||
|
if (isset($data['tags']) && is_array($data['tags'])) {
|
||||||
|
saveEventTags($id, $data['tags'], $db);
|
||||||
|
}
|
||||||
echo json_encode(['updated' => true]);
|
echo json_encode(['updated' => true]);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'DELETE':
|
case 'DELETE':
|
||||||
if ($id) {
|
if ($id) {
|
||||||
|
// Delete attachments from disk
|
||||||
|
$stmt = $db->prepare("SELECT stored_name FROM file_attachments WHERE event_id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $att) {
|
||||||
|
$path = '/var/www/uploads/' . $att['stored_name'];
|
||||||
|
if (file_exists($path)) unlink($path);
|
||||||
|
}
|
||||||
$stmt = $db->prepare("DELETE FROM events WHERE id = ?");
|
$stmt = $db->prepare("DELETE FROM events WHERE id = ?");
|
||||||
$stmt->execute([$id]);
|
$stmt->execute([$id]);
|
||||||
echo json_encode(['deleted' => true]);
|
echo json_encode(['deleted' => true]);
|
||||||
@@ -612,6 +663,81 @@ function handleDocuments($method, $id, $db) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleAttachments($method, $id, $db) {
|
||||||
|
$username = $_SESSION['neptune_username'] ?? 'Unknown';
|
||||||
|
$uploadDir = '/var/www/uploads/';
|
||||||
|
|
||||||
|
switch ($method) {
|
||||||
|
case 'GET':
|
||||||
|
$eventId = $_GET['event_id'] ?? null;
|
||||||
|
if ($eventId) {
|
||||||
|
$stmt = $db->prepare("SELECT id, event_id, original_name, stored_name, mime_type, file_size, uploaded_by, created_at FROM file_attachments WHERE event_id = ? ORDER BY created_at ASC");
|
||||||
|
$stmt->execute([$eventId]);
|
||||||
|
echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
} else {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'event_id required']);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'POST':
|
||||||
|
if (!isset($_FILES['file']) || $_FILES['file']['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Upload failed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$eventId = $_POST['event_id'] ?? null;
|
||||||
|
if (!$eventId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'event_id required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = $_FILES['file'];
|
||||||
|
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
|
||||||
|
$storedName = uniqid('att_', true) . '.' . $ext;
|
||||||
|
$dest = $uploadDir . $storedName;
|
||||||
|
|
||||||
|
if (!is_dir($uploadDir)) {
|
||||||
|
mkdir($uploadDir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to save file']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
INSERT INTO file_attachments (event_id, original_name, stored_name, mime_type, file_size, uploaded_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
");
|
||||||
|
$stmt->execute([
|
||||||
|
$eventId,
|
||||||
|
$file['name'],
|
||||||
|
$storedName,
|
||||||
|
$file['type'] ?: 'application/octet-stream',
|
||||||
|
$file['size'],
|
||||||
|
$username
|
||||||
|
]);
|
||||||
|
echo json_encode(['id' => $db->lastInsertId()]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'DELETE':
|
||||||
|
if ($id) {
|
||||||
|
$stmt = $db->prepare("SELECT stored_name FROM file_attachments WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$att = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($att) {
|
||||||
|
$path = $uploadDir . $att['stored_name'];
|
||||||
|
if (file_exists($path)) unlink($path);
|
||||||
|
$db->prepare("DELETE FROM file_attachments WHERE id = ?")->execute([$id]);
|
||||||
|
}
|
||||||
|
echo json_encode(['deleted' => true]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleShapes($method, $id, $db) {
|
function handleShapes($method, $id, $db) {
|
||||||
switch ($method) {
|
switch ($method) {
|
||||||
case 'GET':
|
case 'GET':
|
||||||
|
|||||||
@@ -58,6 +58,22 @@ function migrate($db) {
|
|||||||
setting_key VARCHAR(100) PRIMARY KEY,
|
setting_key VARCHAR(100) PRIMARY KEY,
|
||||||
setting_value TEXT NOT NULL
|
setting_value TEXT NOT NULL
|
||||||
)");
|
)");
|
||||||
|
try { $db->exec("CREATE TABLE IF NOT EXISTS file_attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_id INT NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
stored_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
file_size INT,
|
||||||
|
uploaded_by VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)"); } catch (Exception $e) {}
|
||||||
|
try { $db->exec("CREATE TABLE IF NOT EXISTS event_tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_id INT NOT NULL,
|
||||||
|
tag VARCHAR(50) NOT NULL,
|
||||||
|
UNIQUE KEY unique_event_tag (event_id, tag)
|
||||||
|
)"); } catch (Exception $e) {}
|
||||||
try {
|
try {
|
||||||
$stmt = $db->prepare("SELECT COUNT(*) as c FROM neptune_settings WHERE setting_key = 'registration_enabled'");
|
$stmt = $db->prepare("SELECT COUNT(*) as c FROM neptune_settings WHERE setting_key = 'registration_enabled'");
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/var/www/html
|
- ./frontend:/var/www/html
|
||||||
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
- uploads_data:/var/www/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
- php
|
- php
|
||||||
networks:
|
networks:
|
||||||
@@ -19,6 +20,7 @@ services:
|
|||||||
MYSQL_ROOT_PASSWORD: neptune_root_pass
|
MYSQL_ROOT_PASSWORD: neptune_root_pass
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/var/www/backend
|
- ./backend:/var/www/backend
|
||||||
|
- uploads_data:/var/www/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql:
|
mysql:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -44,6 +46,7 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
mysql_data:
|
||||||
|
uploads_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
neptune:
|
neptune:
|
||||||
@@ -3,7 +3,10 @@ FROM php:8.2-fpm
|
|||||||
RUN apt-get update && apt-get install -y libcurl4-openssl-dev && \
|
RUN apt-get update && apt-get install -y libcurl4-openssl-dev && \
|
||||||
docker-php-ext-install pdo pdo_mysql curl && \
|
docker-php-ext-install pdo pdo_mysql curl && \
|
||||||
mkdir -p /tmp/sessions && \
|
mkdir -p /tmp/sessions && \
|
||||||
chmod 777 /tmp/sessions
|
chmod 777 /tmp/sessions && \
|
||||||
|
mkdir -p /var/www/uploads && \
|
||||||
|
chmod 755 /var/www/uploads && \
|
||||||
|
chown www-data:www-data /var/www/uploads
|
||||||
|
|
||||||
COPY docker/php.ini /usr/local/etc/php/conf.d/neptune.ini
|
COPY docker/php.ini /usr/local/etc/php/conf.d/neptune.ini
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,26 @@ CREATE TABLE IF NOT EXISTS documents (
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS file_attachments (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_id INT NOT NULL,
|
||||||
|
original_name VARCHAR(255) NOT NULL,
|
||||||
|
stored_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
file_size INT,
|
||||||
|
uploaded_by VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS event_tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
event_id INT NOT NULL,
|
||||||
|
tag VARCHAR(50) NOT NULL,
|
||||||
|
UNIQUE KEY unique_event_tag (event_id, tag),
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
INSERT IGNORE INTO teams (name, color) VALUES
|
INSERT IGNORE INTO teams (name, color) VALUES
|
||||||
('Blue Team', '#0d6efd'),
|
('Blue Team', '#0d6efd'),
|
||||||
('Red Team', '#dc3545'),
|
('Red Team', '#dc3545'),
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ server {
|
|||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /var/www/uploads/;
|
||||||
|
add_header Content-Disposition 'inline';
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -3,4 +3,6 @@ session.gc_maxlifetime = 86400
|
|||||||
session.cookie_lifetime = 0
|
session.cookie_lifetime = 0
|
||||||
session.use_strict_mode = 1
|
session.use_strict_mode = 1
|
||||||
session.cookie_httponly = 1
|
session.cookie_httponly = 1
|
||||||
session.cookie_samesite = Lax
|
session.cookie_samesite = Lax
|
||||||
|
upload_max_filesize = 50M
|
||||||
|
post_max_size = 50M
|
||||||
@@ -256,4 +256,28 @@ kbd {
|
|||||||
font-size: .65rem;
|
font-size: .65rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .03em;
|
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
@@ -152,6 +152,7 @@ function initApp() {
|
|||||||
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
document.getElementById('addUserBtn').addEventListener('click', addUser);
|
||||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||||
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
||||||
|
document.getElementById('tagFilter').addEventListener('change', renderTimeline);
|
||||||
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
|
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
|
||||||
document.getElementById('saveDocument').addEventListener('click', saveDocument);
|
document.getElementById('saveDocument').addEventListener('click', saveDocument);
|
||||||
document.getElementById('documentModal').addEventListener('hidden.bs.modal', () => {
|
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('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('mousedown', onMouseDown);
|
||||||
canvas.addEventListener('mousemove', onMouseMove);
|
canvas.addEventListener('mousemove', onMouseMove);
|
||||||
canvas.addEventListener('mouseup', onMouseUp);
|
canvas.addEventListener('mouseup', onMouseUp);
|
||||||
@@ -250,19 +267,36 @@ async function loadTeams() {
|
|||||||
|
|
||||||
async function loadEvents() {
|
async function loadEvents() {
|
||||||
events = await apiFetch('events');
|
events = await apiFetch('events');
|
||||||
|
loadAllTags();
|
||||||
renderTimeline();
|
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() {
|
function renderTimeline() {
|
||||||
const container = document.getElementById('timelineContainer');
|
const container = document.getElementById('timelineContainer');
|
||||||
const teamFilter = document.getElementById('teamFilter').value;
|
const teamFilter = document.getElementById('teamFilter').value;
|
||||||
|
const tagFilter = document.getElementById('tagFilter').value;
|
||||||
const search = document.getElementById('searchEvents').value.toLowerCase();
|
const search = document.getElementById('searchEvents').value.toLowerCase();
|
||||||
|
|
||||||
let filtered = events;
|
let filtered = events;
|
||||||
if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter);
|
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 =>
|
if (search) filtered = filtered.filter(e =>
|
||||||
e.title.toLowerCase().includes(search) ||
|
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) {
|
if (!filtered.length) {
|
||||||
@@ -287,6 +321,7 @@ function renderTimeline() {
|
|||||||
</div>
|
</div>
|
||||||
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
|
<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>' : ''}
|
${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="d-flex justify-content-between align-items-center mt-1">
|
||||||
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
|
<div class="doc-links-inline" id="docLinks-${e.id}">${renderInlineDocLinks(e.description || '')}</div>
|
||||||
<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>
|
<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>
|
</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="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="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>
|
<div class="comment-log">${renderComments(e)}</div>
|
||||||
@@ -306,6 +342,11 @@ function renderTimeline() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
filtered.forEach(e => {
|
||||||
|
const container = document.getElementById('attachments-' + e.id);
|
||||||
|
if (container) renderAttachments(e.id, container);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDocLinks(text) {
|
function renderDocLinks(text) {
|
||||||
@@ -450,22 +491,123 @@ async function deleteEvent(id, btn) {
|
|||||||
loadEvents();
|
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() {
|
async function saveEvent() {
|
||||||
|
const tags = parseEventTags();
|
||||||
const data = {
|
const data = {
|
||||||
team_id: document.getElementById('eventTeam').value,
|
team_id: document.getElementById('eventTeam').value,
|
||||||
title: document.getElementById('eventTitle').value,
|
title: document.getElementById('eventTitle').value,
|
||||||
description: document.getElementById('eventDescription').value,
|
description: document.getElementById('eventDescription').value,
|
||||||
severity: document.getElementById('eventSeverity').value,
|
severity: document.getElementById('eventSeverity').value,
|
||||||
event_type: document.getElementById('eventType').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');
|
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();
|
bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide();
|
||||||
document.getElementById('eventForm').reset();
|
document.getElementById('eventForm').reset();
|
||||||
|
document.getElementById('eventFilePreview').innerHTML = '';
|
||||||
|
document.getElementById('eventTagsDisplay').innerHTML = '';
|
||||||
|
document.getElementById('eventTagsInput').value = '';
|
||||||
|
fileInput.value = '';
|
||||||
loadEvents();
|
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 ====================
|
// ==================== NETWORK MAP DATA ====================
|
||||||
async function loadNetworkData() {
|
async function loadNetworkData() {
|
||||||
const [n, l, s] = await Promise.all([
|
const [n, l, s] = await Promise.all([
|
||||||
|
|||||||
+18
-2
@@ -63,12 +63,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-3">
|
||||||
<select class="form-select form-select-sm" id="teamFilter">
|
<select class="form-select form-select-sm" id="teamFilter">
|
||||||
<option value="">All Teams</option>
|
<option value="">All Teams</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-8">
|
<div class="col-md-3">
|
||||||
|
<select class="form-select form-select-sm" id="tagFilter">
|
||||||
|
<option value="">All Tags</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
<input type="text" class="form-control form-control-sm" id="searchEvents" placeholder="Search events...">
|
<input type="text" class="form-control form-control-sm" id="searchEvents" placeholder="Search events...">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -240,6 +245,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Tags</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="eventTagsInput" placeholder="e.g. phishing, ransomware, ioc">
|
||||||
|
<small class="text-secondary">Comma-separated tags</small>
|
||||||
|
<div id="eventTagsDisplay" class="d-flex flex-wrap gap-1 mt-1"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">File Attachments</label>
|
||||||
|
<input type="file" class="form-control form-control-sm" id="eventFileInput" multiple>
|
||||||
|
<div id="eventFilePreview" class="mt-1"></div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<div class="modal-footer border-secondary">
|
||||||
|
|||||||
Reference in New Issue
Block a user