I lowkey forgot to commit
This commit is contained in:
76
README.md
76
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
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
"symfony/web-link": "7.3.*",
|
"symfony/web-link": "7.3.*",
|
||||||
"symfony/yaml": "7.3.*",
|
"symfony/yaml": "7.3.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
|
"twig/string-extra": "^3.22",
|
||||||
"twig/twig": "^2.12|^3.0"
|
"twig/twig": "^2.12|^3.0"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
|
|||||||
BIN
config/.DS_Store
vendored
Normal file
BIN
config/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -4,14 +4,37 @@ security:
|
|||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
||||||
providers:
|
providers:
|
||||||
users_in_memory: { memory: null }
|
app_user_provider:
|
||||||
|
entity:
|
||||||
|
class: App\Entity\User
|
||||||
|
property: email
|
||||||
firewalls:
|
firewalls:
|
||||||
dev:
|
dev:
|
||||||
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
pattern: ^/(_(profiler|wdt)|css|images|js)/
|
||||||
security: false
|
security: false
|
||||||
main:
|
main:
|
||||||
lazy: true
|
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
|
# activate different ways to authenticate
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
# https://symfony.com/doc/current/security.html#the-firewall
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ services:
|
|||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
|
|
||||||
|
App\Service\SpotifyClient:
|
||||||
|
arguments:
|
||||||
|
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
|
||||||
|
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ services:
|
|||||||
container_name: app-php
|
container_name: app-php
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# App base URL (used by some tools)
|
# Symfony Messenger (dev-safe default so CLI commands don't fail)
|
||||||
DEFAULT_URI: ${DEFAULT_URI:-http://localhost:8000}
|
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://}
|
||||||
APP_ENV: ${APP_ENV:-dev}
|
|
||||||
APP_SECRET: ${APP_SECRET:-change_me}
|
|
||||||
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
|
# 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}
|
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
|
||||||
volumes:
|
volumes:
|
||||||
@@ -27,8 +25,11 @@ services:
|
|||||||
- ./config:/var/www/html/config
|
- ./config:/var/www/html/config
|
||||||
- ./migrations:/var/www/html/migrations
|
- ./migrations:/var/www/html/migrations
|
||||||
- ./public:/var/www/html/public
|
- ./public:/var/www/html/public
|
||||||
|
- ./templates:/var/www/html/templates
|
||||||
- ./src:/var/www/html/src
|
- ./src:/var/www/html/src
|
||||||
- ./var:/var/www/html/var
|
- ./var:/var/www/html/var
|
||||||
|
- ./.env:/var/www/html/.env:ro
|
||||||
|
- ./vendor:/var/www/html/vendor
|
||||||
# Keep composer manifests on host for version control
|
# Keep composer manifests on host for version control
|
||||||
- ./composer.json:/var/www/html/composer.json
|
- ./composer.json:/var/www/html/composer.json
|
||||||
- ./composer.lock:/var/www/html/composer.lock
|
- ./composer.lock:/var/www/html/composer.lock
|
||||||
|
|||||||
BIN
docker/.DS_Store
vendored
Normal file
BIN
docker/.DS_Store
vendored
Normal file
Binary file not shown.
32
docs/01-setup.md
Normal file
32
docs/01-setup.md
Normal 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
14
docs/02-features.md
Normal 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 (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
|
||||||
|
|
||||||
|
|
||||||
19
docs/03-auth-and-users.md
Normal file
19
docs/03-auth-and-users.md
Normal 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).
|
||||||
|
|
||||||
|
|
||||||
19
docs/04-spotify-integration.md
Normal file
19
docs/04-spotify-integration.md
Normal 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
|
||||||
|
|
||||||
|
|
||||||
16
docs/05-reviews-and-albums.md
Normal file
16
docs/05-reviews-and-albums.md
Normal 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 (1–10) with ticks; badge shows current value.
|
||||||
|
|
||||||
|
|
||||||
17
docs/06-admin-and-settings.md
Normal file
17
docs/06-admin-and-settings.md
Normal 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`.
|
||||||
|
|
||||||
|
|
||||||
23
docs/07-rate-limits-and-caching.md
Normal file
23
docs/07-rate-limits-and-caching.md
Normal 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.
|
||||||
|
|
||||||
|
|
||||||
19
docs/08-troubleshooting.md
Normal file
19
docs/08-troubleshooting.md
Normal 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.
|
||||||
|
|
||||||
|
|
||||||
56
migrations/Version20251031224841.php
Normal file
56
migrations/Version20251031224841.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20251031231033.php
Normal file
31
migrations/Version20251031231033.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20251031231715.php
Normal file
31
migrations/Version20251031231715.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20251101001514.php
Normal file
33
migrations/Version20251101001514.php
Normal 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
BIN
src/.DS_Store
vendored
Normal file
Binary file not shown.
47
src/Command/PromoteAdminCommand.php
Normal file
47
src/Command/PromoteAdminCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
56
src/Controller/AccountController.php
Normal file
56
src/Controller/AccountController.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
37
src/Controller/Admin/SiteSettingsController.php
Normal file
37
src/Controller/Admin/SiteSettingsController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
119
src/Controller/AlbumController.php
Normal file
119
src/Controller/AlbumController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
62
src/Controller/RegistrationController.php
Normal file
62
src/Controller/RegistrationController.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
86
src/Controller/ReviewController.php
Normal file
86
src/Controller/ReviewController.php
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
32
src/Controller/SecurityController.php
Normal file
32
src/Controller/SecurityController.php
Normal 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
88
src/Entity/Review.php
Normal 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
33
src/Entity/Setting.php
Normal 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
116
src/Entity/User.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
51
src/Form/ProfileFormType.php
Normal file
51
src/Form/ProfileFormType.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
53
src/Form/RegistrationFormType.php
Normal file
53
src/Form/RegistrationFormType.php
Normal 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
46
src/Form/ReviewType.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
33
src/Form/SiteSettingsType.php
Normal file
33
src/Form/SiteSettingsType.php
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
60
src/Repository/ReviewRepository.php
Normal file
60
src/Repository/ReviewRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
32
src/Repository/SettingRepository.php
Normal file
32
src/Repository/SettingRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
29
src/Repository/UserRepository.php
Normal file
29
src/Repository/UserRepository.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
37
src/Security/ReviewVoter.php
Normal file
37
src/Security/ReviewVoter.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
220
src/Service/SpotifyClient.php
Normal file
220
src/Service/SpotifyClient.php
Normal 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
BIN
templates/.DS_Store
vendored
Normal file
Binary file not shown.
124
templates/_partials/auth_modal.html.twig
Normal file
124
templates/_partials/auth_modal.html.twig
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
40
templates/_partials/navbar.html.twig
Normal file
40
templates/_partials/navbar.html.twig
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
17
templates/account/dashboard.html.twig
Normal file
17
templates/account/dashboard.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
35
templates/account/settings.html.twig
Normal file
35
templates/account/settings.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
26
templates/admin/settings.html.twig
Normal file
26
templates/admin/settings.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
68
templates/album/search.html.twig
Normal file
68
templates/album/search.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
68
templates/album/show.html.twig
Normal file
68
templates/album/show.html.twig
Normal 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
20
templates/base.html.twig
Normal 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>
|
||||||
|
|
||||||
|
|
||||||
17
templates/review/edit.html.twig
Normal file
17
templates/review/edit.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
29
templates/review/index.html.twig
Normal file
29
templates/review/index.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
30
templates/review/new.html.twig
Normal file
30
templates/review/new.html.twig
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
22
templates/review/show.html.twig
Normal file
22
templates/review/show.html.twig
Normal 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
BIN
var/.DS_Store
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user