diff --git a/backend/api/index.php b/backend/api/index.php index 9fec74f..cf67622 100644 --- a/backend/api/index.php +++ b/backend/api/index.php @@ -73,6 +73,16 @@ try { case 'registration': handleRegistration($method, $db); 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: http_response_code(404); 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) { switch ($method) { 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->execute([$id]); $event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC); + $event['tags'] = loadEventTags($id, $db); } echo json_encode($event); } else { $teamFilter = $_GET['team_id'] ?? null; + $tagFilter = $_GET['tag'] ?? null; $sql = " SELECT e.*, t.name AS team_name, t.color AS team_color FROM events e JOIN teams t ON e.team_id = t.id "; $params = []; + $conditions = []; if ($teamFilter) { - $sql .= " WHERE e.team_id = ?"; + $conditions[] = "e.team_id = ?"; $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"; $stmt = $db->prepare($sql); $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->execute([$event['id']]); $event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC); + $event['tags'] = loadEventTags($event['id'], $db); } echo json_encode($events); } @@ -369,7 +406,11 @@ function handleEvents($method, $id, $db) { $data['event_type'] ?? 'general', $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; case 'PUT': if ($id) { @@ -387,11 +428,21 @@ function handleEvents($method, $id, $db) { $stmt = $db->prepare("UPDATE events SET " . implode(', ', $fields) . " WHERE id = ?"); $stmt->execute($params); } + if (isset($data['tags']) && is_array($data['tags'])) { + saveEventTags($id, $data['tags'], $db); + } echo json_encode(['updated' => true]); } break; case 'DELETE': 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->execute([$id]); 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) { switch ($method) { case 'GET': diff --git a/backend/config/database.php b/backend/config/database.php index 4c60305..5a9d847 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -58,6 +58,22 @@ function migrate($db) { setting_key VARCHAR(100) PRIMARY KEY, 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 { $stmt = $db->prepare("SELECT COUNT(*) as c FROM neptune_settings WHERE setting_key = 'registration_enabled'"); $stmt->execute(); diff --git a/docker-compose.yml b/docker-compose.yml index 760924d..9584654 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,7 @@ services: volumes: - ./frontend:/var/www/html - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf + - uploads_data:/var/www/uploads depends_on: - php networks: @@ -19,6 +20,7 @@ services: MYSQL_ROOT_PASSWORD: neptune_root_pass volumes: - ./backend:/var/www/backend + - uploads_data:/var/www/uploads depends_on: mysql: condition: service_healthy @@ -44,6 +46,7 @@ services: volumes: mysql_data: + uploads_data: networks: neptune: \ No newline at end of file diff --git a/docker/Dockerfile.php b/docker/Dockerfile.php index fed7843..99c7df9 100644 --- a/docker/Dockerfile.php +++ b/docker/Dockerfile.php @@ -3,7 +3,10 @@ FROM php:8.2-fpm RUN apt-get update && apt-get install -y libcurl4-openssl-dev && \ docker-php-ext-install pdo pdo_mysql curl && \ 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 diff --git a/docker/init.sql b/docker/init.sql index 9758ae1..62b04d1 100644 --- a/docker/init.sql +++ b/docker/init.sql @@ -76,6 +76,26 @@ CREATE TABLE IF NOT EXISTS documents ( 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 ('Blue Team', '#0d6efd'), ('Red Team', '#dc3545'), diff --git a/docker/nginx.conf b/docker/nginx.conf index d93fec1..31899cd 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -14,6 +14,11 @@ server { include fastcgi_params; } + location /uploads/ { + alias /var/www/uploads/; + add_header Content-Disposition 'inline'; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/docker/php.ini b/docker/php.ini index 580d783..a2fccd3 100644 --- a/docker/php.ini +++ b/docker/php.ini @@ -3,4 +3,6 @@ session.gc_maxlifetime = 86400 session.cookie_lifetime = 0 session.use_strict_mode = 1 session.cookie_httponly = 1 -session.cookie_samesite = Lax \ No newline at end of file +session.cookie_samesite = Lax +upload_max_filesize = 50M +post_max_size = 50M \ No newline at end of file diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index f05d029..c3c6d5b 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -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; } \ No newline at end of file diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js index b3279e1..0aa216e 100644 --- a/frontend/assets/js/app.js +++ b/frontend/assets/js/app.js @@ -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 => + `
' + renderDocLinks(e.description) + '
' : ''} + ${renderTimelineTags(e.tags)}