I lowkey forgot to commit

This commit is contained in:
2025-11-01 00:28:29 +00:00
parent f9e747633f
commit c0528310c1
54 changed files with 2154 additions and 7 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -1,2 +1,76 @@
# tonehaus
# Tonehaus — Music Ratings
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
## Quick start
1) Start the stack
```bash
docker compose up -d --build
```
2) Create the database schema
```bash
docker compose exec php php bin/console doctrine:database:create --if-not-exists
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
```
3) Promote an admin (to access Site Settings)
```bash
docker compose exec php php bin/console app:promote-admin you@example.com
```
4) Configure Spotify API credentials (admin only)
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
5) Visit `http://localhost:8000` to search for albums.
## Features
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
- Album page with details, reviews list, and inline new review (logged in)
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
- Role-based access: authors manage their own reviews, admins can manage any
- Admin Site Settings to manage Spotify credentials in DB
- User Dashboard to update profile and change password (requires current password)
- Light/Dark theme toggle in Settings (cookie-backed)
- Bootstrap UI
## Rate limiting & caching
- Server-side Client Credentials; access tokens are cached.
- Requests pass through a throttle and 429 Retry-After backoff. GET responses are cached.
- Tunables (optional):
```bash
# seconds per window (default 30)
SPOTIFY_RATE_WINDOW_SECONDS=30
# max requests per window (default 50)
SPOTIFY_RATE_MAX_REQUESTS=50
# max requests for sensitive endpoints (default 20)
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
```
## Docs
See `/docs` for how-tos and deeper notes:
- Setup and configuration: `docs/01-setup.md`
- Features and UX: `docs/02-features.md`
- Authentication and users: `docs/03-auth-and-users.md`
- Spotify integration: `docs/04-spotify-integration.md`
- Reviews and albums: `docs/05-reviews-and-albums.md`
- Admin & site settings: `docs/06-admin-and-settings.md`
- Rate limits & caching: `docs/07-rate-limits-and-caching.md`
- Troubleshooting: `docs/08-troubleshooting.md`
## License
MIT

View File

@@ -43,6 +43,7 @@
"symfony/web-link": "7.3.*",
"symfony/yaml": "7.3.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/string-extra": "^3.22",
"twig/twig": "^2.12|^3.0"
},
"config": {

BIN
config/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -4,14 +4,37 @@ security:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\User
property: email
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
form_login:
login_path: album_search
check_path: app_login
enable_csrf: true
default_target_path: album_search
failure_path: album_search
username_parameter: _username
password_parameter: _password
csrf_parameter: _csrf_token
remember_me:
secret: '%env(APP_SECRET)%'
lifetime: 1209600 # 14 days
path: '/'
secure: auto
samesite: lax
remember_me_parameter: _remember_me
logout:
path: app_logout
target: album_search
# activate different ways to authenticate
# https://symfony.com/doc/current/security.html#the-firewall

View File

@@ -18,3 +18,8 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\Service\SpotifyClient:
arguments:
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'

View File

@@ -15,10 +15,8 @@ services:
container_name: app-php
restart: unless-stopped
environment:
# App base URL (used by some tools)
DEFAULT_URI: ${DEFAULT_URI:-http://localhost:8000}
APP_ENV: ${APP_ENV:-dev}
APP_SECRET: ${APP_SECRET:-change_me}
# Symfony Messenger (dev-safe default so CLI commands don't fail)
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://}
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
volumes:
@@ -27,8 +25,11 @@ services:
- ./config:/var/www/html/config
- ./migrations:/var/www/html/migrations
- ./public:/var/www/html/public
- ./templates:/var/www/html/templates
- ./src:/var/www/html/src
- ./var:/var/www/html/var
- ./.env:/var/www/html/.env:ro
- ./vendor:/var/www/html/vendor
# Keep composer manifests on host for version control
- ./composer.json:/var/www/html/composer.json
- ./composer.lock:/var/www/html/composer.lock

BIN
docker/.DS_Store vendored Normal file

Binary file not shown.

32
docs/01-setup.md Normal file
View File

@@ -0,0 +1,32 @@
# Setup
## Prerequisites
- Docker + Docker Compose
- Spotify Developer account (for a Client ID/Secret)
## Start services
```bash
docker compose up -d --build
```
## Database
```bash
docker compose exec php php bin/console doctrine:database:create --if-not-exists
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
```
## Admin user
```bash
docker compose exec php php bin/console app:promote-admin you@example.com
```
## Spotify credentials
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret.
- Fallback to env vars:
```bash
export SPOTIFY_CLIENT_ID=your_client_id
export SPOTIFY_CLIENT_SECRET=your_client_secret
```

14
docs/02-features.md Normal file
View File

@@ -0,0 +1,14 @@
# Features
- Spotify album search with Advanced filters (album, artist, year range)
- Album page with details, list of reviews, and inline new review
- Review rating slider (110) with live badge
- Per-album aggregates: average rating and total review count
- Auth modal (Login/Sign up) with remember-me cookie
- Role-based access (author vs admin)
- Admin Site Settings to manage Spotify credentials
- User Dashboard for profile changes (email, display name, password)
- Light/Dark theme toggle (cookie-backed)
- Bootstrap UI

19
docs/03-auth-and-users.md Normal file
View File

@@ -0,0 +1,19 @@
# Authentication & Users
## Modal auth
- Login and registration happen in a Bootstrap modal.
- AJAX submits keep users on the same page; state updates after reload.
- Remember-me cookie keeps users logged in across sessions.
## Roles
- `ROLE_USER`: default for registered users.
- `ROLE_ADMIN`: promoted via console `app:promote-admin`.
## Password changes
- On `/dashboard`, users can change email/display name.
- To set a new password, the current password must be provided.
## Logout
- `/logout` (link in user menu).

View File

@@ -0,0 +1,19 @@
# Spotify Integration
## Credentials
- Prefer configuring via `/admin/settings` (stored in DB).
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
## API client
- `src/Service/SpotifyClient.php`
- Client Credentials token fetch (cached)
- `searchAlbums(q, limit)`
- `getAlbum(id)` and `getAlbums([ids])`
- Centralized request pipeline: throttling, 429 backoff, response caching
## Advanced search
- The search page builds Spotify fielded queries:
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
- Optional free-text added to the query

View File

@@ -0,0 +1,16 @@
# Reviews & Albums
## Album page
- Shows album artwork, metadata, average rating and review count.
- Lists reviews newest-first.
- Logged-in users can submit a review inline.
## Permissions
- Anyone can view.
- Authors can edit/delete their own reviews.
- Admins can edit/delete any review.
## UI
- Rating uses a slider (110) with ticks; badge shows current value.

View File

@@ -0,0 +1,17 @@
# Admin & Settings
## Site settings (ROLE_ADMIN)
- URL: `/admin/settings`
- Manage Spotify credentials stored in DB.
## User management
- Promote an admin:
```bash
docker compose exec php php bin/console app:promote-admin user@example.com
```
## Appearance
- `/settings` provides a dark/light mode toggle.
- Preference saved in a cookie; applied via `data-bs-theme`.

View File

@@ -0,0 +1,23 @@
# Rate Limits & Caching
## Throttling
- Requests are throttled per window (default 30s) to avoid bursts.
- Separate caps for sensitive endpoints.
- Configure via env:
```bash
SPOTIFY_RATE_WINDOW_SECONDS=30
SPOTIFY_RATE_MAX_REQUESTS=50
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
```
## 429 handling
- If Spotify returns 429, respects `Retry-After` and retries (up to 3 attempts).
## Response caching
- GET responses cached: search ~10 minutes, album ~1 hour.
- Token responses are cached separately.
## Batching
- `getAlbums([ids])` provided for batch lookups.

View File

@@ -0,0 +1,19 @@
# Troubleshooting
## Cannot find template or routes
- Clear cache: `docker compose exec php php bin/console cache:clear`
- List routes: `docker compose exec php php bin/console debug:router`
## Missing vendors
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
## .env not read in container
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
## Login modal shows blank
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
## Rate limits / 429
- Client backs off using `Retry-After`. Reduce concurrent requests; increase window env vars if needed.

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251031224841 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
$this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN reviews.updated_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE TABLE users (id SERIAL NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
$this->addSql('CREATE TABLE messenger_messages (id BIGSERIAL NOT NULL, body TEXT NOT NULL, headers TEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, available_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, delivered_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)');
$this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)');
$this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)');
$this->addSql('COMMENT ON COLUMN messenger_messages.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.available_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN messenger_messages.delivered_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('CREATE OR REPLACE FUNCTION notify_messenger_messages() RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(\'messenger_messages\', NEW.queue_name::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;');
$this->addSql('DROP TRIGGER IF EXISTS notify_trigger ON messenger_messages;');
$this->addSql('CREATE TRIGGER notify_trigger AFTER INSERT OR UPDATE ON messenger_messages FOR EACH ROW EXECUTE PROCEDURE notify_messenger_messages();');
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
$this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE users');
$this->addSql('DROP TABLE messenger_messages');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251031231033 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251031231715 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20251101001514 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('DROP TABLE settings');
}
}

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Command;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
class PromoteAdminCommand extends Command
{
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
{
parent::__construct();
}
protected function configure(): void
{
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$email = (string) $input->getArgument('email');
$user = $this->users->findOneByEmail($email);
if (!$user) {
$output->writeln('<error>User not found: ' . $email . '</error>');
return Command::FAILURE;
}
$roles = $user->getRoles();
if (!in_array('ROLE_ADMIN', $roles, true)) {
$roles[] = 'ROLE_ADMIN';
$user->setRoles($roles);
$this->em->flush();
}
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\ProfileFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Form\FormError;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
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
{
/** @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');
}
return $this->render('account/dashboard.html.twig', [
'form' => $form->createView(),
]);
}
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
public function settings(): Response
{
return $this->render('account/settings.html.twig');
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Controller\Admin;
use App\Form\SiteSettingsType;
use App\Repository\SettingRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[IsGranted('ROLE_ADMIN')]
class SiteSettingsController extends AbstractController
{
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
public function settings(Request $request, SettingRepository $settings): Response
{
$form = $this->createForm(SiteSettingsType::class);
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
$this->addFlash('success', 'Settings saved.');
return $this->redirectToRoute('admin_settings');
}
return $this->render('admin/settings.html.twig', [
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controller;
use App\Service\SpotifyClient;
use App\Entity\Review;
use App\Form\ReviewType;
use App\Repository\ReviewRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class AlbumController extends AbstractController
{
#[Route('/', name: 'album_search', methods: ['GET'])]
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
{
$query = trim((string) $request->query->get('q', ''));
$albumName = trim($request->query->getString('album', ''));
$artist = trim($request->query->getString('artist', ''));
// 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', ''));
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
$albums = [];
$stats = [];
// Build Spotify fielded search if advanced inputs are supplied
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
$q = $query;
if ($advancedUsed) {
$parts = [];
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
if ($yearFrom > 0 || $yearTo > 0) {
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
} else {
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
$parts[] = 'year:' . $y;
}
}
// also include free-text if provided
if ($query !== '') { $parts[] = $query; }
$q = implode(' ', $parts);
}
if ($q !== '') {
$result = $spotifyClient->searchAlbums($q, 20);
$albums = $result['albums']['items'] ?? [];
if ($albums) {
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
if ($ids) {
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
}
}
}
return $this->render('album/search.html.twig', [
'query' => $query,
'album' => $albumName,
'artist' => $artist,
'year_from' => $yearFrom ?: '',
'year_to' => $yearTo ?: '',
'albums' => $albums,
'stats' => $stats,
]);
}
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
{
$album = $spotifyClient->getAlbum($id);
if ($album === null) {
throw $this->createNotFoundException('Album not found');
}
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
$count = count($existing);
$avg = 0.0;
if ($count > 0) {
$sum = 0;
foreach ($existing as $rev) { $sum += (int) $rev->getRating(); }
$avg = round($sum / $count, 1);
}
// Pre-populate required album metadata before validation so entity constraints pass
$review = new Review();
$review->setSpotifyAlbumId($id);
$review->setAlbumName($album['name'] ?? '');
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->denyAccessUnlessGranted('ROLE_USER');
$review->setAuthor($this->getUser());
$em->persist($review);
$em->flush();
$this->addFlash('success', 'Review added.');
return $this->redirectToRoute('album_show', ['id' => $id]);
}
return $this->render('album/show.html.twig', [
'album' => $album,
'albumId' => $id,
'reviews' => $existing,
'avg' => $avg,
'count' => $count,
'form' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Form\RegistrationFormType;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
class RegistrationController extends AbstractController
{
#[Route('/register', name: 'app_register')]
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
{
// For GET (non-XHR), redirect to home and let the modal open
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
return $this->redirectToRoute('album_search', ['auth' => 'register']);
}
$user = new User();
$form = $this->createForm(RegistrationFormType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$plainPassword = (string) $form->get('plainPassword')->getData();
$hashed = $passwordHasher->hashPassword($user, $plainPassword);
$user->setPassword($hashed);
$entityManager->persist($user);
$entityManager->flush();
if ($request->isXmlHttpRequest()) {
return new JsonResponse(['ok' => true]);
}
$this->addFlash('success', 'Account created. You can now sign in.');
return $this->redirectToRoute('app_login');
}
if ($request->isXmlHttpRequest()) {
// Flatten form errors for the modal
$errors = [];
foreach ($form->getErrors(true) as $error) {
$origin = $error->getOrigin();
$name = $origin ? $origin->getName() : 'form';
$errors[$name][] = $error->getMessage();
}
return new JsonResponse(['ok' => false, 'errors' => $errors], 422);
}
return $this->render('registration/register.html.twig', [
'registrationForm' => $form->createView(),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Controller;
use App\Entity\Review;
use App\Form\ReviewType;
use App\Repository\ReviewRepository;
use App\Service\SpotifyClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/reviews')]
class ReviewController extends AbstractController
{
#[Route('', name: 'review_index', methods: ['GET'])]
public function index(ReviewRepository $reviewRepository): Response
{
$reviews = $reviewRepository->findLatest(50);
return $this->render('review/index.html.twig', [
'reviews' => $reviews,
]);
}
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function new(Request $request): Response
{
$albumId = (string) $request->query->get('album_id', '');
if ($albumId !== '') {
return $this->redirectToRoute('album_show', ['id' => $albumId]);
}
$this->addFlash('info', 'Select an album first.');
return $this->redirectToRoute('album_search');
}
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
public function show(Review $review): Response
{
return $this->render('review/show.html.twig', [
'review' => $review,
]);
}
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
#[IsGranted('ROLE_USER')]
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
{
$this->denyAccessUnlessGranted('REVIEW_EDIT', $review);
$form = $this->createForm(ReviewType::class, $review);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$this->addFlash('success', 'Review updated.');
return $this->redirectToRoute('review_show', ['id' => $review->getId()]);
}
return $this->render('review/edit.html.twig', [
'form' => $form->createView(),
'review' => $review,
]);
}
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
{
$this->denyAccessUnlessGranted('REVIEW_DELETE', $review);
if ($this->isCsrfTokenValid('delete_review_' . $review->getId(), (string) $request->request->get('_token'))) {
$em->remove($review);
$em->flush();
$this->addFlash('success', 'Review deleted.');
}
return $this->redirectToRoute('review_index');
}
// fetchAlbumById no longer needed; album view handles retrieval and creation
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class SecurityController extends AbstractController
{
#[Route('/login', name: 'app_login')]
public function login(Request $request, AuthenticationUtils $authenticationUtils): Response
{
// Keep this route so the firewall can use it as check_path for POST.
// For GET requests, redirect to the main page and let the modal handle UI.
if ($request->isMethod('GET')) {
return new RedirectResponse($this->generateUrl('album_search', ['auth' => 'login']));
}
return new Response(status: 204);
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// Controller can be blank: it will be intercepted by the logout key on your firewall
}
}

88
src/Entity/Review.php Normal file
View File

@@ -0,0 +1,88 @@
<?php
namespace App\Entity;
use App\Repository\ReviewRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
#[ORM\Table(name: 'reviews')]
#[ORM\HasLifecycleCallbacks]
class Review
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?User $author = null;
#[ORM\Column(type: 'string', length: 64)]
#[Assert\NotBlank]
private string $spotifyAlbumId = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumName = '';
#[ORM\Column(type: 'string', length: 255)]
#[Assert\NotBlank]
private string $albumArtist = '';
#[ORM\Column(type: 'string', length: 160)]
#[Assert\NotBlank]
#[Assert\Length(max: 160)]
private string $title = '';
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
#[Assert\Length(min: 20, max: 5000)]
private string $content = '';
#[ORM\Column(type: 'smallint')]
#[Assert\Range(min: 1, max: 10)]
private int $rating = 5;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$now = new \DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
#[ORM\PreUpdate]
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
public function getId(): ?int { return $this->id; }
public function getAuthor(): ?User { return $this->author; }
public function setAuthor(User $author): void { $this->author = $author; }
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
public function getAlbumName(): string { return $this->albumName; }
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
public function getAlbumArtist(): string { return $this->albumArtist; }
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
public function getTitle(): string { return $this->title; }
public function setTitle(string $title): void { $this->title = $title; }
public function getContent(): string { return $this->content; }
public function setContent(string $content): void { $this->content = $content; }
public function getRating(): int { return $this->rating; }
public function setRating(int $rating): void { $this->rating = $rating; }
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
}

33
src/Entity/Setting.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Entity;
use App\Repository\SettingRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SettingRepository::class)]
#[ORM\Table(name: 'settings')]
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
class Setting
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 100)]
#[Assert\NotBlank]
private string $name = '';
#[ORM\Column(type: 'text', nullable: true)]
private ?string $value = null;
public function getId(): ?int { return $this->id; }
public function getName(): string { return $this->name; }
public function setName(string $name): void { $this->name = $name; }
public function getValue(): ?string { return $this->value; }
public function setValue(?string $value): void { $this->value = $value; }
}

116
src/Entity/User.php Normal file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Entity;
use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ORM\Table(name: 'users')]
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 180, unique: true)]
#[Assert\NotBlank]
#[Assert\Email]
private string $email = '';
/**
* @var list<string>
*/
#[ORM\Column(type: 'json')]
private array $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column(type: 'string')]
private string $password = '';
#[ORM\Column(type: 'string', length: 120, nullable: true)]
#[Assert\Length(max: 120)]
private ?string $displayName = null;
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): void
{
$this->email = strtolower($email);
}
public function getUserIdentifier(): string
{
return $this->email;
}
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
if (!in_array('ROLE_USER', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
/**
* @param list<string> $roles
*/
public function setRoles(array $roles): void
{
$this->roles = array_values(array_unique($roles));
}
public function addRole(string $role): void
{
$roles = $this->getRoles();
if (!in_array($role, $roles, true)) {
$roles[] = $role;
}
$this->roles = $roles;
}
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $hashedPassword): void
{
$this->password = $hashedPassword;
}
public function eraseCredentials(): void
{
// no-op
}
public function getDisplayName(): ?string
{
return $this->displayName;
}
public function setDisplayName(?string $displayName): void
{
$this->displayName = $displayName;
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ProfileFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
])
->add('displayName', TextType::class, [
'required' => false,
'constraints' => [new Assert\Length(max: 120)],
])
->add('currentPassword', PasswordType::class, [
'mapped' => false,
'required' => false,
'label' => 'Current password (required to change password)'
])
->add('newPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'required' => false,
'first_options' => ['label' => 'New password (optional)'],
'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([
'data_class' => User::class,
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Form;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class RegistrationFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'required' => true,
'constraints' => [new Assert\NotBlank(), new Assert\Email()],
])
->add('displayName', TextType::class, [
'required' => false,
'constraints' => [new Assert\Length(max: 120)],
])
->add('plainPassword', RepeatedType::class, [
'type' => PasswordType::class,
'mapped' => false,
'first_options' => ['label' => 'Password'],
'second_options' => ['label' => 'Repeat Password'],
'invalid_message' => 'The password fields must match.',
'constraints' => [
new Assert\NotBlank(groups: ['registration']),
new Assert\Length(min: 8, groups: ['registration']),
],
])
->add('register', SubmitType::class, [
'label' => 'Create account',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}

46
src/Form/ReviewType.php Normal file
View File

@@ -0,0 +1,46 @@
<?php
namespace App\Form;
use App\Entity\Review;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\RangeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;
class ReviewType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('title', TextType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 160)],
])
->add('content', TextareaType::class, [
'constraints' => [new Assert\NotBlank(), new Assert\Length(min: 20, max: 5000)],
'attr' => ['rows' => 8],
])
->add('rating', RangeType::class, [
'constraints' => [new Assert\Range(min: 1, max: 10)],
'attr' => [
'min' => 1,
'max' => 10,
'step' => 1,
'class' => 'form-range',
],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Review::class,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class SiteSettingsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('SPOTIFY_CLIENT_ID', TextType::class, [
'required' => false,
'label' => 'Spotify Client ID',
'mapped' => false,
])
->add('SPOTIFY_CLIENT_SECRET', TextType::class, [
'required' => false,
'label' => 'Spotify Client Secret',
'mapped' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Repository;
use App\Entity\Review;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Review>
*/
class ReviewRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Review::class);
}
/**
* @return list<Review>
*/
public function findLatest(int $limit = 20): array
{
return $this->createQueryBuilder('r')
->orderBy('r.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
/**
* Return aggregates for albums: [albumId => ['count' => int, 'avg' => float]].
*
* @param list<string> $albumIds
* @return array<string,array{count:int,avg:float}>
*/
public function getAggregatesForAlbumIds(array $albumIds): array
{
if ($albumIds === []) {
return [];
}
$rows = $this->createQueryBuilder('r')
->select('r.spotifyAlbumId AS albumId, COUNT(r.id) AS cnt, AVG(r.rating) AS avgRating')
->where('r.spotifyAlbumId IN (:ids)')
->setParameter('ids', $albumIds)
->groupBy('r.spotifyAlbumId')
->getQuery()
->getArrayResult();
$out = [];
foreach ($rows as $row) {
$avg = isset($row['avgRating']) ? round((float) $row['avgRating'], 1) : 0.0;
$out[$row['albumId']] = ['count' => (int) $row['cnt'], 'avg' => $avg];
}
return $out;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Repository;
use App\Entity\Setting;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class SettingRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Setting::class);
}
public function getValue(string $name, ?string $default = null): ?string
{
$setting = $this->findOneBy(['name' => $name]);
return $setting?->getValue() ?? $default;
}
public function setValue(string $name, ?string $value): void
{
$em = $this->getEntityManager();
$setting = $this->findOneBy(['name' => $name]) ?? (new Setting())->setName($name);
$setting->setValue($value);
$em->persist($setting);
$em->flush();
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Repository;
use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<User>
*/
class UserRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, User::class);
}
public function findOneByEmail(string $email): ?User
{
return $this->createQueryBuilder('u')
->andWhere('LOWER(u.email) = :email')
->setParameter('email', strtolower($email))
->getQuery()
->getOneOrNullResult();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Security;
use App\Entity\Review;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class ReviewVoter extends Voter
{
public const EDIT = 'REVIEW_EDIT';
public const DELETE = 'REVIEW_DELETE';
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::EDIT, self::DELETE], true) && $subject instanceof Review;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return true;
}
/** @var Review $review */
$review = $subject;
return $review->getAuthor()?->getId() === $user->getId();
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Service;
use Psr\Cache\CacheItemPoolInterface;
use App\Repository\SettingRepository;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class SpotifyClient
{
private HttpClientInterface $httpClient;
private CacheInterface $cache;
private ?string $clientId;
private ?string $clientSecret;
private SettingRepository $settings;
private int $rateWindowSeconds;
private int $rateMaxRequests;
private int $rateMaxRequestsSensitive;
public function __construct(
HttpClientInterface $httpClient,
CacheInterface $cache,
string $clientId,
string $clientSecret,
SettingRepository $settings
) {
$this->httpClient = $httpClient;
$this->cache = $cache;
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->settings = $settings;
// Allow tuning via env vars; fallback to conservative defaults
$this->rateWindowSeconds = (int) (getenv('SPOTIFY_RATE_WINDOW_SECONDS') ?: 30);
$this->rateMaxRequests = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS') ?: 50);
$this->rateMaxRequestsSensitive = (int) (getenv('SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE') ?: 20);
}
/**
* Search Spotify albums by query string.
*
* @param string $query
* @param int $limit
* @return array<mixed>
*/
public function searchAlbums(string $query, int $limit = 12): array
{
$accessToken = $this->getAccessToken();
if ($accessToken === null) {
return ['albums' => ['items' => []]];
}
$url = 'https://api.spotify.com/v1/search';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'q' => $query, 'type' => 'album', 'limit' => $limit ],
];
return $this->sendRequest('GET', $url, $options, 600, false);
}
/**
* Fetch a single album by Spotify ID.
*
* @return array<mixed>|null
*/
public function getAlbum(string $albumId): ?array
{
$accessToken = $this->getAccessToken();
if ($accessToken === null) {
return null;
}
$url = 'https://api.spotify.com/v1/albums/' . urlencode($albumId);
$options = [ 'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ] ];
try {
return $this->sendRequest('GET', $url, $options, 3600, false);
} catch (\Throwable) {
return null;
}
}
/**
* Fetch multiple albums with one call.
*
* @param list<string> $albumIds
* @return array<mixed>|null
*/
public function getAlbums(array $albumIds): ?array
{
if ($albumIds === []) { return []; }
$accessToken = $this->getAccessToken();
if ($accessToken === null) { return null; }
$url = 'https://api.spotify.com/v1/albums';
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'ids' => implode(',', $albumIds) ],
];
try {
return $this->sendRequest('GET', $url, $options, 3600, false);
} catch (\Throwable) {
return null;
}
}
/**
* Centralized request with basic throttling, caching and 429 handling.
*
* @param array<string,mixed> $options
* @return array<mixed>
*/
private function sendRequest(string $method, string $url, array $options, int $cacheTtlSeconds = 0, bool $sensitive = false): array
{
$cacheKey = null;
if ($cacheTtlSeconds > 0 && strtoupper($method) === 'GET') {
$cacheKey = 'spotify_resp_' . sha1($url . '|' . json_encode($options['query'] ?? []));
$cached = $this->cache->get($cacheKey, function($item) use ($cacheTtlSeconds) {
// placeholder; we'll set item value explicitly below on miss
$item->expiresAfter(1);
return null;
});
if (is_array($cached) && !empty($cached)) {
return $cached;
}
}
$this->throttle($sensitive);
$attempts = 0;
while (true) {
++$attempts;
$response = $this->httpClient->request($method, $url, $options);
$status = $response->getStatusCode();
if ($status === 429) {
$retryAfter = (int) ($response->getHeaders()['retry-after'][0] ?? 1);
$retryAfter = max(1, min(30, $retryAfter));
sleep($retryAfter);
if ($attempts < 3) { continue; }
}
$data = $response->toArray(false);
if ($cacheKey && $cacheTtlSeconds > 0 && is_array($data)) {
$this->cache->get($cacheKey, function($item) use ($data, $cacheTtlSeconds) {
$item->expiresAfter($cacheTtlSeconds);
return $data;
});
}
return $data;
}
}
private function throttle(bool $sensitive): void
{
$windowKey = $sensitive ? 'spotify_rate_sensitive' : 'spotify_rate';
$max = $sensitive ? $this->rateMaxRequestsSensitive : $this->rateMaxRequests;
$now = time();
$entry = $this->cache->get($windowKey, function($item) use ($now) {
$item->expiresAfter($this->rateWindowSeconds);
return ['start' => $now, 'count' => 0];
});
if (!is_array($entry) || !isset($entry['start'], $entry['count'])) {
$entry = ['start' => $now, 'count' => 0];
}
$start = (int) $entry['start'];
$count = (int) $entry['count'];
$elapsed = $now - $start;
if ($elapsed >= $this->rateWindowSeconds) {
$start = $now; $count = 0;
}
if ($count >= $max) {
$sleep = max(1, $this->rateWindowSeconds - $elapsed);
sleep($sleep);
$start = time(); $count = 0;
}
$count++;
$newEntry = ['start' => $start, 'count' => $count];
$this->cache->get($windowKey, function($item) use ($newEntry) {
$item->expiresAfter($this->rateWindowSeconds);
return $newEntry;
});
}
private function getAccessToken(): ?string
{
return $this->cache->get('spotify_client_credentials_token', function ($item) {
// Default to 1 hour, will adjust based on response
$item->expiresAfter(3500);
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? '');
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? '');
if ($clientId === '' || $clientSecret === '') {
return null;
}
$response = $this->httpClient->request('POST', 'https://accounts.spotify.com/api/token', [
'headers' => [
'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $clientSecret),
'Content-Type' => 'application/x-www-form-urlencoded',
],
'body' => 'grant_type=client_credentials',
]);
$data = $response->toArray(false);
if (!isset($data['access_token'])) {
return null;
}
if (isset($data['expires_in']) && is_int($data['expires_in'])) {
$ttl = max(60, $data['expires_in'] - 60);
$item->expiresAfter($ttl);
}
return $data['access_token'];
});
}
}

BIN
templates/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,124 @@
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="authModalLabel">Account</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div data-auth-panel="login" class="d-none">
<form data-auth-login action="{{ path('app_login') }}" method="post" class="vstack gap-2">
<div>
<label class="form-label">Email</label>
<input class="form-control" type="email" name="_username" required autocomplete="email" />
</div>
<div>
<label class="form-label">Password</label>
<input class="form-control" type="password" name="_password" required autocomplete="current-password" />
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="_remember_me" id="rememberMe" checked>
<label class="form-check-label" for="rememberMe">Remember me</label>
</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" />
<input type="hidden" name="_target_path" value="" data-auth-target />
<input type="hidden" name="_failure_path" value="" data-auth-failure />
<button class="btn btn-success" type="submit">Login</button>
<button class="btn btn-outline-success" type="button" data-auth-open-register>Sign up</button>
</form>
<div class="text-danger small mt-2 d-none" data-auth-login-error></div>
</div>
<div data-auth-panel="register" class="d-none">
<form data-auth-register action="{{ path('app_register') }}" method="post" class="vstack gap-2">
<input type="hidden" name="registration_form[_token]" value="{{ csrf_token('registration_form') }}" />
<div><label class="form-label">Email</label><input class="form-control" type="email" name="registration_form[email]" required /></div>
<div><label class="form-label">Display name (optional)</label><input class="form-control" type="text" name="registration_form[displayName]" maxlength="120" /></div>
<div><label class="form-label">Password</label><input class="form-control" type="password" name="registration_form[plainPassword][first]" minlength="8" required /></div>
<div><label class="form-label">Repeat password</label><input class="form-control" type="password" name="registration_form[plainPassword][second]" minlength="8" required /></div>
<button class="btn btn-success" type="submit">Create account</button>
<button class="btn btn-outline-secondary" type="button" data-auth-open-login>Back to login</button>
</form>
<div class="text-danger small mt-2 d-none" data-auth-register-error></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function(){
const modalEl = document.getElementById('authModal');
if (!modalEl) return;
const bsModal = new bootstrap.Modal(modalEl);
const panels = modalEl.querySelectorAll('[data-auth-panel]');
function showPanel(kind){
panels.forEach(p => p.classList.toggle('d-none', p.getAttribute('data-auth-panel') !== kind));
bsModal.show();
}
modalEl.querySelector('[data-auth-open-register]')?.addEventListener('click', ()=> showPanel('register'));
modalEl.querySelector('[data-auth-open-login]')?.addEventListener('click', ()=> showPanel('login'));
document.querySelectorAll('[data-open-auth]')?.forEach(btn => {
btn.addEventListener('click', (e)=>{ e.preventDefault(); showPanel(btn.getAttribute('data-open-auth') || 'login'); });
});
const currentUrl = location.pathname + location.search + location.hash;
modalEl.querySelector('[data-auth-target]')?.setAttribute('value', currentUrl);
modalEl.querySelector('[data-auth-failure]')?.setAttribute('value', currentUrl);
// AJAX login
const loginForm = modalEl.querySelector('form[data-auth-login]');
const loginError = modalEl.querySelector('[data-auth-login-error]');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (loginError) { loginError.classList.add('d-none'); }
try {
const resp = await fetch(loginForm.action, { method: 'POST', body: new FormData(loginForm), credentials: 'same-origin' });
if (resp.ok || resp.status === 302) {
bsModal.hide();
location.reload();
} else {
if (loginError) { loginError.textContent = 'Login failed. Please check your credentials.'; loginError.classList.remove('d-none'); }
}
} catch (_) {
if (loginError) { loginError.textContent = 'Network error. Please try again.'; loginError.classList.remove('d-none'); }
}
});
}
// AJAX registration
const regForm = modalEl.querySelector('form[data-auth-register]');
const regError = modalEl.querySelector('[data-auth-register-error]');
if (regForm) {
regForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (regError) { regError.classList.add('d-none'); }
try {
const resp = await fetch(regForm.action, { method: 'POST', body: new FormData(regForm), credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });
if (resp.ok) {
showPanel('login');
if (loginError) { loginError.textContent = 'Account created. You can now sign in.'; loginError.classList.remove('d-none'); }
} else if (resp.status === 422) {
const data = await resp.json();
const messages = Object.values(data.errors || {}).flat().join(' ');
if (regError) { regError.textContent = messages || 'Please correct the highlighted fields.'; regError.classList.remove('d-none'); }
} else {
if (regError) { regError.textContent = 'Registration failed. Please try again.'; regError.classList.remove('d-none'); }
}
} catch (_) {
if (regError) { regError.textContent = 'Network error. Please try again.'; regError.classList.remove('d-none'); }
}
});
}
// auto-open via ?auth=
const params = new URLSearchParams(location.search);
const authParam = params.get('auth');
if (authParam === 'login' || authParam === 'register') {
showPanel(authParam);
}
window.__openAuthModal = showPanel;
})();
</script>

View File

@@ -0,0 +1,40 @@
<nav class="navbar navbar-expand-lg bg-body-tertiary" data-auth-header>
<div class="container">
<a class="navbar-brand fw-bold" href="{{ path('album_search') }}">Tonehaus</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</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>
</ul>
<div class="d-flex align-items-center gap-3">
{% if app.user %}
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle d-flex align-items-center gap-2" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
<path fill-rule="evenodd" d="M14 14s-1-1.5-6-1.5S2 14 2 14s1-4 6-4 6 4 6 4z"/>
</svg>
<span class="text-truncate" style="max-width: 180px;">{{ app.user.displayName ?? app.user.userIdentifier }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% if is_granted('ROLE_ADMIN') %}
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site settings</a></li>
<li><hr class="dropdown-divider"></li>
{% endif %}
<li><a class="dropdown-item" href="{{ path('account_dashboard') }}">Dashboard</a></li>
<li><a class="dropdown-item" href="{{ path('account_settings') }}">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ path('app_logout') }}">Logout</a></li>
</ul>
</div>
{% else %}
<button class="btn btn-success" type="button" data-open-auth="login">Login / Sign up</button>
{% endif %}
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Dashboard{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Your profile</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) }}
{% endblock %}

View File

@@ -0,0 +1,35 @@
{% extends 'base.html.twig' %}
{% block title %}Settings{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Settings</h1>
<div class="card">
<div class="card-body">
<h2 class="h6">Appearance</h2>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="themeToggle">
<label class="form-check-label" for="themeToggle">Dark mode</label>
</div>
<small class="text-secondary">Your choice is saved in a cookie.</small>
</div>
</div>
<script>
(function(){
const key = 'theme';
const root = document.documentElement;
const current = (document.cookie.match(/(?:^|; )theme=([^;]+)/)?.[1] || '').replace(/\+/g,' ');
const initial = current || root.getAttribute('data-bs-theme') || 'light';
const toggle = document.getElementById('themeToggle');
toggle.checked = initial === 'dark';
function setTheme(t){
root.setAttribute('data-bs-theme', t);
const d = new Date(); d.setFullYear(d.getFullYear()+1);
document.cookie = key+'='+t+'; path=/; SameSite=Lax; expires='+d.toUTCString();
}
toggle.addEventListener('change', ()=> setTheme(toggle.checked ? 'dark' : 'light'));
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,26 @@
{% extends 'base.html.twig' %}
{% block title %}Site Settings{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Site Settings</h1>
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
<div class="card">
<div class="card-body">
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>
{{ form_label(form.SPOTIFY_CLIENT_ID) }}
{{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }}
</div>
<div>
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
</div>
<div>
<button class="btn btn-success" type="submit">Save settings</button>
</div>
{{ form_end(form) }}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends 'base.html.twig' %}
{% block title %}Album Search{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Search Albums</h1>
<form class="row g-2 mb-2" action="{{ path('album_search') }}" method="get">
<div class="col-sm">
<input class="form-control" type="search" name="q" value="{{ query }}" placeholder="Free text (optional)" autocomplete="off" />
</div>
<div class="col-auto">
<button class="btn btn-success" type="submit">Search</button>
</div>
<div class="col-12">
<a class="link-secondary" data-bs-toggle="collapse" href="#advancedSearch" role="button" aria-expanded="false" aria-controls="advancedSearch">Advanced search</a>
</div>
<div class="collapse col-12" id="advancedSearch">
<div class="row g-2 mt-1">
<div class="col-sm-4">
<input class="form-control" type="text" name="album" value="{{ album }}" placeholder="Album title" />
</div>
<div class="col-sm-4">
<input class="form-control" type="text" name="artist" value="{{ artist }}" placeholder="Artist" />
</div>
<div class="col-sm-2">
<input class="form-control" type="number" name="year_from" value="{{ year_from }}" placeholder="Year from" min="1900" max="2100" />
</div>
<div class="col-sm-2">
<input class="form-control" type="number" name="year_to" value="{{ year_to }}" placeholder="Year to" min="1900" max="2100" />
</div>
</div>
</div>
</form>
{% if query is empty and (album is empty) and (artist is empty) and (year_from is empty) and (year_to is empty) %}
<p class="text-secondary">Tip: Use the Advanced search to filter by album, artist, or year range.</p>
{% endif %}
{% if albums is defined and albums|length > 0 %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-3">
{% for album in albums %}
<div class="col">
<div class="card h-100">
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
{% if image %}
<a href="{{ path('album_show', {id: album.id}) }}">
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
</a>
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title"><a href="{{ path('album_show', {id: album.id}) }}" class="text-decoration-none">{{ album.name }}</a></h5>
<p class="card-text text-secondary">{{ album.artists|map(a => a.name)|join(', ') }}</p>
<p class="card-text text-secondary">Released {{ album.release_date }}{{ album.total_tracks }} tracks</p>
{% 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>
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% elseif query or album or artist or year_from or year_to %}
<p>No albums found.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends 'base.html.twig' %}
{% block title %}{{ album.name }} — Reviews{% endblock %}
{% block body %}
<div class="row g-4 mb-4">
<div class="col-md-4">
<div class="card h-100">
{% set image = (album.images[1] ?? album.images[0] ?? null) %}
{% if image %}
<img class="card-img-top" src="{{ image.url }}" alt="{{ album.name }} cover" />
{% endif %}
<div class="card-body">
<h5 class="card-title mb-1">{{ album.name }}</h5>
<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>
</div>
</div>
</div>
<div class="col-md-8">
<div class="d-flex justify-content-between align-items-center mb-2">
<h2 class="h5 mb-0">Reviews</h2>
</div>
<div class="vstack gap-3 mb-4">
{% for r in reviews %}
<div class="card">
<div class="card-body">
<h6 class="card-title mb-1">{{ r.title }} <span class="text-secondary">(Rating {{ r.rating }}/10)</span></h6>
<div class="text-secondary mb-2">by {{ r.author.displayName ?? r.author.userIdentifier }}{{ r.createdAt|date('Y-m-d H:i') }}</div>
<p class="card-text">{{ r.content|u.truncate(300, '…', false) }}</p>
<a class="btn btn-link p-0" href="{{ path('review_show', {id: r.id}) }}">Read more</a>
</div>
</div>
{% else %}
<p class="text-secondary">No reviews yet for this album.</p>
{% endfor %}
</div>
{% if app.user %}
<div class="card">
<div class="card-body">
<h3 class="h6">Leave a review</h3>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 6}}) }}{{ form_errors(form.content) }}</div>
<div>
{{ form_label(form.rating) }}
<div class="d-flex align-items-center gap-3">
{{ form_widget(form.rating, {attr: {class: 'form-range', min:1, max:10, step:1, list:'rating-ticks', oninput:'document.getElementById("rating-value").textContent=this.value;'}}) }}
<span id="rating-value" class="badge text-bg-success">{{ form.rating.vars.value ?? 5 }}</span>
</div>
<datalist id="rating-ticks">
{% for i in 1..10 %}<option value="{{ i }}">{{ i }}</option>{% endfor %}
</datalist>
{{ form_errors(form.rating) }}
</div>
<button class="btn btn-success" type="submit">Post review</button>
{{ form_end(form) }}
</div>
</div>
{% else %}
<div class="alert alert-info">Sign in to leave a review. <button class="btn btn-sm btn-success ms-2" type="button" data-open-auth="login">Login / Sign up</button></div>
{% endif %}
</div>
</div>
{% endblock %}

20
templates/base.html.twig Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Music Ratings{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
</head>
<body>
{% include '_partials/navbar.html.twig' %}
<main class="container py-4">
{% block body %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
{% include '_partials/auth_modal.html.twig' %}
</body>
</html>

View File

@@ -0,0 +1,17 @@
{% extends 'base.html.twig' %}
{% block title %}Edit Review{% endblock %}
{% block body %}
<h1 class="h4 mb-1">Edit review</h1>
<p class="text-secondary">{{ review.albumName }}{{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}</div>
<div>{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}</div>
<button class="btn btn-success" type="submit">Update review</button>
{{ form_end(form) }}
<p class="mt-3"><a href="{{ path('review_show', {id: review.id}) }}">Back to review</a></p>
{% endblock %}

View File

@@ -0,0 +1,29 @@
{% 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.albumName }}{{ r.albumArtist }}</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 %}

View File

@@ -0,0 +1,30 @@
{% extends 'base.html.twig' %}
{% block title %}New Review{% endblock %}
{% block body %}
<h1 class="h4 mb-3">Write a review</h1>
<div class="row g-2 mb-3">
<div class="col-md-6">
<label class="form-label">Spotify Album ID</label>
<input class="form-control" type="text" name="spotifyAlbumId" value="{{ review.spotifyAlbumId }}" placeholder="e.g. 4m2880jivSbbyEGAKfITCa" />
</div>
<div class="col-md-6">
<label class="form-label">Artist</label>
<input class="form-control" type="text" name="albumArtist" value="{{ review.albumArtist }}" />
</div>
</div>
<div class="mb-3">
<label class="form-label">Album Title</label>
<input class="form-control" type="text" name="albumName" value="{{ review.albumName }}" />
</div>
{{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }}
<div>{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}</div>
<div>{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}</div>
<div>{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}</div>
<button class="btn btn-success" type="submit">Save review</button>
{{ form_end(form) }}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends 'base.html.twig' %}
{% block title %}{{ review.title }}{% endblock %}
{% block body %}
<p><a href="{{ path('review_index') }}">← Back</a></p>
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
<p class="text-secondary">{{ review.albumName }}{{ review.albumArtist }} ({{ review.spotifyAlbumId }})</p>
<article class="mb-3">
<p>{{ review.content|nl2br }}</p>
</article>
{% if is_granted('REVIEW_EDIT', review) %}
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="{{ path('review_edit', {id: review.id}) }}">Edit</a>
<form action="{{ path('review_delete', {id: review.id}) }}" method="post" onsubmit="return confirm('Delete this review?')">
<input type="hidden" name="_token" value="{{ csrf_token('delete_review_' ~ review.id) }}" />
<button class="btn btn-danger" type="submit">Delete</button>
</form>
</div>
{% endif %}
{% endblock %}

BIN
var/.DS_Store vendored Normal file

Binary file not shown.