wtf
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 2m0s

This commit is contained in:
2025-11-28 02:00:11 +00:00
parent 1c98a634c3
commit dae8f3d999
35 changed files with 1510 additions and 82 deletions

View File

@@ -31,6 +31,14 @@ docker compose exec php php bin/console app:promote-admin you@example.com
5) Visit `http://localhost:8000` to search for albums. 5) Visit `http://localhost:8000` to search for albums.
6) (Optional) Seed demo data
```bash
docker compose exec php php bin/console app:seed-demo-users --count=50
docker compose exec php php bin/console app:seed-demo-albums --count=40 --attach-users
docker compose exec php php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
```
## Database driver ## Database driver
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`. - Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.

View File

@@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Config\DoctrineConfig; use Symfony\Config\DoctrineConfig;
return static function (DoctrineConfig $doctrine): void { return static function (DoctrineConfig $doctrine): void {
@@ -32,6 +33,19 @@ return static function (DoctrineConfig $doctrine): void {
if ($hasCustomPath) { if ($hasCustomPath) {
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%'); $connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
} else { } else {
$projectDir = dirname(__DIR__, 2);
$databasePath = sprintf('%s/var/data/database.sqlite', $projectDir);
$databaseDir = dirname($databasePath);
$filesystem = new Filesystem();
if (!$filesystem->exists($databaseDir)) {
$filesystem->mkdir($databaseDir, 0o775);
}
if (!$filesystem->exists($databasePath)) {
$filesystem->touch($databasePath);
}
$connection->path('%kernel.project_dir%/var/data/database.sqlite'); $connection->path('%kernel.project_dir%/var/data/database.sqlite');
} }
} else { } else {
@@ -39,5 +53,4 @@ return static function (DoctrineConfig $doctrine): void {
$connection->serverVersion('16'); $connection->serverVersion('16');
$connection->charset('utf8'); $connection->charset('utf8');
} }
}; };

View File

@@ -14,9 +14,9 @@ services:
- APP_ENV=dev - APP_ENV=dev
container_name: php container_name: php
restart: unless-stopped restart: unless-stopped
environment: #environment:
# 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:
# Mount only source and config; vendors are installed in-container # Mount only source and config; vendors are installed in-container
- ./bin:/var/www/html/bin - ./bin:/var/www/html/bin

View File

@@ -16,6 +16,10 @@ docker compose exec php php bin/console doctrine:migrations:diff --no-interactio
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
``` ```
### Switching database drivers
- `DATABASE_DRIVER=postgres` (default) continues to use the Postgres 16 service from `docker-compose.yml` and reads credentials from `DATABASE_URL`.
- `DATABASE_DRIVER=sqlite` runs Doctrine against a local SQLite file at `var/data/database.sqlite`. `DATABASE_URL` is ignored; override the SQLite file path with `DATABASE_SQLITE_PATH` if desired.
## Admin user ## Admin user
```bash ```bash
docker compose exec php php bin/console app:promote-admin you@example.com docker compose exec php php bin/console app:promote-admin you@example.com

View File

@@ -10,6 +10,11 @@
- `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings. - `ROLE_MODERATOR`: promoted via console `app:promote-moderator`, or via webUI; can manage users and all reviews/albums but not site settings.
- `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access. - `ROLE_ADMIN`: promoted via console `app:promote-admin`; includes moderator abilities plus site settings access.
### Demo accounts
- Generate placeholder accounts locally with `php bin/console app:seed-demo-users --count=50` (default password: `password`).
- Emails use the pattern `demo+<token>@example.com`, making them easy to spot in the admin UI.
- Give existing accounts avatars with `php bin/console app:seed-user-avatars`; pass `--overwrite` to refresh everyone or tweak `--style` to try other DiceBear sets.
### Access flow ### Access flow
- Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically. - Visiting `/admin/dashboard`, `/admin/users`, or `/admin/settings` while unauthenticated forces a redirect through `/login`, which re-opens the modal automatically.
- Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy. - Moderators inherit all `ROLE_USER` permissions; admins inherit both moderator and user permissions via the role hierarchy.

View File

@@ -8,7 +8,9 @@
- `src/Service/SpotifyClient.php` - `src/Service/SpotifyClient.php`
- Client Credentials token fetch (cached) - Client Credentials token fetch (cached)
- `searchAlbums(q, limit)` - `searchAlbums(q, limit)`
- `getAlbum(id)` and `getAlbums([ids])` - `getAlbum(id)` / `getAlbums([ids])`
- `getAlbumWithTracks(id)` fetches metadata plus a hydrated tracklist
- `getAlbumTracks(id)` provides the raw paginated track payload when needed
## Advanced search ## Advanced search
- The search page builds Spotify fielded queries: - The search page builds Spotify fielded queries:

View File

@@ -2,6 +2,7 @@
## Album page ## Album page
- Shows album artwork, metadata, average rating and review count. - Shows album artwork, metadata, average rating and review count.
- Displays the full Spotify tracklist (duration, ordering, preview links) when available.
- Lists reviews newest-first. - Lists reviews newest-first.
- Logged-in users can submit a review inline. - Logged-in users can submit a review inline.
@@ -13,4 +14,9 @@
## UI ## UI
- Rating uses a slider (110) with ticks; badge shows current value. - Rating uses a slider (110) with ticks; badge shows current value.
## Demo data
- Quickly create placeholder catalog entries with `php bin/console app:seed-demo-albums --count=40`. Add `--attach-users` to assign random existing users as album owners so the admin dashboard shows activity immediately.
- Populate sample reviews with `php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8` so album stats and the admin dashboard have activity.
- Use `--only-empty` when you want to focus on albums that currently have no reviews.

View File

@@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
// SQLite uses the dedicated schema bootstrap migration later in the chain.
return;
}
// Skip if the tables somehow already exist to keep reruns idempotent.
if ($schema->hasTable('reviews') || $schema->hasTable('users') || $schema->hasTable('messenger_messages')) {
return;
}
$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 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('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.created_at IS \'(DC2Type:datetime_immutable)\'');
@@ -46,11 +55,17 @@ final class Version20251031224841 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B'); $this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
$this->addSql('DROP TABLE reviews'); $this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE users'); $this->addSql('DROP TABLE users');
$this->addSql('DROP TABLE messenger_messages'); $this->addSql('DROP TABLE messenger_messages');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
// SQLite bootstraps settings in Version20251127235840.
return;
}
if ($schema->hasTable('settings')) {
return;
}
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))'); $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)'); $this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('DROP TABLE settings'); $this->addSql('DROP TABLE settings');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,6 +19,10 @@ final class Version20251114111853 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
if ($this->isSqlite()) {
// SQLite installs get albums from Version20251127235840.
return;
}
// Idempotent guard: if table already exists (from previous migration), skip // Idempotent guard: if table already exists (from previous migration), skip
if ($schema->hasTable('albums')) { if ($schema->hasTable('albums')) {
return; return;
@@ -31,9 +35,17 @@ final class Version20251114111853 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
if ($this->isSqlite()) {
return;
}
// Be defensive: only drop the table if it exists // Be defensive: only drop the table if it exists
if ($schema->hasTable('albums')) { if ($schema->hasTable('albums')) {
$this->addSql('DROP TABLE albums'); $this->addSql('DROP TABLE albums');
} }
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,7 +19,9 @@ final class Version20251114112016 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); $this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); $this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\''); $this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
@@ -29,7 +31,9 @@ final class Version20251114112016 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); $this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE'); $this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');

View File

@@ -16,7 +16,24 @@ final class Version20251114113000 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// Add nullable album_id first if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if ($reviews->hasColumn('album_id')) {
// Already migrated (common for SQLite dev DBs)
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
// SQLite cannot add FK constraints after table creation; add the column + index and rely on app-level validation.
$this->addSql('ALTER TABLE reviews ADD COLUMN album_id INTEGER DEFAULT NULL');
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_6970EF78E0C31AF9 ON reviews (album_id)');
$this->addSql('UPDATE reviews SET album_id = (SELECT a.id FROM albums a WHERE a.spotify_id = reviews.spotify_album_id) WHERE album_id IS NULL');
return;
}
// Add nullable album_id first (PostgreSQL / others that support full DDL)
$this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL'); $this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE'); $this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)'); $this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
@@ -53,6 +70,20 @@ SQL);
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
if (!$schema->hasTable('reviews')) {
return;
}
$reviews = $schema->getTable('reviews');
if (!$reviews->hasColumn('album_id')) {
return;
}
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
return;
}
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9'); $this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9'); $this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id'); $this->addSql('ALTER TABLE reviews DROP COLUMN album_id');

View File

@@ -16,6 +16,10 @@ final class Version20251114114000 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
if ($this->isSqlite()) {
// SQLite schema never created the legacy columns.
return;
}
// Guard: drop columns only if they exist // Guard: drop columns only if they exist
$this->addSql(<<<'SQL' $this->addSql(<<<'SQL'
DO $$ DO $$
@@ -35,11 +39,19 @@ SQL);
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
if ($this->isSqlite()) {
return;
}
// Recreate columns as nullable in down migration // Recreate columns as nullable in down migration
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL'); $this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -16,6 +16,10 @@ final class Version20251114120500 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
if ($this->isSqlite()) {
// SQLite schema already ships with these columns/defaults.
return;
}
$this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL"); $this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL");
$this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'"); $this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL"); $this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
@@ -28,6 +32,9 @@ final class Version20251114120500 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
if ($this->isSqlite()) {
return;
}
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386"); $this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
$this->addSql("DROP INDEX IF EXISTS uniq_album_local_id"); $this->addSql("DROP INDEX IF EXISTS uniq_album_local_id");
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386"); $this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
@@ -36,6 +43,11 @@ final class Version20251114120500 extends AbstractMigration
$this->addSql("ALTER TABLE albums DROP COLUMN created_by_id"); $this->addSql("ALTER TABLE albums DROP COLUMN created_by_id");
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL"); $this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,16 +19,24 @@ final class Version20251120174722 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL'); $this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL');
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF'); $this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL'); $this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9'); $this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -25,7 +25,14 @@ final class Version20251120175034 extends AbstractMigration
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('CREATE SCHEMA public'); $this->addSql('CREATE SCHEMA public');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -19,16 +19,24 @@ final class Version20251127191813 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
// this up() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
return;
}
$this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT'); $this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT');
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101'); $this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
// this down() migration is auto-generated, please modify it to your needs if ($this->isSqlite()) {
$this->addSql('CREATE SCHEMA public'); return;
}
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\''); $this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id'); $this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
} }
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
} }

View File

@@ -0,0 +1,63 @@
<?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 Version20251127235840 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
if ($schema->hasTable('users')) {
// Already initialized.
return;
}
$this->addSql('CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER DEFAULT NULL, spotify_id VARCHAR(64) DEFAULT NULL, local_id VARCHAR(64) DEFAULT NULL, source VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, artists CLOB NOT NULL --(DC2Type:json)
, release_date VARCHAR(20) DEFAULT NULL, total_tracks INTEGER NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, cover_image_path VARCHAR(255) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
$this->addSql('CREATE TABLE reviews (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, album_id INTEGER NOT NULL, title VARCHAR(160) NOT NULL, content CLOB NOT NULL, rating SMALLINT NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
, CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6970EB0F1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
$this->addSql('CREATE INDEX IDX_6970EB0F1137ABCF ON reviews (album_id)');
$this->addSql('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value CLOB DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
$this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, profile_image_path VARCHAR(255) DEFAULT NULL)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
}
public function down(Schema $schema): void
{
if (!$this->isSqlite()) {
return;
}
$this->addSql('DROP TABLE albums');
$this->addSql('DROP TABLE reviews');
$this->addSql('DROP TABLE settings');
$this->addSql('DROP TABLE users');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -16,14 +16,39 @@ final class Version20251205123000 extends AbstractMigration
public function up(Schema $schema): void public function up(Schema $schema): void
{ {
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL'); if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) {
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL'); $this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
}
if ($this->shouldAddColumn($schema, 'albums', 'cover_image_path')) {
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
}
} }
public function down(Schema $schema): void public function down(Schema $schema): void
{ {
$this->addSql('ALTER TABLE users DROP profile_image_path'); if ($this->isSqlite()) {
$this->addSql('ALTER TABLE albums DROP cover_image_path'); // SQLite cannot drop columns; leave them in place.
return;
}
if ($schema->hasTable('users') && $schema->getTable('users')->hasColumn('profile_image_path')) {
$this->addSql('ALTER TABLE users DROP profile_image_path');
}
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('cover_image_path')) {
$this->addSql('ALTER TABLE albums DROP cover_image_path');
}
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
{
if (!$schema->hasTable($tableName)) {
return false;
}
return !$schema->getTable($tableName)->hasColumn($column);
} }
} }

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251205133000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create album_tracks table to persist Spotify tracklists';
}
public function up(Schema $schema): void
{
if ($this->isSqlite()) {
if ($schema->hasTable('album_tracks')) {
return;
}
$this->addSql('CREATE TABLE album_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, album_id INTEGER NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INTEGER NOT NULL, track_number INTEGER NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INTEGER NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
return;
}
$this->addSql('CREATE TABLE album_tracks (id SERIAL NOT NULL, album_id INT NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INT NOT NULL, track_number INT NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INT NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
$this->addSql('ALTER TABLE album_tracks ADD CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
if ($this->isSqlite()) {
$this->addSql('DROP TABLE IF EXISTS album_tracks');
return;
}
$this->addSql('ALTER TABLE album_tracks DROP CONSTRAINT FK_5E4A3B3B1137ABCF');
$this->addSql('DROP TABLE album_tracks');
}
private function isSqlite(): bool
{
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
}
}

View File

@@ -185,3 +185,119 @@ a:hover {
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
} }
/* Material-inspired data tables */
.mui-table-wrapper {
border: 1px solid var(--md-card-border);
border-radius: 20px;
background-color: var(--md-card);
box-shadow: var(--md-shadow-ambient);
overflow-x: auto;
}
.mui-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
min-width: 620px;
}
.mui-table thead th {
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
color: var(--md-text-secondary);
padding: 0.85rem 1.25rem;
background-color: var(--md-surface-variant);
border-bottom: 1px solid var(--md-outline);
}
.mui-table tbody td {
padding: 1rem 1.25rem;
border-bottom: 1px solid color-mix(in srgb, var(--md-outline) 75%, transparent);
color: var(--md-text-primary);
}
.mui-table--compact tbody td {
padding: 0.65rem 1rem;
}
.mui-table tbody tr:last-child td {
border-bottom: none;
}
.mui-table tbody tr {
transition: background-color 0.15s ease, transform 0.15s ease;
}
.mui-table tbody tr:hover {
background-color: color-mix(in srgb, var(--accent-color) 6%, transparent);
}
.mui-table--striped tbody tr:nth-child(even) {
background-color: color-mix(in srgb, var(--accent-color) 4%, transparent);
}
.mui-table--striped tbody tr:nth-child(even):hover {
background-color: color-mix(in srgb, var(--accent-color) 9%, transparent);
}
.mui-table__number {
font-variant-numeric: tabular-nums;
color: var(--md-text-secondary);
width: 72px;
}
.mui-table__metric {
font-variant-numeric: tabular-nums;
text-align: center;
color: var(--md-text-secondary);
}
.mui-table__title {
font-weight: 600;
}
.mui-table__title-avatar {
display: flex;
align-items: center;
gap: 0.85rem;
}
.mui-table__title-avatar img {
width: 40px;
height: 40px;
border-radius: 12px;
object-fit: cover;
border: 1px solid var(--md-card-border);
}
.mui-table__subtitle {
font-size: 0.85rem;
color: var(--md-text-secondary);
}
.mui-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid var(--md-outline);
color: var(--accent-color);
background-color: transparent;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
}
.mui-icon-button:hover {
background-color: color-mix(in srgb, var(--accent-color) 12%, transparent);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.mui-icon-button svg,
.mui-icon-button span {
font-size: 1rem;
line-height: 1;
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\User;
use App\Repository\AlbumRepository;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-demo-albums',
description: 'Create demo albums with randomized metadata for local development.'
)]
class SeedDemoAlbumsCommand extends Command
{
private const GENRES = [
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
];
private const ADJECTIVES = [
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
];
private const NOUNS = [
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = max(1, (int) $input->getOption('count'));
$attachUsers = (bool) $input->getOption('attach-users');
$users = $attachUsers ? $this->userRepository->findAll() : [];
$created = 0;
$seenLocalIds = [];
while ($created < $count) {
$localId = $this->generateLocalId();
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
continue;
}
$album = new Album();
$album->setSource('user');
$album->setLocalId($localId);
$album->setName($this->generateAlbumName());
$album->setArtists($this->generateArtists());
$album->setReleaseDate($this->generateReleaseDate());
$album->setTotalTracks(random_int(6, 16));
$album->setCoverUrl($this->generateCoverUrl($localId));
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
if ($attachUsers && $users !== []) {
/** @var User $user */
$user = $users[array_rand($users)];
$album->setCreatedBy($user);
}
$this->entityManager->persist($album);
$seenLocalIds[$localId] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
return Command::SUCCESS;
}
private function generateLocalId(): string
{
return 'demo_' . bin2hex(random_bytes(4));
}
private function generateAlbumName(): string
{
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
$genre = self::GENRES[random_int(0, count(self::GENRES) - 1)];
return sprintf('%s %s of %s', $adj, $noun, $genre);
}
/**
* @return list<string>
*/
private function generateArtists(): array
{
$artists = [];
$artistCount = random_int(1, 3);
for ($i = 0; $i < $artistCount; $i++) {
$artists[] = sprintf(
'%s %s',
self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)],
self::NOUNS[random_int(0, count(self::NOUNS) - 1)]
);
}
return array_values(array_unique($artists));
}
private function generateReleaseDate(): string
{
$year = random_int(1990, (int) date('Y'));
$month = random_int(1, 12);
$day = random_int(1, 28);
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
private function generateCoverUrl(string $seed): string
{
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Album;
use App\Entity\Review;
use App\Entity\User;
use App\Repository\AlbumRepository;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-demo-reviews',
description: 'Generate demo reviews across existing albums.'
)]
class SeedDemoReviewsCommand extends Command
{
private const SUBJECTS = [
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
];
private const VERBS = [
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
];
private const QUALIFIERS = [
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
];
public function __construct(
private readonly AlbumRepository $albumRepository,
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10)
->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1)
->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60)
->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$albums = $this->albumRepository->findAll();
$users = $this->userRepository->findAll();
if ($albums === [] || $users === []) {
$io->warning('Need at least one album and one user to seed reviews.');
return Command::FAILURE;
}
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
$onlyEmpty = (bool) $input->getOption('only-empty');
$created = 0;
$processedAlbums = 0;
foreach ($selectedAlbums as $album) {
if ($onlyEmpty && $this->albumHasReviews($album)) {
continue;
}
$targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum));
$created += $this->seedForAlbum($album, $users, $targetReviews);
$processedAlbums++;
}
$this->entityManager->flush();
if ($created === 0) {
$io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.');
return Command::SUCCESS;
}
$io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1)));
return Command::SUCCESS;
}
/**
* @param list<Album> $albums
* @return list<Album>
*/
private function selectAlbums(array $albums, int $coverPercent): array
{
if ($coverPercent >= 100) {
return $albums;
}
$selected = [];
foreach ($albums as $album) {
if (random_int(1, 100) <= $coverPercent) {
$selected[] = $album;
}
}
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
}
/**
* @param list<User> $users
*/
private function seedForAlbum(Album $album, array $users, int $targetReviews): int
{
$created = 0;
$existingAuthors = $this->fetchExistingAuthors($album);
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
if ($availableUsers === []) {
return 0;
}
$targetReviews = min($targetReviews, count($availableUsers));
shuffle($availableUsers);
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
foreach ($selectedUsers as $user) {
$review = new Review();
$review->setAlbum($album);
$review->setAuthor($user);
$review->setRating(random_int(4, 10));
$review->setTitle($this->generateTitle());
$review->setContent($this->generateContent($album));
$this->entityManager->persist($review);
$created++;
}
return $created;
}
/**
* @return array<int,bool>
*/
private function fetchExistingAuthors(Album $album): array
{
$qb = $this->entityManager->createQueryBuilder()
->select('IDENTITY(r.author) AS authorId')
->from(Review::class, 'r')
->where('r.album = :album')
->setParameter('album', $album);
$rows = $qb->getQuery()->getScalarResult();
$out = [];
foreach ($rows as $row) {
$out[(int) $row['authorId']] = true;
}
return $out;
}
private function albumHasReviews(Album $album): bool
{
$count = (int) $this->entityManager->createQueryBuilder()
->select('COUNT(r.id)')
->from(Review::class, 'r')
->where('r.album = :album')
->setParameter('album', $album)
->getQuery()
->getSingleScalarResult();
return $count > 0;
}
private function generateTitle(): string
{
$subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)];
$verb = self::VERBS[random_int(0, count(self::VERBS) - 1)];
return sprintf('%s %s the vibe', $subject, $verb);
}
private function generateContent(Album $album): string
{
$qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)];
return sprintf(
'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.',
$album->getName(),
$qualifier,
self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)],
self::VERBS[random_int(0, count(self::VERBS) - 1)],
self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
#[AsCommand(
name: 'app:seed-demo-users',
description: 'Create demo users with random emails and display names.'
)]
class SeedDemoUsersCommand extends Command
{
private const FIRST_NAMES = [
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
];
private const LAST_NAMES = [
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
];
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly UserRepository $userRepository,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50)
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$count = (int) $input->getOption('count');
$count = $count > 0 ? $count : 50;
$plainPassword = (string) $input->getOption('password');
$created = 0;
$seenEmails = [];
while ($created < $count) {
$email = $this->generateEmail();
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
continue;
}
$user = new User();
$user->setEmail($email);
$user->setDisplayName($this->generateDisplayName());
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
$user->setRoles(['ROLE_USER']);
$this->entityManager->persist($user);
$seenEmails[$email] = true;
$created++;
}
$this->entityManager->flush();
$io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword));
return Command::SUCCESS;
}
private function generateEmail(): string
{
$token = bin2hex(random_bytes(4));
return sprintf('demo+%s@example.com', $token);
}
private function generateDisplayName(): string
{
$first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)];
$last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)];
return sprintf('%s %s', $first, $last);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\User;
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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:seed-user-avatars',
description: 'Assign generated profile images to existing users.'
)]
class SeedUserAvatarsCommand extends Command
{
public function __construct(
private readonly UserRepository $userRepository,
private readonly EntityManagerInterface $entityManager,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set')
->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs')
->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$overwrite = (bool) $input->getOption('overwrite');
$style = (string) $input->getOption('style');
$seedPrefix = (string) $input->getOption('seed-prefix');
$users = $this->userRepository->findAll();
if ($users === []) {
$io->warning('No users found.');
return Command::FAILURE;
}
$updated = 0;
foreach ($users as $user) {
if (!$user instanceof User) {
continue;
}
if (!$overwrite && $user->getProfileImagePath()) {
continue;
}
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
$updated++;
}
if ($updated === 0) {
$io->info('No avatars needed updating.');
return Command::SUCCESS;
}
$this->entityManager->flush();
$io->success(sprintf('Assigned avatars to %d user(s).', $updated));
return Command::SUCCESS;
}
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
{
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
}
}

View File

@@ -9,6 +9,7 @@ use App\Entity\User;
use App\Form\ReviewType; use App\Form\ReviewType;
use App\Form\AlbumType; use App\Form\AlbumType;
use App\Repository\AlbumRepository; use App\Repository\AlbumRepository;
use App\Repository\AlbumTrackRepository;
use App\Repository\ReviewRepository; use App\Repository\ReviewRepository;
use App\Service\AlbumSearchService; use App\Service\AlbumSearchService;
use App\Service\ImageStorage; use App\Service\ImageStorage;
@@ -89,20 +90,25 @@ class AlbumController extends AbstractController
* Renders a detailed album view plus inline review form. * Renders a detailed album view plus inline review form.
*/ */
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])] #[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em): Response public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
{ {
$albumEntity = $this->findAlbum($id, $albumRepo); $albumEntity = $this->findAlbum($id, $albumRepo);
$isSaved = $albumEntity !== null; $isSaved = $albumEntity !== null;
if (!$albumEntity) { if (!$albumEntity) {
$spotifyAlbum = $spotify->getAlbum($id); $spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) { if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found'); throw $this->createNotFoundException('Album not found');
} }
$albumEntity = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); $albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
$em->flush(); $em->flush();
} else {
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
$em->flush();
}
} }
$albumCard = $albumEntity->toTemplateArray(); $albumCard = $albumEntity->toTemplateArray();
$canManage = $this->canManageAlbum($albumEntity); $canManage = $this->canManageAlbum($albumEntity);
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']); $existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
$count = count($existing); $count = count($existing);
@@ -137,6 +143,9 @@ class AlbumController extends AbstractController
'avg' => $avg, 'avg' => $avg,
'count' => $count, 'count' => $count,
'form' => $form->createView(), 'form' => $form->createView(),
'albumOwner' => $albumEntity->getCreatedBy(),
'albumCreatedAt' => $albumEntity->getCreatedAt(),
'tracks' => $trackRows,
]); ]);
} }
@@ -145,7 +154,7 @@ class AlbumController extends AbstractController
*/ */
#[IsGranted('ROLE_USER')] #[IsGranted('ROLE_USER')]
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])] #[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, EntityManagerInterface $em): Response public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
{ {
$token = (string) $request->request->get('_token'); $token = (string) $request->request->get('_token');
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) { if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
@@ -153,11 +162,11 @@ class AlbumController extends AbstractController
} }
$existing = $albumRepo->findOneBySpotifyId($id); $existing = $albumRepo->findOneBySpotifyId($id);
if (!$existing) { if (!$existing) {
$spotifyAlbum = $spotify->getAlbum($id); $spotifyAlbum = $spotify->getAlbumWithTracks($id);
if ($spotifyAlbum === null) { if ($spotifyAlbum === null) {
throw $this->createNotFoundException('Album not found'); throw $this->createNotFoundException('Album not found');
} }
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum); $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
$em->flush(); $em->flush();
$this->addFlash('success', 'Album saved.'); $this->addFlash('success', 'Album saved.');
} else { } else {
@@ -267,9 +276,12 @@ class AlbumController extends AbstractController
*/ */
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
{ {
return str_starts_with($id, 'u_') $local = $albumRepo->findOneByLocalId($id);
? $albumRepo->findOneByLocalId($id) if ($local instanceof Album) {
: $albumRepo->findOneBySpotifyId($id); return $local;
}
return $albumRepo->findOneBySpotifyId($id);
} }
/** /**
@@ -334,6 +346,51 @@ class AlbumController extends AbstractController
} }
} }
/**
* @param array<string,mixed> $spotifyAlbum
*/
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
{
$album = $albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (is_array($tracks) && $tracks !== []) {
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
}
return $album;
}
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
{
if ($album->getSource() !== 'spotify') {
return false;
}
$spotifyId = $album->getSpotifyId();
if ($spotifyId === null) {
return false;
}
$storedCount = $album->getTracks()->count();
$needsSync = $storedCount === 0;
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
$needsSync = true;
}
if (!$needsSync) {
return false;
}
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
if ($spotifyAlbum === null) {
return false;
}
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum);
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
if (!is_array($tracks) || $tracks === []) {
return false;
}
$trackRepo->replaceAlbumTracks($album, $tracks);
$album->setTotalTracks(count($tracks));
return true;
}
} }

View File

@@ -2,8 +2,11 @@
namespace App\Entity; namespace App\Entity;
use App\Repository\AlbumRepository; use App\Entity\AlbumTrack;
use App\Entity\User; use App\Entity\User;
use App\Repository\AlbumRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
@@ -15,6 +18,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
class Album class Album
{ {
#[ORM\OneToMany(mappedBy: 'album', targetEntity: AlbumTrack::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['discNumber' => 'ASC', 'trackNumber' => 'ASC'])]
private Collection $tracks;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
@@ -68,6 +75,11 @@ class Album
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private ?\DateTimeImmutable $updatedAt = null; private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->tracks = new ArrayCollection();
}
/** /**
* Initializes timestamps right before first persistence. * Initializes timestamps right before first persistence.
*/ */
@@ -324,6 +336,29 @@ class Album
'source' => $this->source, 'source' => $this->source,
]; ];
} }
/**
* @return Collection<int, AlbumTrack>
*/
public function getTracks(): Collection
{
return $this->tracks;
}
public function addTrack(AlbumTrack $track): void
{
if (!$this->tracks->contains($track)) {
$this->tracks->add($track);
$track->setAlbum($this);
}
}
public function removeTrack(AlbumTrack $track): void
{
if ($this->tracks->removeElement($track) && $track->getAlbum() === $this) {
$track->setAlbum(null);
}
}
} }

140
src/Entity/AlbumTrack.php Normal file
View File

@@ -0,0 +1,140 @@
<?php
namespace App\Entity;
use App\Repository\AlbumTrackRepository;
use Doctrine\ORM\Mapping as ORM;
/**
* AlbumTrack persists individual tracks fetched from Spotify.
*/
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
#[ORM\Table(name: 'album_tracks')]
#[ORM\UniqueConstraint(name: 'uniq_album_disc_track', columns: ['album_id', 'disc_number', 'track_number'])]
class AlbumTrack
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Album::class, inversedBy: 'tracks')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Album $album = null;
#[ORM\Column(type: 'string', length: 64, nullable: true)]
private ?string $spotifyTrackId = null;
#[ORM\Column(type: 'integer')]
private int $discNumber = 1;
#[ORM\Column(type: 'integer')]
private int $trackNumber = 1;
#[ORM\Column(type: 'string', length: 512)]
private string $name = '';
#[ORM\Column(type: 'integer')]
private int $durationMs = 0;
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
private ?string $previewUrl = null;
public function getId(): ?int
{
return $this->id;
}
public function getAlbum(): ?Album
{
return $this->album;
}
public function setAlbum(?Album $album): void
{
$this->album = $album;
}
public function getSpotifyTrackId(): ?string
{
return $this->spotifyTrackId;
}
public function setSpotifyTrackId(?string $spotifyTrackId): void
{
$this->spotifyTrackId = $spotifyTrackId;
}
public function getDiscNumber(): int
{
return $this->discNumber;
}
public function setDiscNumber(int $discNumber): void
{
$this->discNumber = max(1, $discNumber);
}
public function getTrackNumber(): int
{
return $this->trackNumber;
}
public function setTrackNumber(int $trackNumber): void
{
$this->trackNumber = max(1, $trackNumber);
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getDurationMs(): int
{
return $this->durationMs;
}
public function setDurationMs(int $durationMs): void
{
$this->durationMs = max(0, $durationMs);
}
public function getPreviewUrl(): ?string
{
return $this->previewUrl;
}
public function setPreviewUrl(?string $previewUrl): void
{
$this->previewUrl = $previewUrl;
}
/**
* Normalizes the track for template rendering.
*
* @return array{disc:int,track:int,name:string,duration_label:string,duration_seconds:int,preview_url:?string}
*/
public function toTemplateArray(): array
{
$seconds = (int) floor($this->durationMs / 1000);
$minutes = intdiv($seconds, 60);
$remainingSeconds = $seconds % 60;
return [
'disc' => $this->discNumber,
'track' => $this->trackNumber,
'name' => $this->name,
'duration_label' => sprintf('%d:%02d', $minutes, $remainingSeconds),
'duration_seconds' => $seconds,
'preview_url' => $this->previewUrl,
];
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Repository;
use App\Entity\Album;
use App\Entity\AlbumTrack;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* AlbumTrackRepository manages bulk track synchronization.
*
* @extends ServiceEntityRepository<AlbumTrack>
*/
class AlbumTrackRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, AlbumTrack::class);
}
/**
* Replaces an album's stored tracklist with the provided Spotify payload.
*
* @param list<array<string,mixed>> $trackPayloads
*/
public function replaceAlbumTracks(Album $album, array $trackPayloads): void
{
$em = $this->getEntityManager();
foreach ($album->getTracks()->toArray() as $existing) {
if ($existing instanceof AlbumTrack) {
$album->removeTrack($existing);
}
}
$position = 1;
foreach ($trackPayloads as $payload) {
$name = trim((string) ($payload['name'] ?? ''));
if ($name === '') {
continue;
}
$track = new AlbumTrack();
$track->setAlbum($album);
$track->setSpotifyTrackId($this->stringOrNull($payload['id'] ?? null));
$track->setDiscNumber($this->normalizePositiveInt($payload['disc_number'] ?? 1));
$track->setTrackNumber($this->normalizePositiveInt($payload['track_number'] ?? $position));
$track->setName($name);
$track->setDurationMs(max(0, (int) ($payload['duration_ms'] ?? 0)));
$track->setPreviewUrl($this->stringOrNull($payload['preview_url'] ?? null));
$album->addTrack($track);
$em->persist($track);
$position++;
}
}
private function stringOrNull(mixed $value): ?string
{
if ($value === null) {
return null;
}
$string = trim((string) $value);
return $string === '' ? null : $string;
}
private function normalizePositiveInt(mixed $value): int
{
$int = (int) $value;
return $int > 0 ? $int : 1;
}
}

View File

@@ -1,8 +1,10 @@
<?php <?php
namespace App\Service; namespace App\Service;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
/** /**
@@ -77,6 +79,60 @@ class SpotifyClient
} }
} }
/**
* Fetches album metadata plus the full tracklist.
*
* @return array<mixed>|null
*/
public function getAlbumWithTracks(string $albumId): ?array
{
$album = $this->getAlbum($albumId);
if ($album === null) {
return null;
}
$tracks = $this->getAlbumTracks($albumId);
if ($tracks !== []) {
$album['tracks'] = $album['tracks'] ?? [];
$album['tracks']['items'] = $tracks;
$album['tracks']['total'] = count($tracks);
$album['tracks']['limit'] = count($tracks);
$album['tracks']['offset'] = 0;
$album['tracks']['next'] = null;
$album['tracks']['previous'] = null;
}
return $album;
}
/**
* Retrieves the complete tracklist for an album.
*
* @return list<array<string,mixed>>
*/
public function getAlbumTracks(string $albumId): array
{
$accessToken = $this->getAccessToken();
if ($accessToken === null) {
return [];
}
$items = [];
$limit = 50;
$offset = 0;
do {
$page = $this->requestAlbumTracksPage($albumId, $accessToken, $limit, $offset);
if ($page === null) {
break;
}
$batch = (array) ($page['items'] ?? []);
$items = array_merge($items, $batch);
$offset += $limit;
$total = isset($page['total']) ? (int) $page['total'] : null;
$hasNext = isset($page['next']) && $page['next'] !== null;
} while ($hasNext && ($total === null || $offset < $total));
return $items;
}
/** /**
* Fetch multiple albums with one call. * Fetch multiple albums with one call.
* *
@@ -132,18 +188,38 @@ class SpotifyClient
return $data; return $data;
} }
/**
* @return array<mixed>|null
*/
private function requestAlbumTracksPage(string $albumId, string $accessToken, int $limit, int $offset): ?array
{
$url = sprintf('https://api.spotify.com/v1/albums/%s/tracks', urlencode($albumId));
$options = [
'headers' => [ 'Authorization' => 'Bearer ' . $accessToken ],
'query' => [ 'limit' => $limit, 'offset' => $offset ],
];
try {
return $this->sendRequest('GET', $url, $options, 1200);
} catch (\Throwable) {
return null;
}
}
/** /**
* Retrieves a cached access token or refreshes credentials when missing. * Retrieves a cached access token or refreshes credentials when missing.
*/ */
private function getAccessToken(): ?string private function getAccessToken(): ?string
{ {
return $this->cache->get('spotify_client_credentials_token', function ($item) { $cacheKey = 'spotify_client_credentials_token';
// Default to 1 hour, will adjust based on response $token = $this->cache->get($cacheKey, function (ItemInterface $item) {
// Default to ~1 hour, adjusted after Spotify response
$item->expiresAfter(3500); $item->expiresAfter(3500);
$clientId = $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''); $clientId = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_ID', $this->clientId ?? ''));
$clientSecret = $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''); $clientSecret = trim((string) $this->settings->getValue('SPOTIFY_CLIENT_SECRET', $this->clientSecret ?? ''));
if ($clientId === '' || $clientSecret === '') { if ($clientId === '' || $clientSecret === '') {
// surface the miss quickly so the cache can be recomputed on the next request
$item->expiresAfter(60);
return null; return null;
} }
@@ -158,6 +234,7 @@ class SpotifyClient
$data = $response->toArray(false); $data = $response->toArray(false);
if (!isset($data['access_token'])) { if (!isset($data['access_token'])) {
$item->expiresAfter(60);
return null; return null;
} }
@@ -168,6 +245,13 @@ class SpotifyClient
return $data['access_token']; return $data['access_token'];
}); });
if ($token === null) {
// Remove failed entries so the next request retries instead of serving cached nulls.
$this->cache->delete($cacheKey);
}
return $token;
} }
} }

View File

@@ -2,17 +2,61 @@
{% block title %}User Management{% endblock %} {% block title %}User Management{% endblock %}
{% block body %} {% block body %}
<h1 class="h4 mb-4">User management</h1> {% set createPanelOpen = form.vars.submitted and not form.vars.valid %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-4">
<h1 class="h4 mb-0">User management</h1>
<button class="btn btn-accent" type="button" data-bs-toggle="collapse" data-bs-target="#create-user-panel" aria-expanded="{{ createPanelOpen ? 'true' : 'false' }}" aria-controls="create-user-panel">
Create user
</button>
</div>
<div class="row g-4"> <div class="row g-4">
<div class="col-lg-8"> <div class="col-12">
<div class="collapse {{ createPanelOpen ? 'show' : '' }}" id="create-user-panel">
<div class="card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h6 mb-0">Create user</h2>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#create-user-panel" aria-controls="create-user-panel">
Close
</button>
</div>
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="mb-3">
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.email) }}
</div>
<div class="mb-3">
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.displayName) }}
</div>
<div class="mb-3">
{{ form_label(form.plainPassword.first, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.plainPassword.first, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.plainPassword.first) }}
</div>
<div class="mb-3">
{{ form_label(form.plainPassword.second, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.plainPassword.second, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.plainPassword.second) }}
</div>
{{ form_errors(form.plainPassword) }}
<button class="btn btn-success w-100" type="submit">Create account</button>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h6 mb-0">Accounts</h2> <h2 class="h6 mb-0">Accounts</h2>
<span class="text-secondary small">{{ rows|length }} total</span> <span class="text-secondary small">{{ rows|length }} total</span>
</div> </div>
<div class="table-responsive"> <div class="mui-table-wrapper">
<table class="table table-sm align-middle"> <table class="mui-table">
<thead> <thead>
<tr> <tr>
<th scope="col">Name</th> <th scope="col">Name</th>
@@ -51,10 +95,23 @@
{% endif %} {% endif %}
<tr> <tr>
<td> <td>
<div class="fw-semibold">{{ user.displayName ?? '—' }}</div> <div class="mui-table__title-avatar">
{% set avatar = user.profileImagePath %}
{% if avatar %}
<img src="{{ avatar }}" alt="Avatar for {{ user.displayName ?? user.email }}">
{% else %}
<div class="rounded-3 bg-secondary-subtle text-secondary fw-semibold d-flex align-items-center justify-content-center" style="width:40px;height:40px;">
{{ (user.displayName ?? user.email)|slice(0,1)|upper }}
</div>
{% endif %}
<div>
<div class="mui-table__title">{{ user.displayName ?? '—' }}</div>
<div class="mui-table__subtitle d-md-none">{{ user.email }}</div>
</div>
</div>
</td> </td>
<td>{{ user.email }}</td> <td class="align-middle d-none d-md-table-cell">{{ user.email }}</td>
<td> <td class="align-middle">
{% for role in user.roles %} {% for role in user.roles %}
{% if role == 'ROLE_ADMIN' %} {% if role == 'ROLE_ADMIN' %}
<span class="badge text-bg-danger">Admin</span> <span class="badge text-bg-danger">Admin</span>
@@ -65,8 +122,8 @@
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td class="text-center">{{ row.albumCount }}</td> <td class="mui-table__metric">{{ row.albumCount }}</td>
<td class="text-center">{{ row.reviewCount }}</td> <td class="mui-table__metric">{{ row.reviewCount }}</td>
<td class="text-end"> <td class="text-end">
<div class="d-flex gap-2 justify-content-end"> <div class="d-flex gap-2 justify-content-end">
<form method="post" action="{{ path('admin_users_promote', {id: user.id}) }}" onsubmit="return confirm('{% if isModerator %}Remove moderator access from {{ user.email }}?{% else %}Promote {{ user.email }} to moderator?{% endif %}');"> <form method="post" action="{{ path('admin_users_promote', {id: user.id}) }}" onsubmit="return confirm('{% if isModerator %}Remove moderator access from {{ user.email }}?{% else %}Promote {{ user.email }} to moderator?{% endif %}');">
@@ -97,37 +154,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-lg-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h6 mb-3">Create user</h2>
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="mb-3">
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.email) }}
</div>
<div class="mb-3">
{{ form_label(form.displayName, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.displayName, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.displayName) }}
</div>
<div class="mb-3">
{{ form_label(form.plainPassword.first, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.plainPassword.first, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.plainPassword.first) }}
</div>
<div class="mb-3">
{{ form_label(form.plainPassword.second, null, {label_attr: {class: 'form-label'}}) }}
{{ form_widget(form.plainPassword.second, {attr: {class: 'form-control'}}) }}
{{ form_errors(form.plainPassword.second) }}
</div>
{{ form_errors(form.plainPassword) }}
<button class="btn btn-success w-100" type="submit">Create account</button>
{{ form_end(form) }}
</div>
</div>
</div>
</div> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {

View File

@@ -11,7 +11,19 @@
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-1">{{ album.name }}</h5> <h5 class="card-title mb-1">{{ album.name }}</h5>
<div class="text-secondary mb-2">{{ album.artists|map(a => a.name)|join(', ') }}</div> <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> {% set release_text = album.release_date is defined and album.release_date ? album.release_date : 'Not provided' %}
{% set track_count = album.total_tracks is defined and album.total_tracks ? album.total_tracks : 0 %}
{% set track_text = track_count > 0 ? track_count ~ ' tracks' : 'Track count unknown' %}
<p class="text-secondary mb-2">
Released {{ release_text }}{{ track_text }}
{% if album.source is defined and album.source == 'user' %}
<br>
<small>
Added{{ albumOwner ? ' by ' ~ (albumOwner.displayName ?? albumOwner.userIdentifier) : '' }}
{% if albumCreatedAt %}on {{ albumCreatedAt|date('Y-m-d') }}{% endif %}
</small>
{% endif %}
</p>
<p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p> <p class="mb-2"><strong>User score:</strong> {{ avg }}/10 ({{ count }})</p>
{% if album.external_urls.spotify %} {% if album.external_urls.spotify %}
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a> <a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
@@ -38,6 +50,46 @@
</div> </div>
</div> </div>
<div class="col-md-8"> <div class="col-md-8">
{% if tracks is defined and tracks is not empty %}
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h5 mb-0">Tracklist</h2>
<span class="text-secondary">{{ tracks|length }} tracks</span>
</div>
<div class="mui-table-wrapper w-100">
<table class="mui-table mui-table--compact mui-table--striped mb-0">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col" class="text-end">Duration</th>
</tr>
</thead>
<tbody>
{% for track in tracks %}
<tr>
<td class="mui-table__number">{{ track.disc > 1 ? track.disc ~ '.' : '' }}{{ track.track }}</td>
<td>
<div class="d-flex align-items-center gap-2">
<div class="mui-table__title">{{ track.name }}</div>
{% if track.preview_url %}
<a class="mui-icon-button" href="{{ track.preview_url }}" target="_blank" rel="noopener" title="Preview track">
&#9658;
</a>
{% endif %}
</div>
</td>
<td class="text-end mui-table__metric">{{ track.duration_label }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<h2 class="h5 mb-0">Reviews</h2> <h2 class="h5 mb-0">Reviews</h2>
</div> </div>

BIN
var/.DS_Store vendored

Binary file not shown.