diff options
Diffstat (limited to 'src/app')
| -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 |
6 files changed, 259 insertions, 22 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; ?> |
