From c39667e368676e64245c69639f99515bf4b5c629 Mon Sep 17 00:00:00 2001 From: patrickzerhusen Date: Sat, 25 Apr 2026 14:30:58 +0200 Subject: [PATCH] photos and comments functionality for contributions, moderation page functionality pending --- .gitignore | 5 +- migrations/006_comments_and_photos.sql | 35 ++++ public/api/contributions.php | 194 ++++++++++++++++++++- public/index.php | 9 + public/js/app.js | 224 +++++++++++++++++++++---- public/styles.css | 91 ++++++++++ public/uploads/.htaccess | 7 + public/uploads/photos/.gitkeep | 0 8 files changed, 523 insertions(+), 42 deletions(-) create mode 100644 migrations/006_comments_and_photos.sql create mode 100644 public/uploads/.htaccess create mode 100644 public/uploads/photos/.gitkeep diff --git a/.gitignore b/.gitignore index e89a32f..8f04f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ .env .vscode/ *.log -scripts \ No newline at end of file +scripts + +public/uploads/photos/* +!public/uploads/photos/.gitkeep \ No newline at end of file diff --git a/migrations/006_comments_and_photos.sql b/migrations/006_comments_and_photos.sql new file mode 100644 index 0000000..97366f6 --- /dev/null +++ b/migrations/006_comments_and_photos.sql @@ -0,0 +1,35 @@ +-- ===================================================================== +-- Migration 006: Comments Table and Photo Support +-- ===================================================================== + + +-- --------------------------------------------------------------------- +-- Block 1: Creates Table "comments" +-- Stores Comments on Contributions. Comments is linked to +-- Contributions and identified by browser_id. +-- --------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS comments ( + comment_id SERIAL PRIMARY KEY, + contribution_id INTEGER NOT NULL REFERENCES contributions(contribution_id) ON DELETE CASCADE, + author_name VARCHAR(100) NOT NULL, + browser_id VARCHAR(36) DEFAULT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + + +-- --------------------------------------------------------------------- +-- Block 2: Indexes for fast Comment Queries +-- --------------------------------------------------------------------- +CREATE INDEX idx_comments_contribution ON comments(contribution_id); +CREATE INDEX idx_comments_browser ON comments(browser_id); + + +-- --------------------------------------------------------------------- +-- Block 3: Adds Photo Path Column to Contributions +-- Stores relative Path to uploaded Photo File. +-- --------------------------------------------------------------------- +ALTER TABLE contributions + ADD COLUMN photo_path VARCHAR(255) DEFAULT NULL; + +COMMENT ON COLUMN contributions.photo_path IS 'Relative Path to uploaded Photo. NULL = no Photo.'; \ No newline at end of file diff --git a/public/api/contributions.php b/public/api/contributions.php index 80aadb3..2c1620f 100644 --- a/public/api/contributions.php +++ b/public/api/contributions.php @@ -47,13 +47,22 @@ switch ($action) { case 'delete_news': handle_delete_news($input); break; + case 'read_comments': + handle_read_comments($input); + break; + case 'create_comment': + handle_create_comment($input); + break; + case 'delete_comment': + handle_delete_comment($input); + break; default: error_response('Unknown Action. Supported Actions are read, create, update, delete, vote.'); } // ===================================================================== -// Action Handlers +// Action Handlers for Contributions // ===================================================================== @@ -152,6 +161,11 @@ function handle_read($input) { // Required: municipality_id, geom, geom_type, category, title, author_name // Optional: description // --------------------------------------------------------------------- +// --------------------------------------------------------------------- +// CREATE: Inserts new Contributions with optional Photo Upload +// Required: municipality_id, geom, geom_type, category, title, author_name +// Optional: description, browser_id, photo (File Upload) +// --------------------------------------------------------------------- function handle_create($input) { $pdo = get_db(); @@ -175,14 +189,23 @@ function handle_create($input) { error_response('Invalid GeoJSON in Geometry Field.'); } + // Handles Photo Upload + $photo_path = null; + if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) { + $photo_path = handle_photo_upload($_FILES['photo']); + if (!$photo_path) { + error_response('Photo Upload failed. JPG, PNG, GIF and WebP up to 5 MB are allowed.'); + } + } + // Prepared SQL Statement try { $stmt = $pdo->prepare(" INSERT INTO contributions - (municipality_id, geom, geom_type, category, title, description, author_name, browser_id) + (municipality_id, geom, geom_type, category, title, description, author_name, browser_id, photo_path) VALUES (:mid, ST_SetSRID(ST_GeomFromGeoJSON(:geom), 4326), :geom_type, - :category, :title, :description, :author_name, :browser_id) + :category, :title, :description, :author_name, :browser_id, :photo_path) "); $stmt->execute([ @@ -193,7 +216,8 @@ function handle_create($input) { ':title' => $input['title'], ':description' => $input['description'] ?? '', ':author_name' => $input['author_name'], - ':browser_id' => $input['browser_id'] ?? null + ':browser_id' => $input['browser_id'] ?? null, + ':photo_path' => $photo_path ]); json_response([ @@ -394,6 +418,10 @@ function handle_vote($input) { } +// ===================================================================== +// Action Handlers for News +// ===================================================================== + // --------------------------------------------------------------------- // CREATE NEWS: Inserts new News Entry // Required: municipality_id, title, content @@ -475,4 +503,162 @@ function handle_delete_news($input) { } catch (PDOException $e) { error_response('Database Error: ' . $e->getMessage(), 500); } +} + + +// ===================================================================== +// Action Handlers for Photos +// ===================================================================== + +// --------------------------------------------------------------------- +// PHOTO UPLOAD: Validates and Saves uploaded Photo Files +// Returns relative Path on Success, null on Failure. +// Allowed: JPG, PNG, GIF, WebP. with maximum Size of 5 MB. +// --------------------------------------------------------------------- +function handle_photo_upload($file) { + // Validates File Size + $max_size = 5 * 1024 * 1024; + if ($file['size'] > $max_size) { + return null; + } + + // Validates MIME Type + $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + $finfo = finfo_open(FILEINFO_MIME_TYPE); + $mime = finfo_file($finfo, $file['tmp_name']); + finfo_close($finfo); + + if (!in_array($mime, $allowed_types)) { + return null; + } + + // Generates unique Filename + $ext = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp' + ][$mime]; + + $filename = uniqid('photo_', true) . '.' . $ext; + $upload_dir = __DIR__ . '/../uploads/photos/'; + $target_path = $upload_dir . $filename; + + // Creates Upload Directory + if (!is_dir($upload_dir)) { + mkdir($upload_dir, 0755, true); + } + + // Moves uploaded File + if (move_uploaded_file($file['tmp_name'], $target_path)) { + return 'uploads/photos/' . $filename; + } + + return null; +} + +// ===================================================================== +// Action Handlers for Comments +// ===================================================================== + +// --------------------------------------------------------------------- +// READ COMMENTS: Loads Comments for a Contribution +// Returns Comments sorted by Date (newest first) +// Required: contribution_id +// --------------------------------------------------------------------- +function handle_read_comments($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['contribution_id']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + try { + $stmt = $pdo->prepare(" + SELECT comment_id, contribution_id, author_name, browser_id, content, created_at + FROM comments + WHERE contribution_id = :cid + ORDER BY created_at ASC + "); + $stmt->execute([':cid' => $input['contribution_id']]); + $comments = $stmt->fetchAll(); + + json_response(['comments' => $comments, 'count' => count($comments)]); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } +} + + +// --------------------------------------------------------------------- +// CREATE COMMENT: Adds Comments to Contributions +// Required: contribution_id, author_name, content +// Optional: browser_id +// --------------------------------------------------------------------- +function handle_create_comment($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['contribution_id', 'author_name', 'content']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + // Validates Content Length + if (strlen($input['content']) > 1000) { + error_response('Comment too long. Maximum 1000 Characters.'); + } + + // Checks if Contribution exists + $stmt = $pdo->prepare("SELECT contribution_id FROM contributions WHERE contribution_id = :id"); + $stmt->execute([':id' => $input['contribution_id']]); + if (!$stmt->fetch()) { + error_response('Contribution not found.', 404); + } + + try { + $stmt = $pdo->prepare(" + INSERT INTO comments (contribution_id, author_name, browser_id, content) + VALUES (:cid, :author, :bid, :content) + "); + $stmt->execute([ + ':cid' => $input['contribution_id'], + ':author' => $input['author_name'], + ':bid' => $input['browser_id'] ?? null, + ':content' => $input['content'] + ]); + + json_response([ + 'message' => 'Comment created successfully.', + 'comment_id' => (int) $pdo->lastInsertId() + ], 201); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } +} + + +// --------------------------------------------------------------------- +// DELETE COMMENT: Removes a Comment +// Required: comment_id +// --------------------------------------------------------------------- +function handle_delete_comment($input) { + $pdo = get_db(); + + $missing = validate_required($input, ['comment_id']); + if (!empty($missing)) { + error_response('Missing Fields: ' . implode(', ', $missing)); + } + + try { + $stmt = $pdo->prepare("DELETE FROM comments WHERE comment_id = :id"); + $stmt->execute([':id' => $input['comment_id']]); + + json_response(['message' => 'Comment deleted successfully.']); + + } catch (PDOException $e) { + error_response('Database Error: ' . $e->getMessage(), 500); + } } \ No newline at end of file diff --git a/public/index.php b/public/index.php index f122214..833b6be 100644 --- a/public/index.php +++ b/public/index.php @@ -321,6 +321,15 @@ $news_items = $stmt->fetchAll(); + +
+ + + +
+ diff --git a/public/js/app.js b/public/js/app.js index 397e42f..e46c00e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -388,6 +388,7 @@ function styleLinePolygon(feature) { // Block 9: Feature Popups for Read, Edit, Delete and Vote // ===================================================================== +// Builds Popup HTML for Features function buildPopupHtml(feature) { const props = feature.properties; const cat = CATEGORIES[props.category] || CATEGORIES.other; @@ -398,12 +399,20 @@ function buildPopupHtml(feature) { day: '2-digit', month: '2-digit', year: 'numeric' }); - return '' + + let html = '' + ''; + ''; + } + + // Comments Section + html += ''; + + return html; } + // Binds Popup and Tooltip to Feature Layer function bindFeaturePopup(feature, layer) { const cat = CATEGORIES[feature.properties.category] || CATEGORIES.other; - // Rebuilts if Popup opens + // Dynamic Popup — rebuilt every Time the Popup opens layer.bindPopup(function () { return buildPopupHtml(feature); }, { maxWidth: 320, minWidth: 240 }); + // Loads Comments when Popup opens + layer.on('popupopen', function () { + loadComments(feature.properties.contribution_id); + }); + // Tooltip on Hover layer.bindTooltip(categoryIcon(cat) + ' ' + escapeHtml(feature.properties.title), { direction: 'top', @@ -449,8 +488,9 @@ function submitCreate() { const description = document.getElementById('create-description').value.trim(); const geom = document.getElementById('create-geom').value; const geomType = document.getElementById('create-geom-type').value; + const photoInput = document.getElementById('create-photo'); - // Validates + // Validates required Fields if (!category) { Swal.fire('Kategorie fehlt', 'Bitte wählen Sie eine Kategorie aus.', 'warning'); return; @@ -464,34 +504,48 @@ function submitCreate() { return; } - apiCall({ - action: 'create', - municipality_id: MUNICIPALITY.id, - category: category, - title: title, - description: description, - geom: geom, - geom_type: geomType, - author_name: currentUser, - browser_id: browserId - }, function (response) { - if (response.error) { - Swal.fire('Fehler', response.error, 'error'); - return; - } + // Builds FormData manually to include Photo File + const formData = new FormData(); + formData.append('action', 'create'); + formData.append('municipality_id', MUNICIPALITY.id); + formData.append('category', category); + formData.append('title', title); + formData.append('description', description); + formData.append('geom', geom); + formData.append('geom_type', geomType); + formData.append('author_name', currentUser); + formData.append('browser_id', browserId); - // Triggers Reverse Geocoding in Background - if (response.contribution_id && drawnGeometry) { - const coords = drawnGeomType === 'point' ? drawnGeometry.coordinates : - drawnGeomType === 'line' ? drawnGeometry.coordinates[0] : - drawnGeometry.coordinates[0][0]; - reverseGeocode(response.contribution_id, coords[1], coords[0]); - } + // Appends Photo File if selected + if (photoInput.files.length > 0) { + formData.append('photo', photoInput.files[0]); + } - Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung durch das Moderationsteam veröffentlicht.', 'success'); - closeCreateModal(); - loadContributions(); - }); + // Sends directly via fetch not through apiCall, because of File Upload + fetch(API_URL, { method: 'POST', body: formData }) + .then(function (response) { return response.json(); }) + .then(function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + + // Triggers Reverse Geocoding in Background + if (response.contribution_id && drawnGeometry) { + const coords = drawnGeomType === 'point' ? drawnGeometry.coordinates : + drawnGeomType === 'line' ? drawnGeometry.coordinates[0] : + drawnGeometry.coordinates[0][0]; + reverseGeocode(response.contribution_id, coords[1], coords[0]); + } + + Swal.fire('Eingereicht!', 'Ihr Beitrag wurde erfolgreich eingereicht und wird nach Prüfung durch das Moderationsteam veröffentlicht.', 'success'); + closeCreateModal(); + loadContributions(); + }) + .catch(function (error) { + console.error('Upload Error:', error); + Swal.fire('Verbindungsfehler', 'Verbindung zum Server fehlgeschlagen.', 'error'); + }); } // Cancels Create, closes Modal and clears Form @@ -506,6 +560,9 @@ function closeCreateModal() { document.getElementById('create-description').value = ''; document.getElementById('create-geom').value = ''; document.getElementById('create-geom-type').value = ''; + // Resets Photo Upload + document.getElementById('create-photo').value = ''; + document.getElementById('photo-preview').style.display = 'none'; drawnGeometry = null; drawnGeomType = null; } @@ -1002,6 +1059,80 @@ function filterNews() { }); } +// Loads and Displays Comments forContributions in Popups +function loadComments(contributionId) { + apiCall({ + action: 'read_comments', + contribution_id: contributionId + }, function (response) { + const listContainer = document.getElementById('comments-list-' + contributionId); + const countSpan = document.getElementById('comment-count-' + contributionId); + + if (!listContainer) return; + + if (response.error || !response.comments || response.comments.length === 0) { + listContainer.innerHTML = ''; + if (countSpan) countSpan.textContent = '(0)'; + return; + } + + if (countSpan) countSpan.textContent = '(' + response.count + ')'; + + let html = ''; + response.comments.forEach(function (comment) { + const commentDate = new Date(comment.created_at).toLocaleDateString('de-DE'); + const canDelete = comment.browser_id === browserId || (typeof IS_ADMIN !== 'undefined' && IS_ADMIN); + + html += ''; + }); + + listContainer.innerHTML = html; + }); +} + +// Submits a new Comment on a Contribution +function submitComment(contributionId) { + const input = document.getElementById('comment-input-' + contributionId); + const content = input ? input.value.trim() : ''; + + if (!content) return; + + apiCall({ + action: 'create_comment', + contribution_id: contributionId, + author_name: currentUser, + browser_id: browserId, + content: content + }, function (response) { + if (response.error) { + Swal.fire('Fehler', response.error, 'error'); + return; + } + // Clears Input and reloads Comments + if (input) input.value = ''; + loadComments(contributionId); + }); +} + +// Deletes a Comment +function deleteComment(commentId, contributionId) { + apiCall({ + action: 'delete_comment', + comment_id: commentId + }, function (response) { + if (response.error) return; + // Reloads Comments after Deletion + loadComments(contributionId); + }); +} + // ===================================================================== // Block 16: Application Startup @@ -1020,6 +1151,7 @@ function buildCategoryDropdown() { } } + // Populates Category Dropdown buildCategoryDropdown(); @@ -1030,4 +1162,22 @@ buildCategoryFilter(); loadContributions(); // Shows Welcome Modal on first Visit -checkWelcomeModal(); \ No newline at end of file +checkWelcomeModal(); + + +// Photo Preview in Create Modal +document.getElementById('create-photo').addEventListener('change', function () { + const preview = document.getElementById('photo-preview'); + const previewImg = document.getElementById('photo-preview-img'); + + if (this.files && this.files[0]) { + const reader = new FileReader(); + reader.onload = function (e) { + previewImg.src = e.target.result; + preview.style.display = 'block'; + }; + reader.readAsDataURL(this.files[0]); + } else { + preview.style.display = 'none'; + } +}); \ No newline at end of file diff --git a/public/styles.css b/public/styles.css index 3a10b31..16b4e44 100644 --- a/public/styles.css +++ b/public/styles.css @@ -629,6 +629,97 @@ select.form-input { cursor: pointer; } .popup-detail-actions .btn { flex: 1; padding: 6px 12px; font-size: 0.8rem; min-height: 36px; } +/* ----------------------------------------------------------------- + 4.8 Popup Comments + ----------------------------------------------------------------- */ +.popup-comments { + margin-top: var(--space-sm); + padding-top: var(--space-sm); + border-top: 1px solid var(--color-border); +} + +.popup-comments-header { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-secondary); + margin-bottom: var(--space-sm); +} + +.popup-comments-list { + max-height: 150px; + overflow-y: auto; +} + +.popup-comment { + padding: 6px 0; + border-bottom: 1px solid #f0f0f0; + font-size: 0.8rem; +} + +.popup-comment:last-child { border-bottom: none; } + +.popup-comment-meta { + color: var(--color-text-secondary); + font-size: 0.75rem; + margin-bottom: 2px; +} + +.popup-comment-text { + color: var(--color-text); + line-height: 1.4; +} + +.popup-comment-delete { + color: var(--color-error); + font-size: 0.7rem; +} + +.popup-comment-empty { + font-size: 0.8rem; + color: #bbb; + font-style: italic; + padding: 8px 0; +} + +.popup-comment-form { + display: flex; + gap: 4px; + margin-top: var(--space-sm); +} + +.popup-comment-input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--color-border); + border-radius: 16px; + font-size: 0.8rem; + font-family: var(--font-body); + outline: none; +} + +.popup-comment-input:focus { + border-color: var(--color-primary); +} + +.popup-comment-submit { + border: none; + background: var(--color-primary); + color: white; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + flex-shrink: 0; + transition: filter var(--transition-fast); +} + +.popup-comment-submit:hover { filter: brightness(1.15); } + + /* ================================================================= SECTION 5: Admin-specific Styles (admin.php) ================================================================= */ diff --git a/public/uploads/.htaccess b/public/uploads/.htaccess new file mode 100644 index 0000000..242e491 --- /dev/null +++ b/public/uploads/.htaccess @@ -0,0 +1,7 @@ +# Prevents PHP in Upload Directory +php_flag engine off + +# Allows Image Files + + Require all granted + \ No newline at end of file diff --git a/public/uploads/photos/.gitkeep b/public/uploads/photos/.gitkeep new file mode 100644 index 0000000..e69de29