From f9ad3f4dc05252839457579303a4e0a0f94d8b80 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sat, 21 Mar 2026 22:55:13 +0100 Subject: Add likes, comments, email notifications, and pagination to gallery --- src/app/Controllers/GalleryController.php | 89 ++++++++++++++++++++++++++++++- src/app/Mail.php | 10 ++++ src/app/Models/Comment.php | 39 ++++++++++++++ src/app/Models/Like.php | 59 ++++++++++++++++++++ src/app/Views/gallery/index.php | 64 ++++++++++++++-------- src/app/Views/partials/pagination.php | 20 +++++++ src/config/routes.php | 2 + src/public/css/style.css | 76 +++++++++++++++++++++++++- 8 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 src/app/Models/Comment.php create mode 100644 src/app/Models/Like.php create mode 100644 src/app/Views/partials/pagination.php diff --git a/src/app/Controllers/GalleryController.php b/src/app/Controllers/GalleryController.php index 2edcd17..bc76e21 100644 --- a/src/app/Controllers/GalleryController.php +++ b/src/app/Controllers/GalleryController.php @@ -1,20 +1,30 @@ post = new Post(); + $this->like = new Like(); + $this->comment = new Comment(); } public function index(): void @@ -26,7 +36,84 @@ class GalleryController $totalPosts = $this->post->countAll(); $totalPages = max(1, (int) ceil($totalPosts / self::POSTS_PER_PAGE)); + $userId = $_SESSION['user_id'] ?? null; + + foreach ($posts as &$post) { + $post['like_count'] = $this->like->countByPost($post['id']); + $post['user_liked'] = $userId ? $this->like->hasUserLiked($userId, $post['id']) : false; + $post['comments'] = $this->comment->findByPostId($post['id']); + } + unset($post); + $content = __DIR__ . '/../Views/gallery/index.php'; include __DIR__ . '/../Views/layouts/main.php'; } + + public function like(string $id): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /gallery'); + return; + } + + $this->like->toggle($_SESSION['user_id'], (int) $id); + + $page = (int) ($_POST['page'] ?? 1); + header('Location: /gallery?page=' . $page . '#post-' . $id); + } + + public function comment(string $id): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + if (!Csrf::validate($_POST['csrf_token'] ?? '')) { + Flash::set('error', 'Invalid CSRF token.'); + header('Location: /gallery'); + return; + } + + $content = trim($_POST['content'] ?? ''); + + if ($content === '') { + Flash::set('error', 'Comment cannot be empty.'); + $page = (int) ($_POST['page'] ?? 1); + header('Location: /gallery?page=' . $page . '#post-' . $id); + return; + } + + if (\strlen($content) > 500) { + Flash::set('error', 'Comment is too long (max 500 characters).'); + $page = (int) ($_POST['page'] ?? 1); + header('Location: /gallery?page=' . $page . '#post-' . $id); + return; + } + + $this->comment->create($_SESSION['user_id'], (int) $id, $content); + + // Notify the post owner if they have comment notifications enabled + $post = $this->post->findById((int) $id); + if ($post && $post['user_id'] !== $_SESSION['user_id']) { + $user = new User(); + $owner = $user->findById($post['user_id']); + if ($owner && $owner['notify_comments']) { + Mail::sendCommentNotification( + $owner['email'], + $_SESSION['username'], + (int) $id + ); + } + } + + $page = (int) ($_POST['page'] ?? 1); + header('Location: /gallery?page=' . $page . '#post-' . $id); + } } diff --git a/src/app/Mail.php b/src/app/Mail.php index 054c6e0..650c5b3 100644 --- a/src/app/Mail.php +++ b/src/app/Mail.php @@ -28,6 +28,16 @@ class Mail return self::send($to, $subject, $body); } + public static function sendCommentNotification(string $to, string $commenterUsername, int $postId): bool + { + $url = getenv('APP_URL') . '/gallery#post-' . $postId; + $subject = 'Camagru — New comment on your post'; + $body = '

' . htmlspecialchars($commenterUsername) . ' commented on your post.

' + . '

View the comment

'; + + return self::send($to, $subject, $body); + } + public static function sendPasswordReset(string $to, string $token): bool { $url = getenv('APP_URL') . '/reset-password?token=' . urlencode($token); diff --git a/src/app/Models/Comment.php b/src/app/Models/Comment.php new file mode 100644 index 0000000..464cfe2 --- /dev/null +++ b/src/app/Models/Comment.php @@ -0,0 +1,39 @@ +pdo = Database::getInstance()->getPdo(); + } + + public function create(int $userId, int $postId, string $content): int + { + $stmt = $this->pdo->prepare( + 'INSERT INTO comments (user_id, post_id, content) VALUES (:user_id, :post_id, :content)' + ); + $stmt->execute(['user_id' => $userId, 'post_id' => $postId, 'content' => $content]); + return (int) $this->pdo->lastInsertId(); + } + + public function findByPostId(int $postId): array + { + $stmt = $this->pdo->prepare( + 'SELECT comments.*, users.username FROM comments + JOIN users ON comments.user_id = users.id + WHERE comments.post_id = :post_id + ORDER BY comments.created_at ASC' + ); + $stmt->execute(['post_id' => $postId]); + return $stmt->fetchAll(); + } +} diff --git a/src/app/Models/Like.php b/src/app/Models/Like.php new file mode 100644 index 0000000..dc019a0 --- /dev/null +++ b/src/app/Models/Like.php @@ -0,0 +1,59 @@ +pdo = Database::getInstance()->getPdo(); + } + + /** + * Toggle a like: insert if not yet liked, delete if already liked. + * Returns true if the post is now liked, false if unliked. + */ + public function toggle(int $userId, int $postId): bool + { + // INSERT IGNORE silently fails when the UNIQUE(user_id, post_id) constraint + // is violated, meaning the user already liked this post + $stmt = $this->pdo->prepare( + 'INSERT IGNORE INTO likes (user_id, post_id) VALUES (:user_id, :post_id)' + ); + $stmt->execute(['user_id' => $userId, 'post_id' => $postId]); + + if ($stmt->rowCount() > 0) { + return true; + } + + // Row wasn't inserted → already liked → remove the like + $stmt = $this->pdo->prepare( + 'DELETE FROM likes WHERE user_id = :user_id AND post_id = :post_id' + ); + $stmt->execute(['user_id' => $userId, 'post_id' => $postId]); + return false; + } + + public function countByPost(int $postId): int + { + $stmt = $this->pdo->prepare('SELECT COUNT(*) FROM likes WHERE post_id = :post_id'); + $stmt->execute(['post_id' => $postId]); + return (int) $stmt->fetchColumn(); + } + + public function hasUserLiked(int $userId, int $postId): bool + { + $stmt = $this->pdo->prepare( + 'SELECT 1 FROM likes WHERE user_id = :user_id AND post_id = :post_id LIMIT 1' + ); + $stmt->execute(['user_id' => $userId, 'post_id' => $postId]); + return $stmt->fetch() !== false; + } +} diff --git a/src/app/Views/gallery/index.php b/src/app/Views/gallery/index.php index 62fd7f3..5269d74 100644 --- a/src/app/Views/gallery/index.php +++ b/src/app/Views/gallery/index.php @@ -1,6 +1,9 @@ - + diff --git a/src/app/Views/partials/pagination.php b/src/app/Views/partials/pagination.php new file mode 100644 index 0000000..1fcc499 --- /dev/null +++ b/src/app/Views/partials/pagination.php @@ -0,0 +1,20 @@ + + 1): ?> + + diff --git a/src/config/routes.php b/src/config/routes.php index aa7e34e..5e3c88f 100644 --- a/src/config/routes.php +++ b/src/config/routes.php @@ -27,6 +27,8 @@ $router->post('/profile/notifications', 'ProfileController', 'updateNotification // Gallery $router->get('/gallery', 'GalleryController', 'index'); +$router->post('/gallery/{id}/like', 'GalleryController', 'like'); +$router->post('/gallery/{id}/comment', 'GalleryController', 'comment'); // Editor $router->get('/editor', 'EditorController', 'show'); diff --git a/src/public/css/style.css b/src/public/css/style.css index 2119a40..00bd3ee 100644 --- a/src/public/css/style.css +++ b/src/public/css/style.css @@ -247,12 +247,86 @@ footer { display: block; } +.post-actions { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; +} + +.like-form { + display: inline; +} + +.like-btn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #999; + padding: 0; + line-height: 1; +} + +.like-btn.liked { + color: #e74c3c; +} + +.like-count { + font-size: 0.9rem; + color: #666; +} + +.comments-section { + padding: 0 1rem 0.75rem; +} + +.comments-list { + margin-bottom: 0.5rem; +} + +.comment { + font-size: 0.9rem; + padding: 0.2rem 0; +} + +.comment strong { + margin-right: 0.3rem; +} + +.comment-form { + display: flex; + gap: 0.5rem; +} + +.comment-form input[type="text"] { + flex: 1; + padding: 0.4rem 0.5rem; + border: 1px solid #dbdbdb; + border-radius: 4px; + font-size: 0.9rem; +} + +.comment-form button { + padding: 0.4rem 0.75rem; + background: none; + border: none; + color: #0095f6; + font-weight: 600; + cursor: pointer; + font-size: 0.9rem; +} + +.comment-form button:hover { + color: #007ad9; +} + .pagination { display: flex; justify-content: center; align-items: center; gap: 0.5rem; - margin-top: 2rem; + margin: 1.5rem 0; } .pagination a { -- cgit v1.2.3