diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..2e6d3ac Binary files /dev/null and b/.DS_Store differ diff --git a/README.md b/README.md index 7f7e8e0..8f09378 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/composer.json b/composer.json index 3f8569c..8e21133 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000..960bcf9 Binary files /dev/null and b/config/.DS_Store differ diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..30cbf4a 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 diff --git a/config/services.yaml b/config/services.yaml index 6bbad87..5c4ce08 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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)%' diff --git a/docker-compose.yml b/docker-compose.yml index f822867..809d075 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/.DS_Store b/docker/.DS_Store new file mode 100644 index 0000000..bd1217c Binary files /dev/null and b/docker/.DS_Store differ diff --git a/docs/01-setup.md b/docs/01-setup.md new file mode 100644 index 0000000..c75a7fa --- /dev/null +++ b/docs/01-setup.md @@ -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 +``` + + diff --git a/docs/02-features.md b/docs/02-features.md new file mode 100644 index 0000000..6ff6938 --- /dev/null +++ b/docs/02-features.md @@ -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 (1–10) 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 + + diff --git a/docs/03-auth-and-users.md b/docs/03-auth-and-users.md new file mode 100644 index 0000000..d0ad452 --- /dev/null +++ b/docs/03-auth-and-users.md @@ -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). + + diff --git a/docs/04-spotify-integration.md b/docs/04-spotify-integration.md new file mode 100644 index 0000000..96c4a91 --- /dev/null +++ b/docs/04-spotify-integration.md @@ -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 + + diff --git a/docs/05-reviews-and-albums.md b/docs/05-reviews-and-albums.md new file mode 100644 index 0000000..7c11045 --- /dev/null +++ b/docs/05-reviews-and-albums.md @@ -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 (1–10) with ticks; badge shows current value. + + diff --git a/docs/06-admin-and-settings.md b/docs/06-admin-and-settings.md new file mode 100644 index 0000000..3ed6cef --- /dev/null +++ b/docs/06-admin-and-settings.md @@ -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`. + + diff --git a/docs/07-rate-limits-and-caching.md b/docs/07-rate-limits-and-caching.md new file mode 100644 index 0000000..45af1df --- /dev/null +++ b/docs/07-rate-limits-and-caching.md @@ -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. + + diff --git a/docs/08-troubleshooting.md b/docs/08-troubleshooting.md new file mode 100644 index 0000000..06dc479 --- /dev/null +++ b/docs/08-troubleshooting.md @@ -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. + + diff --git a/migrations/Version20251031224841.php b/migrations/Version20251031224841.php new file mode 100644 index 0000000..f85be80 --- /dev/null +++ b/migrations/Version20251031224841.php @@ -0,0 +1,56 @@ +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'); + } +} diff --git a/migrations/Version20251031231033.php b/migrations/Version20251031231033.php new file mode 100644 index 0000000..d6ad6c7 --- /dev/null +++ b/migrations/Version20251031231033.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/migrations/Version20251031231715.php b/migrations/Version20251031231715.php new file mode 100644 index 0000000..d61bb7a --- /dev/null +++ b/migrations/Version20251031231715.php @@ -0,0 +1,31 @@ +addSql('CREATE SCHEMA public'); + } +} diff --git a/migrations/Version20251101001514.php b/migrations/Version20251101001514.php new file mode 100644 index 0000000..ed97a60 --- /dev/null +++ b/migrations/Version20251101001514.php @@ -0,0 +1,33 @@ +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'); + } +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..d56834b Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/Command/PromoteAdminCommand.php b/src/Command/PromoteAdminCommand.php new file mode 100644 index 0000000..27f6b65 --- /dev/null +++ b/src/Command/PromoteAdminCommand.php @@ -0,0 +1,47 @@ +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('User not found: ' . $email . ''); + return Command::FAILURE; + } + + $roles = $user->getRoles(); + if (!in_array('ROLE_ADMIN', $roles, true)) { + $roles[] = 'ROLE_ADMIN'; + $user->setRoles($roles); + $this->em->flush(); + } + + $output->writeln('Granted ROLE_ADMIN to ' . $email . ''); + return Command::SUCCESS; + } +} + + diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php new file mode 100644 index 0000000..bb1f90a --- /dev/null +++ b/src/Controller/AccountController.php @@ -0,0 +1,56 @@ +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'); + } +} + + diff --git a/src/Controller/Admin/SiteSettingsController.php b/src/Controller/Admin/SiteSettingsController.php new file mode 100644 index 0000000..cec2ff2 --- /dev/null +++ b/src/Controller/Admin/SiteSettingsController.php @@ -0,0 +1,37 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/AlbumController.php b/src/Controller/AlbumController.php new file mode 100644 index 0000000..2aa0317 --- /dev/null +++ b/src/Controller/AlbumController.php @@ -0,0 +1,119 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..c91c083 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,62 @@ +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(), + ]); + } +} + + diff --git a/src/Controller/ReviewController.php b/src/Controller/ReviewController.php new file mode 100644 index 0000000..0794ccb --- /dev/null +++ b/src/Controller/ReviewController.php @@ -0,0 +1,86 @@ +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 +} + + diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..60098e3 --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,32 @@ +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 + } +} + + diff --git a/src/Entity/Review.php b/src/Entity/Review.php new file mode 100644 index 0000000..5935ca0 --- /dev/null +++ b/src/Entity/Review.php @@ -0,0 +1,88 @@ +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; } +} + + diff --git a/src/Entity/Setting.php b/src/Entity/Setting.php new file mode 100644 index 0000000..8d91b0e --- /dev/null +++ b/src/Entity/Setting.php @@ -0,0 +1,33 @@ +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; } +} + + diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..8bfc27d --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,116 @@ + + */ + #[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 $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; + } +} + + diff --git a/src/Form/ProfileFormType.php b/src/Form/ProfileFormType.php new file mode 100644 index 0000000..e87b473 --- /dev/null +++ b/src/Form/ProfileFormType.php @@ -0,0 +1,51 @@ +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, + ]); + } +} + + diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..7c41e1d --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,53 @@ +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, + ]); + } +} + + diff --git a/src/Form/ReviewType.php b/src/Form/ReviewType.php new file mode 100644 index 0000000..6c1f139 --- /dev/null +++ b/src/Form/ReviewType.php @@ -0,0 +1,46 @@ +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, + ]); + } +} + + diff --git a/src/Form/SiteSettingsType.php b/src/Form/SiteSettingsType.php new file mode 100644 index 0000000..605cf39 --- /dev/null +++ b/src/Form/SiteSettingsType.php @@ -0,0 +1,33 @@ +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([]); + } +} + + diff --git a/src/Repository/ReviewRepository.php b/src/Repository/ReviewRepository.php new file mode 100644 index 0000000..8341952 --- /dev/null +++ b/src/Repository/ReviewRepository.php @@ -0,0 +1,60 @@ + + */ +class ReviewRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Review::class); + } + + /** + * @return list + */ + 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 $albumIds + * @return array + */ + 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; + } +} + + diff --git a/src/Repository/SettingRepository.php b/src/Repository/SettingRepository.php new file mode 100644 index 0000000..9247aca --- /dev/null +++ b/src/Repository/SettingRepository.php @@ -0,0 +1,32 @@ +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(); + } +} + + diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..a91b986 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,29 @@ + + */ +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(); + } +} + + diff --git a/src/Security/ReviewVoter.php b/src/Security/ReviewVoter.php new file mode 100644 index 0000000..d199db1 --- /dev/null +++ b/src/Security/ReviewVoter.php @@ -0,0 +1,37 @@ +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(); + } +} + + diff --git a/src/Service/SpotifyClient.php b/src/Service/SpotifyClient.php new file mode 100644 index 0000000..673bb21 --- /dev/null +++ b/src/Service/SpotifyClient.php @@ -0,0 +1,220 @@ +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 + */ + 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|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 $albumIds + * @return array|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 $options + * @return array + */ + 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']; + }); + } +} + + diff --git a/templates/.DS_Store b/templates/.DS_Store new file mode 100644 index 0000000..4632d4e Binary files /dev/null and b/templates/.DS_Store differ diff --git a/templates/_partials/auth_modal.html.twig b/templates/_partials/auth_modal.html.twig new file mode 100644 index 0000000..6d65cd6 --- /dev/null +++ b/templates/_partials/auth_modal.html.twig @@ -0,0 +1,124 @@ + + + + + diff --git a/templates/_partials/navbar.html.twig b/templates/_partials/navbar.html.twig new file mode 100644 index 0000000..34948bb --- /dev/null +++ b/templates/_partials/navbar.html.twig @@ -0,0 +1,40 @@ + + + diff --git a/templates/account/dashboard.html.twig b/templates/account/dashboard.html.twig new file mode 100644 index 0000000..bcd9669 --- /dev/null +++ b/templates/account/dashboard.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} +{% block title %}Dashboard{% endblock %} +{% block body %} +

Your profile

+ {% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} + + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.email) }}{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}{{ form_errors(form.email) }}
+
{{ form_label(form.displayName) }}{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}{{ form_errors(form.displayName) }}
+
{{ form_label(form.currentPassword) }}{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}{{ form_errors(form.currentPassword) }}
+
{{ form_label(form.newPassword.first) }}{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.first) }}
+
{{ form_label(form.newPassword.second) }}{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}{{ form_errors(form.newPassword.second) }}
+ + {{ form_end(form) }} +{% endblock %} + + diff --git a/templates/account/settings.html.twig b/templates/account/settings.html.twig new file mode 100644 index 0000000..30f9459 --- /dev/null +++ b/templates/account/settings.html.twig @@ -0,0 +1,35 @@ +{% extends 'base.html.twig' %} +{% block title %}Settings{% endblock %} +{% block body %} +

Settings

+ +
+
+

Appearance

+
+ + +
+ Your choice is saved in a cookie. +
+
+ + +{% endblock %} + + diff --git a/templates/admin/settings.html.twig b/templates/admin/settings.html.twig new file mode 100644 index 0000000..8761527 --- /dev/null +++ b/templates/admin/settings.html.twig @@ -0,0 +1,26 @@ +{% extends 'base.html.twig' %} +{% block title %}Site Settings{% endblock %} +{% block body %} +

Site Settings

+ {% for msg in app.flashes('success') %}
{{ msg }}
{% endfor %} + +
+
+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
+ {{ form_label(form.SPOTIFY_CLIENT_ID) }} + {{ form_widget(form.SPOTIFY_CLIENT_ID, {attr: {class: 'form-control'}}) }} +
+
+ {{ form_label(form.SPOTIFY_CLIENT_SECRET) }} + {{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }} +
+
+ +
+ {{ form_end(form) }} +
+
+{% endblock %} + + diff --git a/templates/album/search.html.twig b/templates/album/search.html.twig new file mode 100644 index 0000000..e2b282d --- /dev/null +++ b/templates/album/search.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} +{% block title %}Album Search{% endblock %} +{% block body %} +

Search Albums

+
+
+ +
+
+ +
+ +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + {% if query is empty and (album is empty) and (artist is empty) and (year_from is empty) and (year_to is empty) %} +

Tip: Use the Advanced search to filter by album, artist, or year range.

+ {% endif %} + + {% if albums is defined and albums|length > 0 %} +
+ {% for album in albums %} +
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% if image %} + + {{ album.name }} cover + + {% endif %} +
+
{{ album.name }}
+

{{ album.artists|map(a => a.name)|join(', ') }}

+

Released {{ album.release_date }} • {{ album.total_tracks }} tracks

+ {% set s = stats[album.id] ?? { 'avg': 0, 'count': 0 } %} +

User score: {{ s.avg }}/10 ({{ s.count }})

+ +
+
+
+ {% endfor %} +
+ {% elseif query or album or artist or year_from or year_to %} +

No albums found.

+ {% endif %} +{% endblock %} + + diff --git a/templates/album/show.html.twig b/templates/album/show.html.twig new file mode 100644 index 0000000..2683162 --- /dev/null +++ b/templates/album/show.html.twig @@ -0,0 +1,68 @@ +{% extends 'base.html.twig' %} +{% block title %}{{ album.name }} — Reviews{% endblock %} +{% block body %} +
+
+
+ {% set image = (album.images[1] ?? album.images[0] ?? null) %} + {% if image %} + {{ album.name }} cover + {% endif %} +
+
{{ album.name }}
+
{{ album.artists|map(a => a.name)|join(', ') }}
+

Released {{ album.release_date }} • {{ album.total_tracks }} tracks

+

User score: {{ avg }}/10 ({{ count }})

+ Open in Spotify +
+
+
+
+
+

Reviews

+
+
+ {% for r in reviews %} +
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
by {{ r.author.displayName ?? r.author.userIdentifier }} • {{ r.createdAt|date('Y-m-d H:i') }}
+

{{ r.content|u.truncate(300, '…', false) }}

+ Read more +
+
+ {% else %} +

No reviews yet for this album.

+ {% endfor %} +
+ + {% if app.user %} +
+
+

Leave a review

+ {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 6}}) }}{{ form_errors(form.content) }}
+
+ {{ form_label(form.rating) }} +
+ {{ 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;'}}) }} + {{ form.rating.vars.value ?? 5 }} +
+ + {% for i in 1..10 %}{% endfor %} + + {{ form_errors(form.rating) }} +
+ + {{ form_end(form) }} +
+
+ {% else %} +
Sign in to leave a review.
+ {% endif %} +
+
+{% endblock %} + + diff --git a/templates/base.html.twig b/templates/base.html.twig new file mode 100644 index 0000000..6d86eef --- /dev/null +++ b/templates/base.html.twig @@ -0,0 +1,20 @@ + + + + + + {% block title %}Music Ratings{% endblock %} + + + + {% include '_partials/navbar.html.twig' %} +
+ {% block body %}{% endblock %} +
+ + + {% include '_partials/auth_modal.html.twig' %} + + + + diff --git a/templates/review/edit.html.twig b/templates/review/edit.html.twig new file mode 100644 index 0000000..ef74d9f --- /dev/null +++ b/templates/review/edit.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} +{% block title %}Edit Review{% endblock %} +{% block body %} +

Edit review

+

{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})

+ + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+
{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+ + {{ form_end(form) }} + +

Back to review

+{% endblock %} + + diff --git a/templates/review/index.html.twig b/templates/review/index.html.twig new file mode 100644 index 0000000..e65efb9 --- /dev/null +++ b/templates/review/index.html.twig @@ -0,0 +1,29 @@ +{% extends 'base.html.twig' %} +{% block title %}Album Reviews{% endblock %} +{% block body %} +
+

Album reviews

+ {% if app.user %} + New review + {% endif %} +
+ +
+ {% for r in reviews %} +
+
+
+
{{ r.title }} (Rating {{ r.rating }}/10)
+
{{ r.albumName }} — {{ r.albumArtist }}
+

{{ r.content|u.truncate(220, '…', false) }}

+ Read more +
+
+
+ {% else %} +

No reviews yet.

+ {% endfor %} +
+{% endblock %} + + diff --git a/templates/review/new.html.twig b/templates/review/new.html.twig new file mode 100644 index 0000000..7c2aaee --- /dev/null +++ b/templates/review/new.html.twig @@ -0,0 +1,30 @@ +{% extends 'base.html.twig' %} +{% block title %}New Review{% endblock %} +{% block body %} +

Write a review

+ +
+
+ + +
+
+ + +
+
+
+ + +
+ + {{ form_start(form, {attr: {class: 'vstack gap-3', novalidate: 'novalidate'}}) }} +
{{ form_label(form.title) }}{{ form_widget(form.title, {attr: {class: 'form-control'}}) }}{{ form_errors(form.title) }}
+
{{ form_label(form.content) }}{{ form_widget(form.content, {attr: {class: 'form-control', rows: 8}}) }}{{ form_errors(form.content) }}
+
{{ form_label(form.rating) }}{{ form_widget(form.rating, {attr: {class: 'form-control'}}) }}{{ form_errors(form.rating) }}
+ + {{ form_end(form) }} + +{% endblock %} + + diff --git a/templates/review/show.html.twig b/templates/review/show.html.twig new file mode 100644 index 0000000..2955f8b --- /dev/null +++ b/templates/review/show.html.twig @@ -0,0 +1,22 @@ +{% extends 'base.html.twig' %} +{% block title %}{{ review.title }}{% endblock %} +{% block body %} +

← Back

+

{{ review.title }} (Rating {{ review.rating }}/10)

+

{{ review.albumName }} — {{ review.albumArtist }} ({{ review.spotifyAlbumId }})

+
+

{{ review.content|nl2br }}

+
+ + {% if is_granted('REVIEW_EDIT', review) %} +
+ Edit +
+ + +
+
+ {% endif %} +{% endblock %} + + diff --git a/var/.DS_Store b/var/.DS_Store new file mode 100644 index 0000000..b06c0d5 Binary files /dev/null and b/var/.DS_Store differ