From f15d9a9cfd5661f938f0bcdd95b67e9d9d9397a2 Mon Sep 17 00:00:00 2001 From: boris Date: Thu, 20 Nov 2025 20:40:49 +0000 Subject: [PATCH] Added admin dashboard, refactored user dashboard. Removed old reviews route. --- src/Controller/AccountController.php | 80 +++++++++---- .../Admin/SiteDashboardController.php | 47 ++++++++ src/Controller/AlbumController.php | 110 +++++++++++++++++- src/Controller/ReviewController.php | 10 +- src/Form/ChangePasswordFormType.php | 35 ++++++ src/Repository/AlbumRepository.php | 32 +++++ src/Repository/ReviewRepository.php | 27 +++++ templates/_partials/navbar.html.twig | 5 +- templates/account/dashboard.html.twig | 97 +++++++++++++-- templates/account/password.html.twig | 13 +++ templates/admin/site_dashboard.html.twig | 81 +++++++++++++ templates/album/edit.html.twig | 17 +++ templates/album/search.html.twig | 13 ++- templates/album/show.html.twig | 11 +- templates/review/index.html.twig | 29 ----- templates/review/show.html.twig | 2 +- 16 files changed, 534 insertions(+), 75 deletions(-) create mode 100644 src/Controller/Admin/SiteDashboardController.php create mode 100644 src/Form/ChangePasswordFormType.php create mode 100644 templates/account/password.html.twig create mode 100644 templates/admin/site_dashboard.html.twig create mode 100644 templates/album/edit.html.twig delete mode 100644 templates/review/index.html.twig diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index bb1f90a..4027854 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -4,6 +4,9 @@ namespace App\Controller; use App\Entity\User; use App\Form\ProfileFormType; +use App\Form\ChangePasswordFormType; +use App\Repository\ReviewRepository; +use App\Repository\AlbumRepository; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\SecurityBundle\Attribute\IsGranted; @@ -16,33 +19,42 @@ use Symfony\Component\Routing\Attribute\Route; #[IsGranted('ROLE_USER')] class AccountController extends AbstractController { - #[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])] - public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response + #[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])] + public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response { /** @var User $user */ $user = $this->getUser(); - $form = $this->createForm(ProfileFormType::class, $user); - $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { - $newPassword = (string) $form->get('newPassword')->getData(); - if ($newPassword !== '') { - $current = (string) $form->get('currentPassword')->getData(); - if ($current === '' || !$hasher->isPasswordValid($user, $current)) { - $form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); - return $this->render('account/dashboard.html.twig', [ - 'form' => $form->createView(), - ]); - } - $user->setPassword($hasher->hashPassword($user, $newPassword)); - } - $em->flush(); - $this->addFlash('success', 'Profile updated.'); - return $this->redirectToRoute('account_dashboard'); - } + $reviewCount = (int) $reviews->createQueryBuilder('r') + ->select('COUNT(r.id)') + ->where('r.author = :u')->setParameter('u', $user) + ->getQuery()->getSingleScalarResult(); + $albumCount = (int) $albums->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.source = :src')->setParameter('src', 'user') + ->andWhere('a.createdBy = :u')->setParameter('u', $user) + ->getQuery()->getSingleScalarResult(); + $userType = $this->isGranted('ROLE_ADMIN') ? 'Admin' : 'User'; + $userReviews = $reviews->createQueryBuilder('r') + ->where('r.author = :u')->setParameter('u', $user) + ->orderBy('r.createdAt', 'DESC') + ->setMaxResults(10) + ->getQuery()->getResult(); + $userAlbums = $albums->createQueryBuilder('a') + ->where('a.source = :src')->setParameter('src', 'user') + ->andWhere('a.createdBy = :u')->setParameter('u', $user) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults(10) + ->getQuery()->getResult(); return $this->render('account/dashboard.html.twig', [ - 'form' => $form->createView(), + 'email' => $user->getEmail(), + 'displayName' => $user->getDisplayName(), + 'reviewCount' => $reviewCount, + 'albumCount' => $albumCount, + 'userType' => $userType, + 'userReviews' => $userReviews, + 'userAlbums' => $userAlbums, ]); } @@ -51,6 +63,32 @@ class AccountController extends AbstractController { return $this->render('account/settings.html.twig'); } + + #[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])] + public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response + { + /** @var User $user */ + $user = $this->getUser(); + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $current = (string) $form->get('currentPassword')->getData(); + if ($current === '' || !$hasher->isPasswordValid($user, $current)) { + $form->get('currentPassword')->addError(new FormError('Current password is incorrect.')); + return $this->render('account/password.html.twig', [ + 'form' => $form->createView(), + ]); + } + $newPassword = (string) $form->get('newPassword')->getData(); + $user->setPassword($hasher->hashPassword($user, $newPassword)); + $em->flush(); + $this->addFlash('success', 'Password updated.'); + return $this->redirectToRoute('account_dashboard'); + } + return $this->render('account/password.html.twig', [ + 'form' => $form->createView(), + ]); + } } diff --git a/src/Controller/Admin/SiteDashboardController.php b/src/Controller/Admin/SiteDashboardController.php new file mode 100644 index 0000000..b8df39e --- /dev/null +++ b/src/Controller/Admin/SiteDashboardController.php @@ -0,0 +1,47 @@ +createQueryBuilder('r') + ->select('COUNT(r.id)') + ->getQuery()->getSingleScalarResult(); + + $totalAlbums = (int) $albums->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->getQuery()->getSingleScalarResult(); + + $totalUsers = (int) $users->createQueryBuilder('u') + ->select('COUNT(u.id)') + ->getQuery()->getSingleScalarResult(); + + $recentReviews = $reviews->findLatest(50); + $recentAlbums = $albums->createQueryBuilder('a') + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults(50) + ->getQuery()->getResult(); + + return $this->render('admin/site_dashboard.html.twig', [ + 'totalReviews' => $totalReviews, + 'totalAlbums' => $totalAlbums, + 'totalUsers' => $totalUsers, + 'recentReviews' => $recentReviews, + 'recentAlbums' => $recentAlbums, + ]); + } +} + + diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php index ff318cb..37f8658 100644 --- a/src/Controller/AlbumController.php +++ b/src/Controller/AlbumController.php @@ -24,6 +24,7 @@ class AlbumController extends AbstractController $query = trim((string) $request->query->get('q', '')); $albumName = trim($request->query->getString('album', '')); $artist = trim($request->query->getString('artist', '')); + $source = $request->query->getString('source', 'all'); // 'all' | 'spotify' | 'user' // Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues $yearFromRaw = trim((string) $request->query->get('year_from', '')); $yearToRaw = trim((string) $request->query->get('year_to', '')); @@ -53,11 +54,11 @@ class AlbumController extends AbstractController $q = implode(' ', $parts); } - if ($q !== '') { + if ($q !== '' || $source === 'user') { $result = $spotifyClient->searchAlbums($q, 20); $searchItems = $result['albums']['items'] ?? []; $logger->info('Album search results received', ['query' => $q, 'items' => is_countable($searchItems) ? count($searchItems) : 0]); - if ($searchItems) { + if ($searchItems && ($source === 'all' || $source === 'spotify')) { // Build ordered list of IDs from search results $ids = []; foreach ($searchItems as $it) { @@ -84,7 +85,9 @@ class AlbumController extends AbstractController $logger->info('Albums upserted to DB', ['upserted' => $upserted]); if ($ids) { - $stats = $reviewRepository->getAggregatesForAlbumIds($ids); + if ($source === 'spotify' || $source === 'all') { + $stats = $reviewRepository->getAggregatesForAlbumIds($ids); + } $existing = $albumsRepo->findBySpotifyIdsKeyed($ids); $savedIds = array_keys($existing); // Preserve Spotify order and render from DB @@ -96,6 +99,25 @@ class AlbumController extends AbstractController } } } + // User-created search results + if ($source === 'user' || $source === 'all') { + $userAlbums = $albumsRepo->searchUserAlbums($albumName, $artist, $yearFrom, $yearTo, 20); + if ($userAlbums) { + $entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums)); + $userStatsByEntityId = $reviewRepository->getAggregatesForAlbumEntityIds($entityIds); + // Merge into stats keyed by localId + foreach ($userAlbums as $ua) { + $localId = (string) $ua->getLocalId(); + $entityId = (int) $ua->getId(); + if (isset($userStatsByEntityId[$entityId])) { + $stats[$localId] = $userStatsByEntityId[$entityId]; + } + } + $userAlbumPayloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums); + // Prepend user albums to list + $albums = array_merge($userAlbumPayloads, $albums); + } + } } return $this->render('album/search.html.twig', [ @@ -107,6 +129,7 @@ class AlbumController extends AbstractController 'albums' => $albums, 'stats' => $stats, 'savedIds' => $savedIds, + 'source' => $source, ]); } @@ -123,6 +146,8 @@ class AlbumController extends AbstractController $artistsCsv = (string) $form->get('artistsCsv')->getData(); $artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== '')); $album->setArtists($artists); + // Normalize release date to YYYY-MM-DD + $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); // Assign createdBy and generate unique localId $u = $this->getUser(); if ($u instanceof \App\Entity\User) { @@ -154,6 +179,14 @@ class AlbumController extends AbstractController } $isSaved = $albumEntity !== null; $album = $albumEntity->toTemplateArray(); + $isAdmin = $this->isGranted('ROLE_ADMIN'); + $current = $this->getUser(); + $isOwner = false; + if ($current instanceof \App\Entity\User) { + $isOwner = ($albumEntity->getCreatedBy()?->getId() === $current->getId()); + } + $allowedEdit = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner); + $allowedDelete = $isAdmin || ($albumEntity->getSource() === 'user' && $isOwner); $existing = $reviews->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); $count = count($existing); @@ -183,6 +216,8 @@ class AlbumController extends AbstractController 'album' => $album, 'albumId' => $id, 'isSaved' => $isSaved, + 'allowedEdit' => $allowedEdit, + 'allowedDelete' => $allowedDelete, 'reviews' => $existing, 'avg' => $avg, 'count' => $count, @@ -255,6 +290,75 @@ class AlbumController extends AbstractController } while ($albumsRepo->findOneByLocalId($id) !== null); return $id; } + + private function normalizeReleaseDate(?string $input): ?string + { + if ($input === null || trim($input) === '') { + return null; + } + $s = trim($input); + // YYYY + if (preg_match('/^\d{4}$/', $s)) { + return $s . '-01-01'; + } + // YYYY-MM + if (preg_match('/^\d{4}-\d{2}$/', $s)) { + return $s . '-01'; + } + // YYYY-MM-DD + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) { + return $s; + } + // Fallback: attempt to parse + try { + $dt = new \DateTimeImmutable($s); + return $dt->format('Y-m-d'); + } catch (\Throwable) { + return null; + } + } + + #[IsGranted('ROLE_USER')] + #[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])] + public function edit(string $id, Request $request, AlbumRepository $albumsRepo, EntityManagerInterface $em): Response + { + $album = str_starts_with($id, 'u_') ? $albumsRepo->findOneByLocalId($id) : $albumsRepo->findOneBySpotifyId($id); + if (!$album) { + throw $this->createNotFoundException('Album not found'); + } + $isAdmin = $this->isGranted('ROLE_ADMIN'); + $current = $this->getUser(); + $isOwner = false; + if ($current instanceof \App\Entity\User) { + $isOwner = ($album->getCreatedBy()?->getId() === $current->getId()); + } + if ($album->getSource() === 'user') { + if (!$isAdmin && !$isOwner) { + throw $this->createAccessDeniedException(); + } + } else { + if (!$isAdmin) { + throw $this->createAccessDeniedException(); + } + } + $form = $this->createForm(AlbumType::class, $album); + // Prepopulate artistsCsv + $form->get('artistsCsv')->setData(implode(', ', $album->getArtists())); + $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { + $artistsCsv = (string) $form->get('artistsCsv')->getData(); + $artists = array_values(array_filter(array_map(static fn($s) => trim((string) $s), explode(',', $artistsCsv)), static fn($s) => $s !== '')); + $album->setArtists($artists); + $album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate())); + $em->flush(); + $this->addFlash('success', 'Album updated.'); + return $this->redirectToRoute('album_show', ['id' => $id]); + } + return $this->render('album/edit.html.twig', [ + 'form' => $form->createView(), + 'albumId' => $id, + ]); + } } diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php index 0794ccb..28b017a 100644 --- a/src/Controller/ReviewController.php +++ b/src/Controller/ReviewController.php @@ -17,13 +17,11 @@ use Symfony\Component\Routing\Attribute\Route; #[Route('/reviews')] class ReviewController extends AbstractController { + // Exclusively for compat, used to route to standalone reviews page. #[Route('', name: 'review_index', methods: ['GET'])] - public function index(ReviewRepository $reviewRepository): Response + public function index(): Response { - $reviews = $reviewRepository->findLatest(50); - return $this->render('review/index.html.twig', [ - 'reviews' => $reviews, - ]); + return $this->redirectToRoute('account_dashboard'); } #[Route('/new', name: 'review_new', methods: ['GET', 'POST'])] @@ -77,7 +75,7 @@ class ReviewController extends AbstractController $em->flush(); $this->addFlash('success', 'Review deleted.'); } - return $this->redirectToRoute('review_index'); + return $this->redirectToRoute('account_dashboard'); } // fetchAlbumById no longer needed; album view handles retrieval and creation diff --git a/src/Form/ChangePasswordFormType.php b/src/Form/ChangePasswordFormType.php new file mode 100644 index 0000000..655bb50 --- /dev/null +++ b/src/Form/ChangePasswordFormType.php @@ -0,0 +1,35 @@ +add('currentPassword', PasswordType::class, [ + 'label' => 'Current password', + ]) + ->add('newPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'first_options' => ['label' => 'New password'], + 'second_options' => ['label' => 'Repeat new password'], + 'invalid_message' => 'The password fields must match.', + 'constraints' => [new Assert\Length(min: 8)], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} + + diff --git a/src/Repository/AlbumRepository.php b/src/Repository/AlbumRepository.php index 4f97de2..62a45e2 100644 --- a/src/Repository/AlbumRepository.php +++ b/src/Repository/AlbumRepository.php @@ -63,6 +63,38 @@ class AlbumRepository extends ServiceEntityRepository return $qb->getQuery()->getResult(); } + /** + * Search user-created albums by optional fields. + * + * @return list + */ + public function searchUserAlbums(?string $albumName, ?string $artist, int $yearFrom, int $yearTo, int $limit = 20): array + { + $qb = $this->createQueryBuilder('a') + ->where('a.source = :src') + ->setParameter('src', 'user') + ->setMaxResults($limit); + if ($albumName !== null && $albumName !== '') { + $qb->andWhere('LOWER(a.name) LIKE :an')->setParameter('an', '%' . mb_strtolower($albumName) . '%'); + } + if ($artist !== null && $artist !== '') { + // artists is JSON; use text match + $qb->andWhere("CAST(a.artists as text) ILIKE :ar")->setParameter('ar', '%' . $artist . '%'); + } + if ($yearFrom > 0 || $yearTo > 0) { + // releaseDate is YYYY-MM-DD; compare by year via substring + if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) { + $qb->andWhere("SUBSTRING(a.releaseDate,1,4) BETWEEN :yf AND :yt") + ->setParameter('yf', (string) $yearFrom) + ->setParameter('yt', (string) $yearTo); + } else { + $y = $yearFrom > 0 ? $yearFrom : $yearTo; + $qb->andWhere("SUBSTRING(a.releaseDate,1,4) = :y")->setParameter('y', (string) $y); + } + } + return $qb->getQuery()->getResult(); + } + /** * Upsert based on a Spotify album payload. * diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php index 1e08e62..d5b3fa7 100644 --- a/src/Repository/ReviewRepository.php +++ b/src/Repository/ReviewRepository.php @@ -55,6 +55,33 @@ class ReviewRepository extends ServiceEntityRepository } return $out; } + + /** + * Aggregates keyed by album entity id. + * + * @param list $albumEntityIds + * @return array + */ + public function getAggregatesForAlbumEntityIds(array $albumEntityIds): array + { + if ($albumEntityIds === []) { + return []; + } + $rows = $this->createQueryBuilder('r') + ->innerJoin('r.album', 'a') + ->select('a.id AS albumEntityId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating') + ->where('a.id IN (:ids)') + ->setParameter('ids', $albumEntityIds) + ->groupBy('a.id') + ->getQuery() + ->getArrayResult(); + $out = []; + foreach ($rows as $row) { + $avg = isset($row['avgRating']) ? round((float) $row['avgRating'], 1) : 0.0; + $out[(int) $row['albumEntityId']] = ['count' => (int) $row['cnt'], 'avg' => $avg]; + } + return $out; + } } diff --git a/templates/_partials/navbar.html.twig b/templates/_partials/navbar.html.twig index 34948bb..6e8db8e 100644 --- a/templates/_partials/navbar.html.twig +++ b/templates/_partials/navbar.html.twig @@ -6,7 +6,9 @@