diff options
| -rw-r--r-- | src/app/Controllers/GalleryController.php | 89 | ||||
| -rw-r--r-- | src/app/Mail.php | 10 | ||||
| -rw-r--r-- | src/app/Models/Comment.php | 39 | ||||
| -rw-r--r-- | src/app/Models/Like.php | 59 | ||||
| -rw-r--r-- | src/app/Views/gallery/index.php | 64 | ||||
| -rw-r--r-- | src/app/Views/partials/pagination.php | 20 | ||||
| -rw-r--r-- | src/config/routes.php | 2 | ||||
| -rw-r--r-- | src/public/css/style.css | 76 |
8 files changed, 336 insertions, 23 deletions
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 @@ <?php declare(strict_types=1); -// Gallery: public paginated feed of all posts, newest first. +// Gallery: public paginated feed with likes, comments, and notifications. namespace App\Controllers; +use App\Csrf; +use App\Flash; +use App\Mail; +use App\Models\Comment; +use App\Models\Like; use App\Models\Post; +use App\Models\User; class GalleryController { private Post $post; + private Like $like; + private Comment $comment; private const POSTS_PER_PAGE = 5; public function __construct() { $this->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 = '<p><strong>' . htmlspecialchars($commenterUsername) . '</strong> commented on your post.</p>' + . '<p><a href="' . htmlspecialchars($url) . '">View the comment</a></p>'; + + 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 @@ +<?php + +declare(strict_types=1); +// Comment model: database operations for the comments table. + +namespace App\Models; + +use App\Database; + +class Comment +{ + private \PDO $pdo; + + public function __construct() + { + $this->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 @@ +<?php + +declare(strict_types=1); +// Like model: database operations for the likes table. + +namespace App\Models; + +use App\Database; + +class Like +{ + private \PDO $pdo; + + public function __construct() + { + $this->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 @@ -<?php // Gallery: public feed of all posts with pagination.?> +<?php // Gallery: public feed of all posts with likes, comments, and pagination.?> <div class="gallery-page"> <h1>Gallery</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + + <?php include __DIR__ . '/../partials/pagination.php'; ?> <?php if (empty($posts)): ?> <p class="hint">No posts yet. Be the first!</p> @@ -8,33 +11,52 @@ <div class="gallery-feed"> <?php foreach ($posts as $post): ?> - <article class="gallery-post"> + <article class="gallery-post" id="post-<?= $post['id'] ?>"> <div class="post-header"> <strong><?= htmlspecialchars($post['username']) ?></strong> <time><?= date('M j, Y', strtotime($post['created_at'])) ?></time> </div> <img src="/<?= htmlspecialchars($post['image_path']) ?>" alt="Post by <?= htmlspecialchars($post['username']) ?>"> + + <div class="post-actions"> + <?php if (isset($_SESSION['user_id'])): ?> + <form method="POST" action="/gallery/<?= $post['id'] ?>/like" class="like-form"> + <?= \App\Csrf::field() ?> + <input type="hidden" name="page" value="<?= $page ?>"> + <button type="submit" class="like-btn <?= $post['user_liked'] ? 'liked' : '' ?>"> + <?= $post['user_liked'] ? '♥' : '♡' ?> + </button> + </form> + <?php else: ?> + <span class="like-btn">♡</span> + <?php endif; ?> + <span class="like-count"><?= $post['like_count'] ?> <?= $post['like_count'] === 1 ? 'like' : 'likes' ?></span> + </div> + + <div class="comments-section"> + <?php if (!empty($post['comments'])): ?> + <div class="comments-list"> + <?php foreach ($post['comments'] as $comment): ?> + <div class="comment"> + <strong><?= htmlspecialchars($comment['username']) ?></strong> + <?= htmlspecialchars($comment['content']) ?> + </div> + <?php endforeach; ?> + </div> + <?php endif; ?> + + <?php if (isset($_SESSION['user_id'])): ?> + <form method="POST" action="/gallery/<?= $post['id'] ?>/comment" class="comment-form"> + <?= \App\Csrf::field() ?> + <input type="hidden" name="page" value="<?= $page ?>"> + <input type="text" name="content" placeholder="Add a comment..." maxlength="500" required> + <button type="submit">Post</button> + </form> + <?php endif; ?> + </div> </article> <?php endforeach; ?> </div> - <?php if ($totalPages > 1): ?> - <nav class="pagination"> - <?php if ($page > 1): ?> - <a href="/gallery?page=<?= $page - 1 ?>">« Previous</a> - <?php endif; ?> - - <?php for ($i = 1; $i <= $totalPages; $i++): ?> - <?php if ($i === $page): ?> - <span class="current-page"><?= $i ?></span> - <?php else: ?> - <a href="/gallery?page=<?= $i ?>"><?= $i ?></a> - <?php endif; ?> - <?php endfor; ?> - - <?php if ($page < $totalPages): ?> - <a href="/gallery?page=<?= $page + 1 ?>">Next »</a> - <?php endif; ?> - </nav> - <?php endif; ?> + <?php include __DIR__ . '/../partials/pagination.php'; ?> </div> 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 @@ +<?php // Pagination nav: expects $page and $totalPages to be set.?> +<?php if ($totalPages > 1): ?> + <nav class="pagination"> + <?php if ($page > 1): ?> + <a href="/gallery?page=<?= $page - 1 ?>">« Previous</a> + <?php endif; ?> + + <?php for ($i = 1; $i <= $totalPages; $i++): ?> + <?php if ($i === $page): ?> + <span class="current-page"><?= $i ?></span> + <?php else: ?> + <a href="/gallery?page=<?= $i ?>"><?= $i ?></a> + <?php endif; ?> + <?php endfor; ?> + + <?php if ($page < $totalPages): ?> + <a href="/gallery?page=<?= $page + 1 ?>">Next »</a> + <?php endif; ?> + </nav> +<?php endif; ?> 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 { |
