Added admin dashboard, refactored user dashboard. Removed old reviews route.
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m53s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m53s
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
47
src/Controller/Admin/SiteDashboardController.php
Normal file
47
src/Controller/Admin/SiteDashboardController.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
class SiteDashboardController extends AbstractController
|
||||
{
|
||||
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
||||
{
|
||||
$totalReviews = (int) $reviews->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
35
src/Form/ChangePasswordFormType.php
Normal file
35
src/Form/ChangePasswordFormType.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
class ChangePasswordFormType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->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([]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,38 @@ class AlbumRepository extends ServiceEntityRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search user-created albums by optional fields.
|
||||
*
|
||||
* @return list<Album>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -55,6 +55,33 @@ class ReviewRepository extends ServiceEntityRepository
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates keyed by album entity id.
|
||||
*
|
||||
* @param list<int> $albumEntityIds
|
||||
* @return array<int,array{count:int,avg:float}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navMain">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ path('review_index') }}">Your Reviews</a></li>
|
||||
{% if app.user %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ path('album_new') }}">Create Album</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if app.user %}
|
||||
@@ -20,6 +22,7 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Site dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site settings</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,17 +1,96 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Your profile</h1>
|
||||
<h1 class="h4 mb-3">Your dashboard</h1>
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.email) }}{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}{{ form_errors(form.email) }}</div>
|
||||
<div>{{ form_label(form.displayName) }}{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}{{ form_errors(form.displayName) }}</div>
|
||||
<div>{{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}</div>
|
||||
<div>{{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}</div>
|
||||
<div>{{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
{{ form_end(form) }}
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">Review Count</div>
|
||||
<div class="display-6">{{ reviewCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">Album Count</div>
|
||||
<div class="display-6">{{ albumCount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">User Type</div>
|
||||
<div class="display-6">{{ userType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Profile</h2>
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" value="{{ email }}" readonly />
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label">Display name</label>
|
||||
<input class="form-control" value="{{ displayName }}" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-outline-secondary" href="{{ path('account_password') }}">Change password</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mt-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Your reviews</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for r in userReviews %}
|
||||
<div>
|
||||
<div><a href="{{ path('review_show', {id: r.id}) }}" class="text-decoration-none">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">You haven't written any reviews yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Your albums</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for a in userAlbums %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div><a href="{{ path('album_show', {id: a.localId}) }}" class="text-decoration-none">{{ a.name }}</a></div>
|
||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %}</div>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ path('album_edit', {id: a.localId}) }}">Edit</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">You haven't created any albums yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
13
templates/account/password.html.twig
Normal file
13
templates/account/password.html.twig
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Change Password{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Change password</h1>
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}</div>
|
||||
<div>{{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}</div>
|
||||
<div>{{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}</div>
|
||||
<button class="btn btn-success" type="submit">Update password</button>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
81
templates/admin/site_dashboard.html.twig
Normal file
81
templates/admin/site_dashboard.html.twig
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Site Dashboard{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Site dashboard</h1>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">Total Reviews</div>
|
||||
<div class="display-6">{{ totalReviews }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">Total Albums</div>
|
||||
<div class="display-6">{{ totalAlbums }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">Total Users</div>
|
||||
<div class="display-6">{{ totalUsers }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Latest reviews (50)</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for r in recentReviews %}
|
||||
<div>
|
||||
<div><a class="text-decoration-none" href="{{ path('review_show', {id: r.id}) }}">{{ r.title }}</a> <span class="text-secondary">(Rating {{ r.rating }}/10)</span></div>
|
||||
<div class="text-secondary small">{{ r.album.name }} • by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">No reviews.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Latest albums (50)</h2>
|
||||
<div class="vstack gap-2">
|
||||
{% for a in recentAlbums %}
|
||||
{% set publicId = a.source == 'user' ? a.localId : a.spotifyId %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div>
|
||||
{% if publicId %}
|
||||
<a class="text-decoration-none" href="{{ path('album_show', {id: publicId}) }}">{{ a.name }}</a>
|
||||
{% else %}
|
||||
<span>{{ a.name }}</span>
|
||||
{% endif %}
|
||||
{% if a.source == 'user' %}<span class="badge text-bg-primary">User</span>{% else %}<span class="badge text-bg-success">Spotify</span>{% endif %}
|
||||
</div>
|
||||
<div class="text-secondary small">{{ a.artists|join(', ') }}{% if a.releaseDate %} • {{ a.releaseDate }}{% endif %} • {{ a.createdAt|date('Y-m-d H:i') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary">No albums.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
17
templates/album/edit.html.twig
Normal file
17
templates/album/edit.html.twig
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Edit Album{% endblock %}
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Edit album</h1>
|
||||
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
|
||||
<div>{{ form_label(form.name) }}{{ form_widget(form.name, {attr: {class: 'form-control'}}) }}{{ form_errors(form.name) }}</div>
|
||||
<div>{{ form_label(form.artistsCsv) }}{{ form_widget(form.artistsCsv, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.releaseDate) }}{{ form_widget(form.releaseDate, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.totalTracks) }}{{ form_widget(form.totalTracks, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.coverUrl) }}{{ form_widget(form.coverUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<div>{{ form_label(form.externalUrl) }}{{ form_widget(form.externalUrl, {attr: {class: 'form-control'}}) }}</div>
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('album_show', {id: albumId}) }}">Cancel</a>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
</div>
|
||||
<div class="collapse col-12" id="advancedSearch">
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ (source is defined and source == 'all') or source is not defined ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ (source is defined and source == 'spotify') ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ (source is defined and source == 'user') ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album }}" placeholder="Album title" />
|
||||
</div>
|
||||
@@ -52,12 +59,14 @@
|
||||
{% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %}
|
||||
<p class="card-text"><small class="text-secondary">User score: {{ s.avg }}/10 ({{ s.count }})</small></p>
|
||||
<div class="mt-auto">
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% if album.external_urls.spotify is defined and album.external_urls.spotify %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
{% if album.source is defined and album.source == 'user' %}
|
||||
<span class="badge text-bg-primary ms-2">User album</span>
|
||||
{% endif %}
|
||||
{% if app.user %}
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||
{% if savedIds is defined and (album.id in savedIds) %}
|
||||
<span class="badge text-bg-secondary ms-2">Saved</span>
|
||||
{% else %}
|
||||
|
||||
@@ -13,17 +13,22 @@
|
||||
<div class="text-secondary mb-2">{{ album.artists|map(a => a.name)|join(', ') }}</div>
|
||||
<p class="text-secondary mb-2">Released {{ album.release_date }} • {{ album.total_tracks }} tracks</p>
|
||||
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% if album.external_urls.spotify %}
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% endif %}
|
||||
{% if album.source is defined and album.source == 'user' %}
|
||||
<span class="badge text-bg-primary ms-2">User album</span>
|
||||
{% endif %}
|
||||
{% if app.user and (isSaved is defined) and (not isSaved) %}
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') and (isSaved is defined) and (not isSaved) %}
|
||||
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: albumId}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ albumId) }}">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save album</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
{% if allowedEdit %}
|
||||
<a class="btn btn-outline-secondary btn-sm ms-2" href="{{ path('album_edit', {id: albumId}) }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if allowedDelete %}
|
||||
<form class="d-inline ms-2" method="post" action="{{ path('album_delete', {id: albumId}) }}" onsubmit="return confirm('Delete this album from the database?');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-album-' ~ albumId) }}">
|
||||
<button class="btn btn-outline-danger btn-sm" type="submit">Delete</button>
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}Album Reviews{% endblock %}
|
||||
{% block body %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h4 mb-0">Album reviews</h1>
|
||||
{% if app.user %}
|
||||
<a class="btn btn-success" href="{{ path('review_new') }}">New review</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
{% for r in reviews %}
|
||||
<div class="col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h5>
|
||||
<div class="text-secondary mb-2">{{ r.album.name }} — {{ r.album.artists|join(', ') }}</div>
|
||||
<p class="card-text">{{ r.content|u.truncate(220, '…', false) }}</p>
|
||||
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>No reviews yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}{{ review.title }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ path('review_index') }}">← Back</a></p>
|
||||
<p><a href="{{ path('account_dashboard') }}">← Back to dashboard</a></p>
|
||||
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<p class="text-secondary">
|
||||
{{ review.album.name }} — {{ review.album.artists|join(', ') }}
|
||||
|
||||
Reference in New Issue
Block a user