eerrrrrr
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
All checks were successful
CI - Build Tonehaus Docker image / tonehaus-ci-build (push) Successful in 1m57s
This commit is contained in:
13
.env.example
13
.env.example
@@ -1,8 +1,13 @@
|
||||
SPOTIFY_CLIENT_ID=
|
||||
SPOTIFY_CLIENT_SECRET=
|
||||
APP_SECRET=changeme
|
||||
DEFAULT_URI=http://localhost:8000
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changeme # Arbitrary secret. Ideally a long random string.
|
||||
APP_ALLOW_REGISTRATION=1 #
|
||||
DEFAULT_URI=http://localhost:8000 # Should match external URI of application.
|
||||
DATABASE_DRIVER=postgres # Allowed values: postgres, sqlite
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||
#DATABASE_SQLITE_PATH=/absolute/path/to/database.sqlite # Optional override when DATABASE_DRIVER=sqlite
|
||||
ALBUM_SEARCH_LIMIT=30 # Amount of albums to be displayed at once. WARNING: Setting this number too high may cause rate limits.
|
||||
|
||||
# POSTGRES_DB=
|
||||
# POSTGRES_USER=
|
||||
@@ -10,7 +15,3 @@ DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony
|
||||
|
||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD=password
|
||||
|
||||
SPOTIFY_RATE_WINDOW_SECONDS=30
|
||||
SPOTIFY_RATE_MAX_REQUESTS=50
|
||||
SPOTIFY_RATE_MAX_REQUESTS_SENSITIVE=20
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
||||
!var/cache/.gitkeep
|
||||
!var/logs/.gitkeep
|
||||
!var/sessions/.gitkeep
|
||||
/var/data/
|
||||
|
||||
# Logs (Symfony4)
|
||||
/var/log/*
|
||||
|
||||
20
README.md
20
README.md
@@ -31,6 +31,12 @@ docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
|
||||
5) Visit `http://localhost:8000` to search for albums.
|
||||
|
||||
## Database driver
|
||||
|
||||
- Set `DATABASE_DRIVER=postgres` (default) to keep using the Postgres 16 container defined in `docker-compose.yml`.
|
||||
- Set `DATABASE_DRIVER=sqlite` to run against a self-contained SQLite file stored at `var/data/database.sqlite`.
|
||||
- When `DATABASE_DRIVER=sqlite`, the `DATABASE_URL` env var is ignored. Doctrine will automatically create and use the SQLite file; override the default location with `DATABASE_SQLITE_PATH` if needed.
|
||||
|
||||
## Features
|
||||
|
||||
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
|
||||
@@ -45,17 +51,6 @@ docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
## 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
|
||||
|
||||
@@ -67,8 +62,7 @@ See `/docs` for how-tos and deeper notes:
|
||||
- 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`
|
||||
- Troubleshooting: `docs/07-troubleshooting.md`
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -11,14 +11,10 @@
|
||||
"doctrine/doctrine-bundle": "^2.18",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.5",
|
||||
"doctrine/orm": "^3.5",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "7.3.*",
|
||||
"symfony/asset-mapper": "7.3.*",
|
||||
"symfony/console": "7.3.*",
|
||||
"symfony/doctrine-messenger": "7.3.*",
|
||||
"symfony/dotenv": "7.3.*",
|
||||
"symfony/expression-language": "7.3.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/form": "7.3.*",
|
||||
"symfony/framework-bundle": "7.3.*",
|
||||
@@ -35,7 +31,6 @@
|
||||
"symfony/string": "7.3.*",
|
||||
"symfony/twig-bundle": "7.3.*",
|
||||
"symfony/validator": "7.3.*",
|
||||
"symfony/web-link": "7.3.*",
|
||||
"symfony/yaml": "7.3.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/string-extra": "^3.22",
|
||||
|
||||
43
config/packages/doctrine.php
Normal file
43
config/packages/doctrine.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Symfony\Config\DoctrineConfig;
|
||||
|
||||
return static function (DoctrineConfig $doctrine): void {
|
||||
$driver = strtolower((string) ($_ENV['DATABASE_DRIVER'] ?? $_SERVER['DATABASE_DRIVER'] ?? 'postgres'));
|
||||
$supportedDrivers = ['postgres', 'sqlite'];
|
||||
|
||||
if (!in_array($driver, $supportedDrivers, true)) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Unsupported DATABASE_DRIVER "%s". Allowed values: %s',
|
||||
$driver,
|
||||
implode(', ', $supportedDrivers)
|
||||
));
|
||||
}
|
||||
|
||||
$dbal = $doctrine->dbal();
|
||||
$dbal->defaultConnection('default');
|
||||
|
||||
$connection = $dbal->connection('default');
|
||||
$connection->profilingCollectBacktrace('%kernel.debug%');
|
||||
$connection->useSavepoints(true);
|
||||
|
||||
if ('sqlite' === $driver) {
|
||||
$connection->driver('pdo_sqlite');
|
||||
|
||||
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
||||
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
||||
|
||||
if ($hasCustomPath) {
|
||||
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||
} else {
|
||||
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||
}
|
||||
} else {
|
||||
$connection->url('%env(resolve:DATABASE_URL)%');
|
||||
$connection->serverVersion('16');
|
||||
$connection->charset('utf8');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
framework:
|
||||
messenger:
|
||||
failure_transport: failed
|
||||
|
||||
transports:
|
||||
# https://symfony.com/doc/current/messenger.html#transport-configuration
|
||||
async:
|
||||
#dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
|
||||
dsn: 'sync://' # meh
|
||||
options:
|
||||
use_notify: true
|
||||
check_delayed_interval: 60000
|
||||
retry_strategy:
|
||||
max_retries: 3
|
||||
multiplier: 2
|
||||
failed: 'doctrine://default?queue_name=failed'
|
||||
# sync: 'sync://'
|
||||
|
||||
default_bus: messenger.bus.default
|
||||
|
||||
buses:
|
||||
messenger.bus.default: []
|
||||
|
||||
routing:
|
||||
|
||||
# Route your messages to the transports
|
||||
# 'App\Message\YourMessage': async
|
||||
@@ -1,4 +1,7 @@
|
||||
security:
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: ['ROLE_MODERATOR']
|
||||
ROLE_MODERATOR: ['ROLE_USER']
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
@@ -17,11 +20,11 @@ security:
|
||||
provider: app_user_provider
|
||||
|
||||
form_login:
|
||||
login_path: album_search
|
||||
login_path: app_login
|
||||
check_path: app_login
|
||||
enable_csrf: true
|
||||
default_target_path: album_search
|
||||
failure_path: album_search
|
||||
failure_path: app_login
|
||||
username_parameter: _username
|
||||
password_parameter: _password
|
||||
csrf_parameter: _csrf_token
|
||||
@@ -45,8 +48,9 @@ security:
|
||||
# Easy way to control access for large sections of your site
|
||||
# Note: Only the *first* access control that matches will be used
|
||||
access_control:
|
||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
||||
# - { path: ^/profile, roles: ROLE_USER }
|
||||
- { path: ^/admin/settings, roles: ROLE_ADMIN }
|
||||
- { path: ^/admin/users, roles: ROLE_MODERATOR }
|
||||
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/php/Dockerfile
|
||||
target: dev # change to "prod" for production build
|
||||
target: dev
|
||||
args:
|
||||
- APP_ENV=dev
|
||||
container_name: php
|
||||
|
||||
@@ -21,12 +21,23 @@ docker compose exec php php bin/console doctrine:migrations:migrate --no-interac
|
||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
||||
```
|
||||
|
||||
## Moderator (optional)
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-moderator mod@example.com
|
||||
```
|
||||
|
||||
## Spotify credentials
|
||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret.
|
||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret. (Stored in DB)
|
||||
- Fallback to env vars:
|
||||
```bash
|
||||
export SPOTIFY_CLIENT_ID=your_client_id
|
||||
export SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
|
||||
## Optional feature flags
|
||||
- Disable public registration by setting an env variable before starting Symfony:
|
||||
```bash
|
||||
export APP_ALLOW_REGISTRATION=0 # set to 1 (default) to re-enable
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@
|
||||
- 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)
|
||||
- Role-based access (user, moderator, admin) with protected admin routes
|
||||
- Admin Site Settings to manage Spotify credentials
|
||||
- Moderator/Admin dashboard with latest activity snapshots
|
||||
- User management table (create/delete accounts, promote/demote moderators)
|
||||
- User Dashboard for profile changes (email, display name, password)
|
||||
- Light/Dark theme toggle (cookie-backed)
|
||||
- Bootstrap UI
|
||||
|
||||
@@ -7,10 +7,24 @@
|
||||
|
||||
## Roles
|
||||
- `ROLE_USER`: default for registered users.
|
||||
- `ROLE_ADMIN`: promoted via console `app:promote-admin`.
|
||||
- `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.
|
||||
|
||||
### Access flow
|
||||
- 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.
|
||||
- Admin-only actions (site settings, moderator toggling, deleting other admins) are additionally guarded in controllers/templates to avoid accidental misuse.
|
||||
|
||||
### User management UI
|
||||
- `/admin/users` (moderator+) lists every account along with album/review counts.
|
||||
- Moderators can create new accounts (without affecting their own login session.. ).
|
||||
- Delete buttons are disabled (with tooltip hints) for protected rows such as the current user or any admin.
|
||||
- Admins see a Promote/Demote toggle: promoting grants `ROLE_MODERATOR`; demoting removes that role unless the target is an admin (admins always outrank moderators).
|
||||
- Admins can disable public registration from `/admin/settings`; when disabled, the “Sign up” button in the auth modal is replaced with a tooltip explaining that registration is closed, but `/admin/users` remains fully functional.
|
||||
- Registration can also be enforced via `APP_ALLOW_REGISTRATION=0/1` in the environment; the DB setting syncs on each Symfony boot, so flips take effect after the next restart.
|
||||
|
||||
## Password changes
|
||||
- On `/dashboard`, users can change email/display name.
|
||||
- On `/profile`, users can change email/display name.
|
||||
- To set a new password, the current password must be provided.
|
||||
|
||||
## Logout
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
- 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:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
## Permissions
|
||||
- Anyone can view.
|
||||
- Authors can edit/delete their own reviews.
|
||||
- Admins can edit/delete any review.
|
||||
- Moderators and admins can edit/delete any review or user-created album.
|
||||
|
||||
## UI
|
||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
||||
|
||||
@@ -1,14 +1,42 @@
|
||||
# Admin & Settings
|
||||
|
||||
## Access control
|
||||
- All `/admin/*` pages require authentication; unauthorized visitors get redirected through `/login`, which opens the auth modal automatically.
|
||||
- `ROLE_MODERATOR` grants dashboard + user list access.
|
||||
- `ROLE_ADMIN` adds settings access and moderator promotion/demotion abilities.
|
||||
|
||||
## Site dashboard (ROLE_MODERATOR)
|
||||
- URL: `/admin/dashboard`
|
||||
- Shows total counts plus the most recent reviews and albums so staff can moderate activity quickly.
|
||||
|
||||
## User management (ROLE_MODERATOR)
|
||||
- URL: `/admin/users`
|
||||
- Table columns:
|
||||
- Name/email/roles + album/review counts (queried via aggregates).
|
||||
- Action buttons always render; disabled buttons show tooltips describing why (e.g., "Administrators cannot be deleted").
|
||||
- Moderators:
|
||||
- Create new accounts via the inline form without logging themselves out.
|
||||
- Delete standard users or other moderators (except themselves).
|
||||
- Admins:
|
||||
- Toggle moderator role (Promote/Demote) for non-admin accounts.
|
||||
- Cannot delete or demote other admins—admin privileges supersede moderator status.
|
||||
|
||||
## Site settings (ROLE_ADMIN)
|
||||
- URL: `/admin/settings`
|
||||
- Manage Spotify credentials stored in DB.
|
||||
- Form persists Spotify Client ID/Secret in the DB (no restart needed).
|
||||
- Toggle “Allow self-service registration” to pause public sign-ups while keeping `/admin/users` creation available to staff.
|
||||
- The setting syncs with the `APP_ALLOW_REGISTRATION` environment variable each time Symfony boots (change the env value and restart to enforce). UI changes persist while the process runs.
|
||||
- CSRF + role guards prevent unauthorized updates.
|
||||
|
||||
## User management
|
||||
- Promote an admin:
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-admin user@example.com
|
||||
```
|
||||
- Promote a moderator:
|
||||
```bash
|
||||
docker compose exec php php bin/console app:promote-moderator user@example.com
|
||||
```
|
||||
|
||||
## Appearance
|
||||
- `/settings` provides a dark/light mode toggle.
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
## 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.
|
||||
|
||||
## Hitting admin routes redirects to home
|
||||
- Expected when not logged in or lacking the required role.
|
||||
- Ensure your user has `ROLE_MODERATOR` for `/admin/dashboard` or `/admin/users`, and `ROLE_ADMIN` for `/admin/settings`.
|
||||
- Use the console commands in `06-admin-and-settings.md` to grant roles.
|
||||
|
||||
187
public/css/app.css
Normal file
187
public/css/app.css
Normal file
@@ -0,0 +1,187 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--accent-color: #6750a4;
|
||||
--accent-on-color: #ffffff;
|
||||
--md-surface: color-mix(in srgb, var(--accent-color) 6%, #ffffff);
|
||||
--md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #f5f4fa);
|
||||
--md-card: #ffffff;
|
||||
--md-card-border: color-mix(in srgb, var(--accent-color) 24%, transparent);
|
||||
--md-outline: color-mix(in srgb, var(--accent-color) 18%, #d9d5ea);
|
||||
--md-text-primary: #1c1b20;
|
||||
--md-text-secondary: color-mix(in srgb, var(--accent-color) 30%, #4a4458);
|
||||
--md-muted-bg: color-mix(in srgb, var(--accent-color) 18%, transparent);
|
||||
--md-focus-ring: color-mix(in srgb, var(--accent-color) 45%, transparent);
|
||||
--md-shadow-ambient: 0 12px 32px color-mix(in srgb, rgba(15, 13, 33, 0.2) 70%, var(--accent-color) 10%);
|
||||
}
|
||||
|
||||
[data-bs-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--md-surface: color-mix(in srgb, var(--accent-color) 5%, #131217);
|
||||
--md-surface-variant: color-mix(in srgb, var(--accent-color) 14%, #1f1e25);
|
||||
--md-card: color-mix(in srgb, var(--accent-color) 8%, #1f1e25);
|
||||
--md-card-border: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||
--md-outline: color-mix(in srgb, var(--accent-color) 35%, #6c6772);
|
||||
--md-text-primary: color-mix(in srgb, var(--accent-color) 6%, #f5f2ff);
|
||||
--md-text-secondary: color-mix(in srgb, var(--accent-color) 24%, #cfc6dc);
|
||||
--md-muted-bg: color-mix(in srgb, var(--accent-color) 22%, transparent);
|
||||
--md-shadow-ambient: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--md-surface);
|
||||
color: var(--md-text-primary);
|
||||
font-family: 'Inter', 'Segoe UI', -apple-system, BlinkMacSystemFont, 'Roboto', sans-serif;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
main.container {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--md-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--md-text-secondary) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--md-card);
|
||||
border: 1px solid var(--md-card-border);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--md-shadow-ambient);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar.bg-body-tertiary {
|
||||
background-color: color-mix(in srgb, var(--accent-color) 6%, rgba(255, 255, 255, 0.92)) !important;
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--md-card-border);
|
||||
border-radius: 0 0 28px 28px;
|
||||
box-shadow: 0 12px 32px rgba(15, 13, 33, 0.1);
|
||||
margin: 0 auto 2rem auto;
|
||||
max-width: 1100px;
|
||||
width: calc(100% - 2rem);
|
||||
position: relative;
|
||||
z-index: 1040;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.navbar.bg-body-tertiary {
|
||||
border-radius: 0;
|
||||
width: 100%;
|
||||
margin: 0 0 1.5rem 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-bs-theme='dark'] .navbar.bg-body-tertiary {
|
||||
background-color: color-mix(in srgb, var(--accent-color) 8%, rgba(26, 25, 32, 0.96)) !important;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--md-card-border);
|
||||
background-color: var(--md-card);
|
||||
}
|
||||
|
||||
.btn-success,
|
||||
.btn-primary,
|
||||
.btn-accent {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-on-color);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-success:hover,
|
||||
.btn-primary:hover,
|
||||
.btn-accent:hover {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-on-color);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-outline-secondary,
|
||||
.btn-outline-success,
|
||||
.btn-outline-primary {
|
||||
color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover,
|
||||
.btn-outline-success:hover,
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-on-color);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-color);
|
||||
opacity: 0.9;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
border-radius: 14px;
|
||||
border-color: var(--md-outline);
|
||||
background-color: var(--md-card);
|
||||
color: var(--md-text-primary);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 0.2rem var(--md-focus-ring);
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.badge.text-bg-primary,
|
||||
.badge.text-bg-secondary {
|
||||
background-color: var(--accent-color) !important;
|
||||
color: var(--accent-on-color) !important;
|
||||
}
|
||||
|
||||
.landing-search-input {
|
||||
border: 1px solid var(--md-card-border);
|
||||
}
|
||||
|
||||
.card .btn.btn-outline-primary,
|
||||
.card .btn.btn-outline-success {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.card .btn.btn-success,
|
||||
.card .btn.btn-outline-primary,
|
||||
.card .btn.btn-outline-success {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 16px;
|
||||
border: none;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
57
src/Command/PromoteModeratorCommand.php
Normal file
57
src/Command/PromoteModeratorCommand.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?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-moderator', description: 'Grant ROLE_MODERATOR to a user by email')]
|
||||
class PromoteModeratorCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Stores dependencies for the console handler.
|
||||
*/
|
||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the required email argument.
|
||||
*/
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
|
||||
}
|
||||
|
||||
/**
|
||||
* Grants the moderator role if the user exists.
|
||||
*/
|
||||
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_MODERATOR', $roles, true)) {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$user->setRoles($roles);
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\ProfileFormType;
|
||||
use App\Form\ChangePasswordFormType;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Service\ImageStorage;
|
||||
@@ -42,7 +41,13 @@ class AccountController extends AbstractController
|
||||
->where('a.source = :src')->setParameter('src', 'user')
|
||||
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
||||
->getQuery()->getSingleScalarResult();
|
||||
$userType = $this->isGranted('ROLE_ADMIN') ? 'Admin' : 'User';
|
||||
if ($this->isGranted('ROLE_ADMIN')) {
|
||||
$userType = 'Admin';
|
||||
} elseif ($this->isGranted('ROLE_MODERATOR')) {
|
||||
$userType = 'Moderator';
|
||||
} else {
|
||||
$userType = 'User';
|
||||
}
|
||||
$userReviews = $reviews->createQueryBuilder('r')
|
||||
->where('r.author = :u')->setParameter('u', $user)
|
||||
->orderBy('r.createdAt', 'DESC')
|
||||
@@ -84,9 +89,10 @@ class AccountController extends AbstractController
|
||||
$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/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
return $this->render('account/profile.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'profileImage' => $user->getProfileImagePath(),
|
||||
]);
|
||||
}
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
}
|
||||
@@ -116,35 +122,6 @@ class AccountController extends AbstractController
|
||||
{
|
||||
return $this->render('account/settings.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the password change form and updates the hash.
|
||||
*/
|
||||
#[Route('/account/password', name: 'account_password', methods: ['GET', 'POST'])]
|
||||
public function changePassword(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$form = $this->createForm(ChangePasswordFormType::class);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$current = (string) $form->get('currentPassword')->getData();
|
||||
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
||||
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
||||
return $this->render('account/password.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
$newPassword = (string) $form->get('newPassword')->getData();
|
||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Password updated.');
|
||||
return $this->redirectToRoute('account_dashboard');
|
||||
}
|
||||
return $this->render('account/password.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ use Symfony\Component\Routing\Attribute\Route;
|
||||
/**
|
||||
* DashboardController shows high-level site activity to admins.
|
||||
*/
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
#[IsGranted('ROLE_MODERATOR')]
|
||||
class DashboardController extends AbstractController
|
||||
{
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller\Admin;
|
||||
|
||||
use App\Form\SiteSettingsType;
|
||||
use App\Repository\SettingRepository;
|
||||
use App\Service\RegistrationToggle;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -20,22 +21,31 @@ class SettingsController extends AbstractController
|
||||
* Displays and persists Spotify credential settings.
|
||||
*/
|
||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||
public function settings(Request $request, SettingRepository $settings): Response
|
||||
public function settings(Request $request, SettingRepository $settings, RegistrationToggle $registrationToggle): 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'));
|
||||
$registrationOverride = $registrationToggle->envOverride();
|
||||
$form->get('REGISTRATION_ENABLED')->setData($registrationToggle->isEnabled());
|
||||
|
||||
$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());
|
||||
if ($registrationOverride === null) {
|
||||
$registrationToggle->persist((bool) $form->get('REGISTRATION_ENABLED')->getData());
|
||||
} else {
|
||||
$this->addFlash('info', 'Registration is locked by APP_ALLOW_REGISTRATION and cannot be changed.');
|
||||
}
|
||||
$this->addFlash('success', 'Settings saved.');
|
||||
return $this->redirectToRoute('admin_settings');
|
||||
}
|
||||
|
||||
return $this->render('admin/settings.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'registrationImmutable' => $registrationOverride !== null,
|
||||
'registrationOverrideValue' => $registrationOverride,
|
||||
]);
|
||||
}
|
||||
}
|
||||
121
src/Controller/Admin/UserController.php
Normal file
121
src/Controller/Admin/UserController.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Admin;
|
||||
|
||||
use App\Dto\AdminUserData;
|
||||
use App\Entity\User;
|
||||
use App\Form\AdminUserType;
|
||||
use App\Repository\UserRepository;
|
||||
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\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
/**
|
||||
* UserController exposes moderator/admin user management tools.
|
||||
*/
|
||||
#[IsGranted('ROLE_MODERATOR')]
|
||||
class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserPasswordHasherInterface $hasher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists all users and handles manual account creation.
|
||||
*/
|
||||
#[Route('/admin/users', name: 'admin_users', methods: ['GET', 'POST'])]
|
||||
public function index(Request $request, UserRepository $users): Response
|
||||
{
|
||||
$formData = new AdminUserData();
|
||||
$form = $this->createForm(AdminUserType::class, $formData);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||
$newUser = new User();
|
||||
$newUser->setEmail($formData->email);
|
||||
$newUser->setDisplayName($formData->displayName);
|
||||
$newUser->setPassword($this->hasher->hashPassword($newUser, $plainPassword));
|
||||
$this->em->persist($newUser);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'User account created.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
return $this->render('admin/users.html.twig', [
|
||||
'form' => $form->createView(),
|
||||
'rows' => $users->findAllWithStats(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user account (moderators cannot delete admins).
|
||||
*/
|
||||
#[Route('/admin/users/{id}/delete', name: 'admin_users_delete', methods: ['POST'])]
|
||||
public function delete(User $target, Request $request): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('delete-user-' . $target->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
/** @var User|null $current */
|
||||
$current = $this->getUser();
|
||||
if ($current && $target->getId() === $current->getId()) {
|
||||
$this->addFlash('danger', 'You cannot delete your own account.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
if (in_array('ROLE_ADMIN', $target->getRoles(), true)) {
|
||||
$this->addFlash('danger', 'Administrators cannot delete other administrators.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
$this->em->remove($target);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'User deleted.');
|
||||
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a user to moderator (admins only).
|
||||
*/
|
||||
#[Route('/admin/users/{id}/promote', name: 'admin_users_promote', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_ADMIN')]
|
||||
public function promote(User $target, Request $request): Response
|
||||
{
|
||||
if (!$this->isCsrfTokenValid('promote-user-' . $target->getId(), (string) $request->request->get('_token'))) {
|
||||
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||
}
|
||||
|
||||
$roles = $target->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) {
|
||||
$this->addFlash('danger', 'Administrators already include moderator permissions.');
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
$isModerator = in_array('ROLE_MODERATOR', $roles, true);
|
||||
|
||||
if ($isModerator) {
|
||||
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
||||
$target->setRoles($filtered);
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'Moderator privileges removed.');
|
||||
} else {
|
||||
$roles[] = 'ROLE_MODERATOR';
|
||||
$target->setRoles(array_values(array_unique($roles)));
|
||||
$this->em->flush();
|
||||
$this->addFlash('success', 'User promoted to moderator.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_users');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\SpotifyClient;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Dto\AlbumSearchCriteria;
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use App\Form\ReviewType;
|
||||
use App\Form\AlbumType;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use App\Service\AlbumSearchService;
|
||||
use App\Service\ImageStorage;
|
||||
use App\Service\SpotifyClient;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
@@ -19,7 +21,6 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* AlbumController orchestrates search, CRUD, and review entry on albums.
|
||||
@@ -28,6 +29,7 @@ class AlbumController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImageStorage $imageStorage,
|
||||
private readonly AlbumSearchService $albumSearch,
|
||||
private readonly int $searchLimit = 20
|
||||
) {
|
||||
}
|
||||
@@ -36,37 +38,21 @@ class AlbumController extends AbstractController
|
||||
* Searches Spotify plus local albums and decorates results with review stats.
|
||||
*/
|
||||
#[Route('/', name: 'album_search', methods: ['GET'])]
|
||||
public function search(Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, EntityManagerInterface $em, LoggerInterface $logger): Response
|
||||
public function search(Request $request): Response
|
||||
{
|
||||
$filters = $this->buildSearchFilters($request);
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
|
||||
$spotifyData = $this->resolveSpotifyAlbums($filters, $spotify, $albumRepo, $reviewRepo, $em, $logger);
|
||||
$stats = $this->mergeStats($stats, $spotifyData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
||||
|
||||
$userData = $this->resolveUserAlbums($filters, $albumRepo, $reviewRepo);
|
||||
$stats = $this->mergeStats($stats, $userData['stats']);
|
||||
|
||||
$albums = $this->composeAlbumList(
|
||||
$filters['source'],
|
||||
$userData['payloads'],
|
||||
$spotifyData['payloads'],
|
||||
$filters['limit']
|
||||
);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, []);
|
||||
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||
$result = $this->albumSearch->search($criteria);
|
||||
|
||||
return $this->render('album/search.html.twig', [
|
||||
'query' => $filters['query'],
|
||||
'album' => $filters['albumName'],
|
||||
'artist' => $filters['artist'],
|
||||
'year_from' => $filters['yearFrom'] ?: '',
|
||||
'year_to' => $filters['yearTo'] ?: '',
|
||||
'albums' => $albums,
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
'source' => $filters['source'],
|
||||
'query' => $criteria->query,
|
||||
'album' => $criteria->albumName,
|
||||
'artist' => $criteria->artist,
|
||||
'year_from' => $criteria->yearFrom ?? '',
|
||||
'year_to' => $criteria->yearTo ?? '',
|
||||
'albums' => $result->albums,
|
||||
'stats' => $result->stats,
|
||||
'savedIds' => $result->savedIds,
|
||||
'source' => $criteria->source,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -291,7 +277,7 @@ class AlbumController extends AbstractController
|
||||
*/
|
||||
private function canManageAlbum(Album $album): bool
|
||||
{
|
||||
if ($this->isGranted('ROLE_ADMIN')) {
|
||||
if ($this->isGranted('ROLE_MODERATOR')) {
|
||||
return true;
|
||||
}
|
||||
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
|
||||
@@ -348,309 +334,6 @@ class AlbumController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* query:string,
|
||||
* albumName:string,
|
||||
* artist:string,
|
||||
* source:string,
|
||||
* yearFrom:int,
|
||||
* yearTo:int,
|
||||
* limit:int,
|
||||
* spotifyQuery:string,
|
||||
* hasUserFilters:bool,
|
||||
* useSpotify:bool
|
||||
* }
|
||||
*/
|
||||
private function buildSearchFilters(Request $request): array
|
||||
{
|
||||
$query = trim((string) $request->query->get('q', ''));
|
||||
$albumName = trim($request->query->getString('album', ''));
|
||||
$artist = trim($request->query->getString('artist', ''));
|
||||
$source = $request->query->getString('source', 'all');
|
||||
$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;
|
||||
$spotifyQuery = $this->buildSpotifyQuery($query, $albumName, $artist, $yearFrom, $yearTo);
|
||||
$hasUserFilters = ($spotifyQuery !== '' || $albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
|
||||
$useSpotify = ($source === 'all' || $source === 'spotify');
|
||||
|
||||
return [
|
||||
'query' => $query,
|
||||
'albumName' => $albumName,
|
||||
'artist' => $artist,
|
||||
'source' => $source,
|
||||
'yearFrom' => $yearFrom,
|
||||
'yearTo' => $yearTo,
|
||||
'limit' => $this->searchLimit,
|
||||
'spotifyQuery' => $spotifyQuery,
|
||||
'hasUserFilters' => $hasUserFilters,
|
||||
'useSpotify' => $useSpotify,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSpotifyQuery(string $query, string $albumName, string $artist, int $yearFrom, int $yearTo): string
|
||||
{
|
||||
$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;
|
||||
}
|
||||
}
|
||||
if ($query !== '') { $parts[] = $query; }
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function resolveSpotifyAlbums(
|
||||
array $filters,
|
||||
SpotifyClient $spotify,
|
||||
AlbumRepository $albumRepo,
|
||||
ReviewRepository $reviewRepo,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
): array {
|
||||
if (!$filters['useSpotify'] || $filters['spotifyQuery'] === '') {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$stored = $albumRepo->searchSpotifyAlbums(
|
||||
$filters['spotifyQuery'],
|
||||
$filters['albumName'],
|
||||
$filters['artist'],
|
||||
$filters['yearFrom'],
|
||||
$filters['yearTo'],
|
||||
$filters['limit']
|
||||
);
|
||||
$storedPayloads = array_map(static fn($a) => $a->toTemplateArray(), $stored);
|
||||
$storedIds = $this->collectSpotifyIds($stored);
|
||||
$stats = $storedIds ? $reviewRepo->getAggregatesForAlbumIds($storedIds) : [];
|
||||
$savedIds = $storedIds;
|
||||
|
||||
if (count($stored) >= $filters['limit']) {
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $filters['limit']),
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
];
|
||||
}
|
||||
|
||||
$apiPayloads = $this->fetchSpotifyPayloads(
|
||||
$filters,
|
||||
$spotify,
|
||||
$albumRepo,
|
||||
$reviewRepo,
|
||||
$em,
|
||||
$logger
|
||||
);
|
||||
|
||||
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $filters['limit']);
|
||||
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchSpotifyPayloads(
|
||||
array $filters,
|
||||
SpotifyClient $spotify,
|
||||
AlbumRepository $albumRepo,
|
||||
ReviewRepository $reviewRepo,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger
|
||||
): array {
|
||||
$result = $spotify->searchAlbums($filters['spotifyQuery'], $filters['limit']);
|
||||
$searchItems = $result['albums']['items'] ?? [];
|
||||
$logger->info('Album search results received', ['query' => $filters['spotifyQuery'], 'items' => is_countable($searchItems) ? count($searchItems) : 0]);
|
||||
|
||||
if (!$searchItems) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$ids = $this->extractSpotifyIds($searchItems);
|
||||
if ($ids === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$full = $spotify->getAlbums($ids);
|
||||
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
||||
if ($albumsPayload === [] && $searchItems !== []) {
|
||||
$albumsPayload = $searchItems;
|
||||
$logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
|
||||
}
|
||||
|
||||
$upserted = 0;
|
||||
foreach ($albumsPayload as $sa) {
|
||||
$albumRepo->upsertFromSpotifyAlbum((array) $sa);
|
||||
$upserted++;
|
||||
}
|
||||
$em->flush();
|
||||
$logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
||||
|
||||
$existing = $albumRepo->findBySpotifyIdsKeyed($ids);
|
||||
$payloads = [];
|
||||
foreach ($ids as $sid) {
|
||||
if (isset($existing[$sid])) {
|
||||
$payloads[] = $existing[$sid]->toTemplateArray();
|
||||
}
|
||||
}
|
||||
|
||||
$stats = $reviewRepo->getAggregatesForAlbumIds($ids);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'stats' => $stats,
|
||||
'savedIds' => array_keys($existing),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array>,stats:array<string,array{count:int,avg:float}>}
|
||||
*/
|
||||
private function resolveUserAlbums(array $filters, AlbumRepository $albumRepo, ReviewRepository $reviewRepo): array
|
||||
{
|
||||
if (!$filters['hasUserFilters'] || ($filters['source'] !== 'user' && $filters['source'] !== 'all')) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$userAlbums = $albumRepo->searchUserAlbums(
|
||||
$filters['query'],
|
||||
$filters['albumName'],
|
||||
$filters['artist'],
|
||||
$filters['yearFrom'],
|
||||
$filters['yearTo'],
|
||||
$filters['limit']
|
||||
);
|
||||
if ($userAlbums === []) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$entityIds = array_values(array_map(static fn($a) => $a->getId(), $userAlbums));
|
||||
$userStats = $reviewRepo->getAggregatesForAlbumEntityIds($entityIds);
|
||||
$payloads = array_map(static fn($a) => $a->toTemplateArray(), $userAlbums);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $userAlbums
|
||||
* @param array<int,array{count:int,avg:float}> $userStats
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
*/
|
||||
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($userAlbums as $album) {
|
||||
$entityId = (int) $album->getId();
|
||||
$localId = (string) $album->getLocalId();
|
||||
if ($localId !== '' && isset($userStats[$entityId])) {
|
||||
$mapped[$localId] = $userStats[$entityId];
|
||||
}
|
||||
}
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
|
||||
{
|
||||
if ($source === 'user') {
|
||||
return array_slice($userPayloads, 0, $limit);
|
||||
}
|
||||
if ($source === 'spotify') {
|
||||
return array_slice($spotifyPayloads, 0, $limit);
|
||||
}
|
||||
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectSpotifyIds(array $albums): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($albums as $album) {
|
||||
$sid = (string) $album->getSpotifyId();
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $searchItems
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractSpotifyIds(array $searchItems): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($searchItems as $item) {
|
||||
$id = isset($item['id']) ? (string) $item['id'] : '';
|
||||
if ($id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function mergeStats(array $current, array $updates): array
|
||||
{
|
||||
foreach ($updates as $key => $value) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
|
||||
private function mergeSavedIds(array $current, array $updates): array
|
||||
{
|
||||
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
|
||||
return array_values(array_unique($merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $primary
|
||||
* @param array<int,array<mixed>> $secondary
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function mergePayloadLists(array $primary, array $secondary, int $limit): array
|
||||
{
|
||||
$seen = [];
|
||||
$merged = [];
|
||||
foreach ($primary as $payload) {
|
||||
$merged[] = $payload;
|
||||
if (isset($payload['id'])) {
|
||||
$seen[$payload['id']] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
foreach ($secondary as $payload) {
|
||||
$id = $payload['id'] ?? null;
|
||||
if ($id !== null && isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$merged[] = $payload;
|
||||
if ($id !== null) {
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Form\RegistrationFormType;
|
||||
use App\Service\RegistrationToggle;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
@@ -21,8 +22,17 @@ class RegistrationController extends AbstractController
|
||||
* Processes registration submissions or serves the form modal.
|
||||
*/
|
||||
#[Route('/register', name: 'app_register')]
|
||||
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher): Response
|
||||
public function register(Request $request, EntityManagerInterface $entityManager, UserPasswordHasherInterface $passwordHasher, RegistrationToggle $registrationToggle): Response
|
||||
{
|
||||
$registrationEnabled = $registrationToggle->isEnabled();
|
||||
if (!$registrationEnabled && !$this->isGranted('ROLE_ADMIN')) {
|
||||
if ($request->isXmlHttpRequest()) {
|
||||
return new JsonResponse(['ok' => false, 'errors' => ['registration' => ['Registration is currently disabled.']]], 403);
|
||||
}
|
||||
$this->addFlash('info', 'Registration is currently disabled.');
|
||||
return $this->redirectToRoute('album_search');
|
||||
}
|
||||
|
||||
// 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']);
|
||||
|
||||
24
src/Dto/AdminUserData.php
Normal file
24
src/Dto/AdminUserData.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* AdminUserData transports user creation input from the admin form.
|
||||
* This is a Data Transfer Object to avoid direct entity manipulation.
|
||||
* Used to allow user creation in the user management panel without invalidating active token.
|
||||
* (This took too long to figure out)
|
||||
*/
|
||||
class AdminUserData
|
||||
{
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
public string $email = '';
|
||||
|
||||
#[Assert\Length(max: 120)]
|
||||
public ?string $displayName = null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
82
src/Dto/AlbumSearchCriteria.php
Normal file
82
src/Dto/AlbumSearchCriteria.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
/**
|
||||
* AlbumSearchCriteria captures normalized filters for album discovery.
|
||||
*/
|
||||
final class AlbumSearchCriteria
|
||||
{
|
||||
public readonly string $query;
|
||||
public readonly string $albumName;
|
||||
public readonly string $artist;
|
||||
public readonly ?int $yearFrom;
|
||||
public readonly ?int $yearTo;
|
||||
public readonly string $source;
|
||||
public readonly int $limit;
|
||||
|
||||
public function __construct(
|
||||
string $query,
|
||||
string $albumName,
|
||||
string $artist,
|
||||
?int $yearFrom,
|
||||
?int $yearTo,
|
||||
string $source,
|
||||
int $limit
|
||||
) {
|
||||
$this->query = $query;
|
||||
$this->albumName = $albumName;
|
||||
$this->artist = $artist;
|
||||
$this->yearFrom = $yearFrom;
|
||||
$this->yearTo = $yearTo;
|
||||
$this->source = in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
$this->limit = max(1, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds criteria from an incoming HTTP request.
|
||||
*/
|
||||
public static function fromRequest(Request $request, int $defaultLimit = 20): self
|
||||
{
|
||||
return new self(
|
||||
query: trim((string) $request->query->get('q', '')),
|
||||
albumName: trim($request->query->getString('album', '')),
|
||||
artist: trim($request->query->getString('artist', '')),
|
||||
yearFrom: self::normalizeYear($request->query->get('year_from')),
|
||||
yearTo: self::normalizeYear($request->query->get('year_to')),
|
||||
source: self::normalizeSource($request->query->getString('source', 'all')),
|
||||
limit: $defaultLimit
|
||||
);
|
||||
}
|
||||
|
||||
public function useSpotify(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'spotify';
|
||||
}
|
||||
|
||||
public function useUser(): bool
|
||||
{
|
||||
return $this->source === 'all' || $this->source === 'user';
|
||||
}
|
||||
|
||||
private static function normalizeYear(mixed $value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
return preg_match('/^\d{4}$/', $raw) ? (int) $raw : null;
|
||||
}
|
||||
|
||||
private static function normalizeSource(string $source): string
|
||||
{
|
||||
$source = strtolower(trim($source));
|
||||
return in_array($source, ['all', 'spotify', 'user'], true) ? $source : 'all';
|
||||
}
|
||||
}
|
||||
|
||||
23
src/Dto/AlbumSearchResult.php
Normal file
23
src/Dto/AlbumSearchResult.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
/**
|
||||
* AlbumSearchResult encapsulates merged payloads for presentation layers.
|
||||
*/
|
||||
final class AlbumSearchResult
|
||||
{
|
||||
/**
|
||||
* @param array<int,array<mixed>> $albums
|
||||
* @param array<string,array{count:int,avg:float}> $stats
|
||||
* @param array<int,string> $savedIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly AlbumSearchCriteria $criteria,
|
||||
public readonly array $albums,
|
||||
public readonly array $stats,
|
||||
public readonly array $savedIds
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
57
src/Form/AdminUserType.php
Normal file
57
src/Form/AdminUserType.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Dto\AdminUserData;
|
||||
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;
|
||||
|
||||
/**
|
||||
* AdminUserType lets moderators manually create accounts.
|
||||
*/
|
||||
class AdminUserType 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' => 'Passwords must match.',
|
||||
'constraints' => [
|
||||
new Assert\NotBlank(),
|
||||
new Assert\Length(min: 8),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => AdminUserData::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class AlbumType extends AbstractType
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
'label' => 'Album cover',
|
||||
'help' => 'JPEG or PNG up to 5MB.',
|
||||
'constraints' => [new Assert\Image(maxSize: '5M')],
|
||||
])
|
||||
->add('externalUrl', TextType::class, [
|
||||
|
||||
@@ -9,6 +9,9 @@ use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* Retained for compatibility; password updates now live on the profile page.
|
||||
*/
|
||||
class ChangePasswordFormType extends AbstractType
|
||||
{
|
||||
/**
|
||||
@@ -38,4 +41,3 @@ class ChangePasswordFormType extends AbstractType
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Form;
|
||||
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
@@ -24,6 +25,11 @@ class SiteSettingsType extends AbstractType
|
||||
'required' => false,
|
||||
'label' => 'Spotify Client Secret',
|
||||
'mapped' => false,
|
||||
])
|
||||
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Allow self-service registration',
|
||||
'mapped' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,11 @@ class SettingRepository extends ServiceEntityRepository
|
||||
public function setValue(string $name, ?string $value): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$setting = $this->findOneBy(['name' => $name]) ?? (new Setting())->setName($name);
|
||||
$setting = $this->findOneBy(['name' => $name]);
|
||||
if ($setting === null) {
|
||||
$setting = new Setting();
|
||||
$setting->setName($name);
|
||||
}
|
||||
$setting->setValue($value);
|
||||
$em->persist($setting);
|
||||
$em->flush();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Album;
|
||||
use App\Entity\Review;
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
@@ -32,6 +34,36 @@ class UserRepository extends ServiceEntityRepository
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every user with aggregated album/review counts.
|
||||
*
|
||||
* @return list<array{user: User, albumCount: int, reviewCount: int}>
|
||||
*/
|
||||
public function findAllWithStats(): array
|
||||
{
|
||||
$rows = $this->createQueryBuilder('u')
|
||||
->select('u', 'COUNT(DISTINCT a.id) AS albumCount', 'COUNT(DISTINCT r.id) AS reviewCount')
|
||||
->leftJoin(Album::class, 'a', 'WITH', 'a.createdBy = u')
|
||||
->leftJoin(Review::class, 'r', 'WITH', 'r.author = u')
|
||||
->groupBy('u.id')
|
||||
->orderBy('u.email', 'ASC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return array_map(static function (array $row): array {
|
||||
/** @var User $user */
|
||||
$user = $row[0] ?? $row['user'] ?? null;
|
||||
if (!$user instanceof User) {
|
||||
throw new \RuntimeException('Unexpected result row; expected User entity.');
|
||||
}
|
||||
return [
|
||||
'user' => $user,
|
||||
'albumCount' => (int) ($row['albumCount'] ?? 0),
|
||||
'reviewCount' => (int) ($row['reviewCount'] ?? 0),
|
||||
];
|
||||
}, $rows);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,8 @@ class ReviewVoter extends Voter
|
||||
return false;
|
||||
}
|
||||
|
||||
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||
$roles = $user->getRoles();
|
||||
if (in_array('ROLE_ADMIN', $roles, true) || in_array('ROLE_MODERATOR', $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
313
src/Service/AlbumSearchService.php
Normal file
313
src/Service/AlbumSearchService.php
Normal file
@@ -0,0 +1,313 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Dto\AlbumSearchCriteria;
|
||||
use App\Dto\AlbumSearchResult;
|
||||
use App\Entity\Album;
|
||||
use App\Repository\AlbumRepository;
|
||||
use App\Repository\ReviewRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* AlbumSearchService composes Spotify and user albums into reusable payloads.
|
||||
*/
|
||||
class AlbumSearchService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SpotifyClient $spotify,
|
||||
private readonly AlbumRepository $albumRepository,
|
||||
private readonly ReviewRepository $reviewRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function search(AlbumSearchCriteria $criteria): AlbumSearchResult
|
||||
{
|
||||
$spotifyQuery = $this->buildSpotifyQuery($criteria);
|
||||
$hasUserFilters = $this->hasUserFilters($criteria, $spotifyQuery);
|
||||
|
||||
$stats = [];
|
||||
$savedIds = [];
|
||||
$spotifyPayloads = [];
|
||||
$userPayloads = [];
|
||||
|
||||
if ($criteria->useSpotify() && $spotifyQuery !== '') {
|
||||
$spotifyData = $this->resolveSpotifyAlbums($criteria, $spotifyQuery);
|
||||
$spotifyPayloads = $spotifyData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $spotifyData['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $spotifyData['savedIds']);
|
||||
}
|
||||
|
||||
if ($criteria->useUser() && $hasUserFilters) {
|
||||
$userData = $this->resolveUserAlbums($criteria);
|
||||
$userPayloads = $userData['payloads'];
|
||||
$stats = $this->mergeStats($stats, $userData['stats']);
|
||||
}
|
||||
|
||||
$albums = $this->composeAlbumList($criteria->source, $userPayloads, $spotifyPayloads, $criteria->limit);
|
||||
|
||||
return new AlbumSearchResult($criteria, $albums, $stats, $savedIds);
|
||||
}
|
||||
|
||||
private function buildSpotifyQuery(AlbumSearchCriteria $criteria): string
|
||||
{
|
||||
$parts = [];
|
||||
if ($criteria->albumName !== '') {
|
||||
$parts[] = 'album:' . $criteria->albumName;
|
||||
}
|
||||
if ($criteria->artist !== '') {
|
||||
$parts[] = 'artist:' . $criteria->artist;
|
||||
}
|
||||
if ($criteria->yearFrom !== null || $criteria->yearTo !== null) {
|
||||
if ($criteria->yearFrom !== null && $criteria->yearTo !== null && $criteria->yearTo >= $criteria->yearFrom) {
|
||||
$parts[] = 'year:' . $criteria->yearFrom . '-' . $criteria->yearTo;
|
||||
} else {
|
||||
$year = $criteria->yearFrom ?? $criteria->yearTo;
|
||||
if ($year !== null) {
|
||||
$parts[] = 'year:' . $year;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($criteria->query !== '') {
|
||||
$parts[] = $criteria->query;
|
||||
}
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function hasUserFilters(AlbumSearchCriteria $criteria, string $spotifyQuery): bool
|
||||
{
|
||||
return $spotifyQuery !== ''
|
||||
|| $criteria->albumName !== ''
|
||||
|| $criteria->artist !== ''
|
||||
|| $criteria->yearFrom !== null
|
||||
|| $criteria->yearTo !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function resolveSpotifyAlbums(AlbumSearchCriteria $criteria, string $spotifyQuery): array
|
||||
{
|
||||
$stored = $this->albumRepository->searchSpotifyAlbums(
|
||||
$spotifyQuery,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
$storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $stored);
|
||||
$storedIds = $this->collectSpotifyIds($stored);
|
||||
$stats = $storedIds ? $this->reviewRepository->getAggregatesForAlbumIds($storedIds) : [];
|
||||
$savedIds = $storedIds;
|
||||
|
||||
if (count($stored) >= $criteria->limit) {
|
||||
return [
|
||||
'payloads' => array_slice($storedPayloads, 0, $criteria->limit),
|
||||
'stats' => $stats,
|
||||
'savedIds' => $savedIds,
|
||||
];
|
||||
}
|
||||
|
||||
$apiPayloads = $this->fetchSpotifyPayloads($criteria, $spotifyQuery, $storedPayloads);
|
||||
$payloads = $this->mergePayloadLists($apiPayloads['payloads'], $storedPayloads, $criteria->limit);
|
||||
$stats = $this->mergeStats($stats, $apiPayloads['stats']);
|
||||
$savedIds = $this->mergeSavedIds($savedIds, $apiPayloads['savedIds']);
|
||||
|
||||
return ['payloads' => $payloads, 'stats' => $stats, 'savedIds' => $savedIds];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $storedPayloads
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>,savedIds:array<int,string>}
|
||||
*/
|
||||
private function fetchSpotifyPayloads(AlbumSearchCriteria $criteria, string $spotifyQuery, array $storedPayloads): array
|
||||
{
|
||||
$result = $this->spotify->searchAlbums($spotifyQuery, $criteria->limit);
|
||||
$searchItems = $result['albums']['items'] ?? [];
|
||||
$this->logger->info('Album search results received', [
|
||||
'query' => $spotifyQuery,
|
||||
'items' => is_countable($searchItems) ? count($searchItems) : 0,
|
||||
]);
|
||||
|
||||
if (!$searchItems) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$ids = $this->extractSpotifyIds($searchItems);
|
||||
if ($ids === []) {
|
||||
return ['payloads' => [], 'stats' => [], 'savedIds' => []];
|
||||
}
|
||||
|
||||
$full = $this->spotify->getAlbums($ids);
|
||||
$albumsPayload = is_array($full) ? ($full['albums'] ?? []) : [];
|
||||
if ($albumsPayload === [] && $searchItems !== []) {
|
||||
$albumsPayload = $searchItems;
|
||||
$this->logger->warning('Spotify getAlbums returned empty; falling back to search items', ['count' => count($albumsPayload)]);
|
||||
}
|
||||
|
||||
$upserted = 0;
|
||||
foreach ($albumsPayload as $payload) {
|
||||
$this->albumRepository->upsertFromSpotifyAlbum((array) $payload);
|
||||
$upserted++;
|
||||
}
|
||||
$this->em->flush();
|
||||
$this->logger->info('Albums upserted to DB', ['upserted' => $upserted]);
|
||||
|
||||
$existing = $this->albumRepository->findBySpotifyIdsKeyed($ids);
|
||||
$payloads = [];
|
||||
foreach ($ids as $sid) {
|
||||
if (isset($existing[$sid])) {
|
||||
$payloads[] = $existing[$sid]->toTemplateArray();
|
||||
}
|
||||
}
|
||||
|
||||
$stats = $this->reviewRepository->getAggregatesForAlbumIds($ids);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'stats' => $stats,
|
||||
'savedIds' => array_keys($existing),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{payloads:array<int,array<mixed>>,stats:array<string,array{count:int,avg:float}>}
|
||||
*/
|
||||
private function resolveUserAlbums(AlbumSearchCriteria $criteria): array
|
||||
{
|
||||
$userAlbums = $this->albumRepository->searchUserAlbums(
|
||||
$criteria->query,
|
||||
$criteria->albumName,
|
||||
$criteria->artist,
|
||||
$criteria->yearFrom ?? 0,
|
||||
$criteria->yearTo ?? 0,
|
||||
$criteria->limit
|
||||
);
|
||||
if ($userAlbums === []) {
|
||||
return ['payloads' => [], 'stats' => []];
|
||||
}
|
||||
|
||||
$entityIds = array_values(array_map(static fn(Album $album) => (int) $album->getId(), $userAlbums));
|
||||
$userStats = $this->reviewRepository->getAggregatesForAlbumEntityIds($entityIds);
|
||||
$payloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $userAlbums);
|
||||
|
||||
return [
|
||||
'payloads' => $payloads,
|
||||
'stats' => $this->mapUserStatsToLocalIds($userAlbums, $userStats),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $userAlbums
|
||||
* @param array<int,array{count:int,avg:float}> $userStats
|
||||
* @return array<string,array{count:int,avg:float}>
|
||||
*/
|
||||
private function mapUserStatsToLocalIds(array $userAlbums, array $userStats): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($userAlbums as $album) {
|
||||
$entityId = (int) $album->getId();
|
||||
$localId = (string) $album->getLocalId();
|
||||
if ($localId !== '' && isset($userStats[$entityId])) {
|
||||
$mapped[$localId] = $userStats[$entityId];
|
||||
}
|
||||
}
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function composeAlbumList(string $source, array $userPayloads, array $spotifyPayloads, int $limit): array
|
||||
{
|
||||
if ($source === 'user') {
|
||||
return array_slice($userPayloads, 0, $limit);
|
||||
}
|
||||
if ($source === 'spotify') {
|
||||
return array_slice($spotifyPayloads, 0, $limit);
|
||||
}
|
||||
return array_slice(array_merge($userPayloads, $spotifyPayloads), 0, $limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Album> $albums
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectSpotifyIds(array $albums): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($albums as $album) {
|
||||
$sid = (string) $album->getSpotifyId();
|
||||
if ($sid !== '') {
|
||||
$ids[] = $sid;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,mixed> $searchItems
|
||||
* @return list<string>
|
||||
*/
|
||||
private function extractSpotifyIds(array $searchItems): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($searchItems as $item) {
|
||||
$id = isset($item['id']) ? (string) $item['id'] : '';
|
||||
if ($id !== '') {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function mergeStats(array $current, array $updates): array
|
||||
{
|
||||
foreach ($updates as $key => $value) {
|
||||
$current[$key] = $value;
|
||||
}
|
||||
return $current;
|
||||
}
|
||||
|
||||
private function mergeSavedIds(array $current, array $updates): array
|
||||
{
|
||||
$merged = array_merge($current, array_filter($updates, static fn($id) => $id !== ''));
|
||||
return array_values(array_unique($merged));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,array<mixed>> $primary
|
||||
* @param array<int,array<mixed>> $secondary
|
||||
* @return array<int,array<mixed>>
|
||||
*/
|
||||
private function mergePayloadLists(array $primary, array $secondary, int $limit): array
|
||||
{
|
||||
$seen = [];
|
||||
$merged = [];
|
||||
foreach ($primary as $payload) {
|
||||
$merged[] = $payload;
|
||||
if (isset($payload['id'])) {
|
||||
$seen[$payload['id']] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
foreach ($secondary as $payload) {
|
||||
$id = $payload['id'] ?? null;
|
||||
if ($id !== null && isset($seen[$id])) {
|
||||
continue;
|
||||
}
|
||||
$merged[] = $payload;
|
||||
if ($id !== null) {
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (count($merged) >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return array_slice($merged, 0, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
69
src/Service/RegistrationToggle.php
Normal file
69
src/Service/RegistrationToggle.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Repository\SettingRepository;
|
||||
|
||||
/**
|
||||
* RegistrationToggle centralizes the logic around the registration switch.
|
||||
*/
|
||||
final class RegistrationToggle
|
||||
{
|
||||
private ?bool $envOverride;
|
||||
|
||||
public function __construct(private readonly SettingRepository $settings)
|
||||
{
|
||||
$this->envOverride = $this->detectEnvOverride();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the environment-provided override, or null when unset.
|
||||
*/
|
||||
public function envOverride(): ?bool
|
||||
{
|
||||
return $this->envOverride;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves whether registration should currently be enabled.
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
if ($this->envOverride !== null) {
|
||||
return $this->envOverride;
|
||||
}
|
||||
|
||||
return $this->settings->getValue('REGISTRATION_ENABLED', '1') !== '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a new database-backed toggle value.
|
||||
*/
|
||||
public function persist(bool $enabled): void
|
||||
{
|
||||
$this->settings->setValue('REGISTRATION_ENABLED', $enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
private function detectEnvOverride(): ?bool
|
||||
{
|
||||
$raw = $_ENV['APP_ALLOW_REGISTRATION'] ?? $_SERVER['APP_ALLOW_REGISTRATION'] ?? null;
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
if (is_bool($raw)) {
|
||||
return $raw;
|
||||
}
|
||||
$normalized = strtolower(trim((string) $raw));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
if (in_array($normalized, ['0', 'false', 'off', 'no'], true)) {
|
||||
return false;
|
||||
}
|
||||
if (in_array($normalized, ['1', 'true', 'on', 'yes'], true)) {
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
27
src/Twig/AppSettingsExtension.php
Normal file
27
src/Twig/AppSettingsExtension.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Twig;
|
||||
|
||||
use App\Service\RegistrationToggle;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\Extension\GlobalsInterface;
|
||||
|
||||
/**
|
||||
* Exposes frequently used configuration values to Twig templates.
|
||||
*/
|
||||
class AppSettingsExtension extends AbstractExtension implements GlobalsInterface
|
||||
{
|
||||
public function __construct(private readonly RegistrationToggle $registrationToggle)
|
||||
{
|
||||
}
|
||||
|
||||
public function getGlobals(): array
|
||||
{
|
||||
return [
|
||||
'registration_enabled' => $this->registrationToggle->isEnabled(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
BIN
templates/.DS_Store
vendored
BIN
templates/.DS_Store
vendored
Binary file not shown.
@@ -1,4 +1,5 @@
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
|
||||
{% set registrationEnabled = registration_enabled ?? true %}
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true" data-registration-enabled="{{ registrationEnabled ? '1' : '0' }}">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -24,21 +25,25 @@
|
||||
<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>
|
||||
<button class="btn btn-outline-success" type="button" data-auth-open-register {% if not registrationEnabled %}disabled title="Registration is currently disabled"{% endif %}>Sign up</button>
|
||||
</form>
|
||||
<div class="text-danger small mt-2 d-none" data-auth-login-error></div>
|
||||
<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>
|
||||
{% if registrationEnabled %}
|
||||
<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>
|
||||
{% else %}
|
||||
<div class="alert alert-info mb-0">Registration is currently disabled. Please check back later.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +60,21 @@
|
||||
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'));
|
||||
const registrationEnabled = modalEl.getAttribute('data-registration-enabled') === '1';
|
||||
const registerToggle = modalEl.querySelector('[data-auth-open-register]');
|
||||
if (registerToggle) {
|
||||
registerToggle.addEventListener('click', (e) => {
|
||||
if (!registrationEnabled) {
|
||||
e.preventDefault();
|
||||
if (loginError) {
|
||||
loginError.textContent = 'Registration is currently disabled.';
|
||||
loginError.classList.remove('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
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'); });
|
||||
@@ -89,7 +108,8 @@
|
||||
// AJAX registration
|
||||
const regForm = modalEl.querySelector('form[data-auth-register]');
|
||||
const regError = modalEl.querySelector('[data-auth-register-error]');
|
||||
if (regForm) {
|
||||
|
||||
if (regForm && registrationEnabled) {
|
||||
regForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (regError) { regError.classList.add('d-none'); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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>
|
||||
<a class="navbar-brand fw-bold ps-2" 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>
|
||||
@@ -13,18 +13,25 @@
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if app.user %}
|
||||
<div class="dropdown">
|
||||
{% set avatar = app.user.profileImagePath %}
|
||||
<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>
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Profile picture" class="rounded-circle border" width="28" height="28" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<span class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center" style="width:28px;height:28px;">
|
||||
{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<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') %}
|
||||
{% if is_granted('ROLE_MODERATOR') %}
|
||||
<li><h6 class="dropdown-header">Site</h6></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Site Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Site Settings</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_dashboard') }}">Dashboard</a></li>
|
||||
<li><a class="dropdown-item" href="{{ path('admin_users') }}">User Management</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<li><a class="dropdown-item" href="{{ path('admin_settings') }}">Settings</a></li>
|
||||
{% endif %}
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><h6 class="dropdown-header">User</h6></li>
|
||||
|
||||
@@ -22,41 +22,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary">User Type</div>
|
||||
<div class="display-6">{{ userType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Profile</h2>
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-auto">
|
||||
<a class="card h-100 text-reset text-decoration-none" href="{{ path('account_profile') }}">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border" width="72" height="72" style="object-fit: cover;">
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border" width="64" height="64" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white" style="width:72px;height:72px;">
|
||||
<div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center text-white flex-shrink-0" style="width:64px;height:64px;">
|
||||
<span class="fw-semibold">{{ (displayName ?? email)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="text-secondary">User Type</div>
|
||||
<div class="fw-semibold">{{ displayName ?? email }}</div>
|
||||
<div class="text-secondary small">{{ userType }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Email</label>
|
||||
<input class="form-control" value="{{ email }}" readonly />
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<label class="form-label">Display name</label>
|
||||
<input class="form-control" value="{{ displayName }}" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<a class="btn btn-outline-primary me-2" href="{{ path('account_profile') }}">Edit profile</a>
|
||||
<a class="btn btn-outline-secondary" href="{{ path('account_password') }}">Change password</a>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,41 +6,73 @@
|
||||
<div class="alert alert-success">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<h2 class="h6 mb-3">Current picture</h2>
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border mb-3" width="160" height="160" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center mb-3" style="width:160px;height:160px;">
|
||||
<span class="fs-3">{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}</span>
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Profile details</h2>
|
||||
<div class="text-center mb-3">
|
||||
{% if profileImage %}
|
||||
<img src="{{ profileImage }}" alt="Profile picture" class="rounded-circle border mb-3" width="160" height="160" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-inline-flex align-items-center justify-content-center mb-3" style="width:160px;height:160px;">
|
||||
<span class="fs-3">{{ (app.user.displayName ?? app.user.userIdentifier)|slice(0,1)|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-secondary small mb-0">Images up to 4MB. JPG or PNG recommended.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<p class="text-secondary small mb-0">Images up to 4MB. JPG or PNG recommended.</p>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.profileImage, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.profileImage, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.profileImage) }}
|
||||
</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-0">
|
||||
{{ form_label(form.email, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.email, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.email) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
<div class="mb-3">{{ form_row(form.email) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.displayName) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.profileImage) }}</div>
|
||||
<hr>
|
||||
<p class="text-secondary small mb-3">Password change is optional. Provide your current password only if you want to update it.</p>
|
||||
<div class="mb-3">{{ form_row(form.currentPassword) }}</div>
|
||||
<div class="mb-3">{{ form_row(form.newPassword) }}</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h2 class="h6 mb-3">Password</h2>
|
||||
<p class="text-secondary small">Leave the fields below blank to keep your current password. You'll need to supply your existing password whenever you create a new one.</p>
|
||||
<div class="mb-3">
|
||||
{{ form_label(form.currentPassword, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.currentPassword, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.currentPassword) }}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
{{ form_errors(form.newPassword) }}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
{{ form_label(form.newPassword.first, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.newPassword.first, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.first) }}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form_label(form.newPassword.second, null, {label_attr: {class: 'form-label'}}) }}
|
||||
{{ form_widget(form.newPassword.second, {attr: {class: 'form-control'}}) }}
|
||||
{{ form_errors(form.newPassword.second) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">Save changes</button>
|
||||
<a class="btn btn-link" href="{{ path('account_dashboard') }}">Cancel</a>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -10,24 +10,76 @@
|
||||
<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>
|
||||
<small class="text-secondary d-block mb-3">Theme and accent settings are stored in cookies.</small>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label mb-2" for="accentPicker">Accent colour</label>
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap">
|
||||
<input class="form-control form-control-color" type="color" id="accentPicker" value="#6750a4" title="Choose accent colour">
|
||||
<span class="text-secondary small">Matches the Material-inspired palette across buttons, links, and highlights.</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 THEME_KEY = 'theme';
|
||||
const ACCENT_KEY = 'accentColor';
|
||||
const DEFAULT_ACCENT = '#6750a4';
|
||||
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();
|
||||
const accentPicker = document.getElementById('accentPicker');
|
||||
|
||||
function getCookie(name) {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]+)'));
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
}
|
||||
|
||||
function persist(name, value) {
|
||||
const d = new Date();
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax; expires=' + d.toUTCString();
|
||||
}
|
||||
|
||||
function accentContrast(hex) {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (normalized.length !== 6) {
|
||||
return '#ffffff';
|
||||
}
|
||||
const r = parseInt(normalized.substring(0, 2), 16);
|
||||
const g = parseInt(normalized.substring(2, 4), 16);
|
||||
const b = parseInt(normalized.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r) + (0.587 * g) + (0.114 * b);
|
||||
return luminance > 180 ? '#1c1b20' : '#ffffff';
|
||||
}
|
||||
|
||||
if (toggle) {
|
||||
const initialTheme = getCookie(THEME_KEY) || root.getAttribute('data-bs-theme') || 'light';
|
||||
toggle.checked = initialTheme === 'dark';
|
||||
const setTheme = (theme) => {
|
||||
root.setAttribute('data-bs-theme', theme);
|
||||
persist(THEME_KEY, theme);
|
||||
};
|
||||
setTheme(initialTheme);
|
||||
toggle.addEventListener('change', () => setTheme(toggle.checked ? 'dark' : 'light'));
|
||||
}
|
||||
|
||||
if (accentPicker) {
|
||||
const storedAccent = getCookie(ACCENT_KEY) || DEFAULT_ACCENT;
|
||||
const validAccent = /^#([0-9a-f]{6})$/i.test(storedAccent) ? storedAccent : DEFAULT_ACCENT;
|
||||
accentPicker.value = validAccent;
|
||||
|
||||
const applyAccent = (value) => {
|
||||
const hex = /^#([0-9a-f]{6})$/i.test(value) ? value : DEFAULT_ACCENT;
|
||||
root.style.setProperty('--accent-color', hex);
|
||||
root.style.setProperty('--accent-on-color', accentContrast(hex));
|
||||
persist(ACCENT_KEY, hex);
|
||||
};
|
||||
|
||||
applyAccent(validAccent);
|
||||
accentPicker.addEventListener('input', (event) => applyAccent(event.target.value));
|
||||
}
|
||||
toggle.addEventListener('change', ()=> setTheme(toggle.checked ? 'dark' : 'light'));
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-3">Site Settings</h1>
|
||||
{% for msg in app.flashes('success') %}<div class="alert alert-success">{{ msg }}</div>{% endfor %}
|
||||
{% for msg in app.flashes('info') %}<div class="alert alert-info">{{ msg }}</div>{% endfor %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -15,6 +16,18 @@
|
||||
{{ form_label(form.SPOTIFY_CLIENT_SECRET) }}
|
||||
{{ form_widget(form.SPOTIFY_CLIENT_SECRET, {attr: {class: 'form-control'}}) }}
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
{% set registrationToggleAttrs = {class: 'form-check-input'} %}
|
||||
{% if registrationImmutable %}
|
||||
{% set registrationToggleAttrs = registrationToggleAttrs|merge({disabled: true}) %}
|
||||
{% endif %}
|
||||
{{ form_widget(form.REGISTRATION_ENABLED, {attr: registrationToggleAttrs}) }}
|
||||
{{ form_label(form.REGISTRATION_ENABLED, null, {label_attr: {class: 'form-check-label'}}) }}
|
||||
<div class="form-text">When disabled, public sign-up is blocked but admins can still create users from `/admin/users`.</div>
|
||||
{% if registrationImmutable %}
|
||||
<div class="form-text text-warning">Locked by <code>APP_ALLOW_REGISTRATION</code> ({{ registrationOverrideValue ? 'enabled' : 'disabled' }}).</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-success" type="submit">Save settings</button>
|
||||
</div>
|
||||
|
||||
142
templates/admin/users.html.twig
Normal file
142
templates/admin/users.html.twig
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% block title %}User Management{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1 class="h4 mb-4">User management</h1>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="h6 mb-0">Accounts</h2>
|
||||
<span class="text-secondary small">{{ rows|length }} total</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Roles</th>
|
||||
<th scope="col" class="text-center">Albums</th>
|
||||
<th scope="col" class="text-center">Reviews</th>
|
||||
<th scope="col" class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
{% set user = row.user %}
|
||||
{% set isSelf = app.user and app.user.id == user.id %}
|
||||
{% set isAdminUser = 'ROLE_ADMIN' in user.roles %}
|
||||
{% set canDelete = (not isSelf) and (not isAdminUser) %}
|
||||
{% set isModerator = 'ROLE_MODERATOR' in user.roles %}
|
||||
{% set canPromote = is_granted('ROLE_ADMIN') and not isAdminUser %}
|
||||
{% set promoteReason = '' %}
|
||||
{% if not canPromote %}
|
||||
{% if not is_granted('ROLE_ADMIN') %}
|
||||
{% set promoteReason = 'Only administrators can update roles.' %}
|
||||
{% else %}
|
||||
{% set promoteReason = isModerator ? 'Demote not available.' : 'Promotion not available.' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set deleteReason = '' %}
|
||||
{% if not canDelete %}
|
||||
{% if isSelf %}
|
||||
{% set deleteReason = 'You cannot delete your own account.' %}
|
||||
{% elseif isAdminUser %}
|
||||
{% set deleteReason = 'Administrators cannot be deleted.' %}
|
||||
{% else %}
|
||||
{% set deleteReason = 'Delete not available.' %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="fw-semibold">{{ user.displayName ?? '—' }}</div>
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
<td>
|
||||
{% for role in user.roles %}
|
||||
{% if role == 'ROLE_ADMIN' %}
|
||||
<span class="badge text-bg-danger">Admin</span>
|
||||
{% elseif role == 'ROLE_MODERATOR' %}
|
||||
<span class="badge text-bg-primary">Moderator</span>
|
||||
{% elseif role == 'ROLE_USER' %}
|
||||
<span class="badge text-bg-secondary">User</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="text-center">{{ row.albumCount }}</td>
|
||||
<td class="text-center">{{ row.reviewCount }}</td>
|
||||
<td class="text-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 %}');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('promote-user-' ~ user.id) }}">
|
||||
<span class="d-inline-block" {% if not canPromote %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ promoteReason }}" tabindex="0"{% endif %}>
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit" {% if not canPromote %}disabled aria-disabled="true"{% endif %}>
|
||||
{% if isModerator %}Demote{% else %}Promote{% endif %}
|
||||
</button>
|
||||
</span>
|
||||
</form>
|
||||
<form method="post" action="{{ path('admin_users_delete', {id: user.id}) }}" onsubmit="return confirm('Delete {{ user.email }}? This cannot be undone.');">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('delete-user-' ~ user.id) }}">
|
||||
<span class="d-inline-block" {% if not canDelete %}data-bs-toggle="tooltip" data-bs-placement="top" title="{{ deleteReason }}" tabindex="0"{% endif %}>
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" {% if not canDelete %}disabled aria-disabled="true"{% endif %}>Delete</button>
|
||||
</span>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-secondary py-4">No users found.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const tooltips = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltips.forEach(function (el) {
|
||||
if (!el.getAttribute('data-bs-original-title')) {
|
||||
bootstrap.Tooltip.getOrCreateInstance(el);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -1,46 +1,100 @@
|
||||
{% 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">
|
||||
{% set query_value = query|default('') %}
|
||||
{% set album_value = album|default('') %}
|
||||
{% set artist_value = artist|default('') %}
|
||||
{% set year_from_value = year_from|default('') %}
|
||||
{% set year_to_value = year_to|default('') %}
|
||||
{% set source_value = source|default('all') %}
|
||||
{% set has_search = (query_value is not empty) or (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set advanced_open = (album_value is not empty) or (artist_value is not empty) or (year_from_value is not empty) or (year_to_value is not empty) or (source_value != 'all') %}
|
||||
{% set landing_view = not has_search %}
|
||||
|
||||
{% if landing_view %}
|
||||
<style>
|
||||
.landing-search-form {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.landing-search-input {
|
||||
border-radius: 999px;
|
||||
padding: 1rem 1.5rem;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
|
||||
<h1 class="{{ landing_view ? 'display-6 text-center mb-4' : 'h4 mb-3' }}">Search Albums</h1>
|
||||
<form class="{{ landing_view ? 'landing-search-form mb-4' : 'row g-2 mb-2 align-items-center' }}" action="{{ path('album_search') }}" method="get">
|
||||
{% if landing_view %}
|
||||
<div>
|
||||
<input class="form-control form-control-lg landing-search-input" type="search" name="q" value="{{ query_value }}" placeholder="Search albums, artists..." autocomplete="off" />
|
||||
</div>
|
||||
<div class="d-flex justify-content-center gap-3 mt-3">
|
||||
<button class="btn btn-success px-4" type="submit">Search</button>
|
||||
<button class="btn btn-outline-secondary px-4" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="collapse {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" placeholder="Artist" />
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" 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_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-sm">
|
||||
<input class="form-control" type="search" name="q" value="{{ query }}" placeholder="Search.." autocomplete="off" />
|
||||
<input class="form-control" type="search" name="q" value="{{ query_value }}" placeholder="Search.." autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<button class="btn btn-success" type="submit">Search</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="{{ advanced_open ? 'true' : 'false' }}" aria-controls="advancedSearch">Advanced</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="collapse col-12 {{ advanced_open ? 'show' : '' }}" id="advancedSearch">
|
||||
<div class="row g-2 mt-1">
|
||||
<div class="col-sm-3 order-sm-4">
|
||||
<select class="form-select" name="source">
|
||||
<option value="all" {{ (source is defined and source == 'all') or source is not defined ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ (source is defined and source == 'spotify') ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ (source is defined and source == 'user') ? 'selected' : '' }}>User-created</option>
|
||||
<option value="all" {{ source_value == 'all' ? 'selected' : '' }}>All sources</option>
|
||||
<option value="spotify" {{ source_value == 'spotify' ? 'selected' : '' }}>Spotify</option>
|
||||
<option value="user" {{ source_value == 'user' ? 'selected' : '' }}>User-created</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="album" value="{{ album }}" placeholder="Album title" />
|
||||
<input class="form-control" type="text" name="album" value="{{ album_value }}" placeholder="Album title" />
|
||||
</div>
|
||||
<div class="col-sm-4">
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist }}" placeholder="Artist" />
|
||||
<input class="form-control" type="text" name="artist" value="{{ artist_value }}" 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" />
|
||||
<input class="form-control" type="number" name="year_from" value="{{ year_from_value }}" 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" />
|
||||
<input class="form-control" type="number" name="year_to" value="{{ year_to_value }}" placeholder="Year to" min="1900" max="2100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
@@ -63,12 +117,9 @@
|
||||
<a class="btn btn-outline-success btn-sm" href="{{ album.external_urls.spotify }}" target="_blank" rel="noopener">Open in Spotify</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-success btn-sm" href="{{ path('album_show', {id: album.id}) }}">Reviews</a>
|
||||
{% if album.source is defined and album.source == 'user' %}
|
||||
<span class="badge text-bg-primary ms-2">User album</span>
|
||||
{% endif %}
|
||||
{% if app.user and (album.source is not defined or album.source != 'user') %}
|
||||
{% if savedIds is defined and (album.id in savedIds) %}
|
||||
<span class="badge text-bg-secondary ms-2">Saved</span>
|
||||
{# Saved indicator intentionally omitted to reduce noise #}
|
||||
{% else %}
|
||||
<form class="d-inline ms-2" method="post" action="{{ path('album_save', {id: album.id}) }}">
|
||||
<input type="hidden" name="_token" value="{{ csrf_token('save-album-' ~ album.id) }}">
|
||||
|
||||
@@ -43,12 +43,26 @@
|
||||
</div>
|
||||
<div class="vstack gap-3 mb-4">
|
||||
{% for r in reviews %}
|
||||
{% set avatar = r.author.profileImagePath ?? null %}
|
||||
<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 class="d-flex gap-3">
|
||||
<div>
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Avatar for {{ r.author.displayName ?? r.author.userIdentifier }}" class="rounded-circle border" width="56" height="56" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center" style="width:56px;height:56px;">
|
||||
{{ (r.author.displayName ?? r.author.userIdentifier)|slice(0,1)|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
{% set accent_cookie = app.request.cookies.get('accentColor') %}
|
||||
{% set accent_color = (accent_cookie is defined and accent_cookie and accent_cookie matches '/^#[0-9a-fA-F]{6}$/') ? accent_cookie : '#6750a4' %}
|
||||
<!doctype html>
|
||||
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}">
|
||||
<html lang="en" data-bs-theme="{{ app.request.cookies.get('theme') ?? 'light' }}" style="--accent-color: {{ accent_color }};">
|
||||
<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">
|
||||
<link rel="stylesheet" href="{{ app.request.basePath }}/css/app.css">
|
||||
<script>
|
||||
(function() {
|
||||
const root = document.documentElement;
|
||||
const defaultAccent = '{{ accent_color|e('js') }}';
|
||||
const match = document.cookie.match(/(?:^|; )accentColor=([^;]+)/);
|
||||
const accent = match ? decodeURIComponent(match[1]) : defaultAccent;
|
||||
|
||||
function contrast(hex) {
|
||||
const normalized = hex.replace('#', '');
|
||||
if (normalized.length !== 6) {
|
||||
return '#ffffff';
|
||||
}
|
||||
const r = parseInt(normalized.substring(0, 2), 16);
|
||||
const g = parseInt(normalized.substring(2, 4), 16);
|
||||
const b = parseInt(normalized.substring(4, 6), 16);
|
||||
const luminance = (0.299 * r) + (0.587 * g) + (0.114 * b);
|
||||
return luminance > 180 ? '#1c1b20' : '#ffffff';
|
||||
}
|
||||
|
||||
root.style.setProperty('--accent-color', accent);
|
||||
root.style.setProperty('--accent-on-color', contrast(accent));
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
{% include '_partials/navbar.html.twig' %}
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
{% block title %}{{ review.title }}{% endblock %}
|
||||
{% block body %}
|
||||
<p><a href="{{ path('account_dashboard') }}">← Back to dashboard</a></p>
|
||||
<h1 class="h4">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
{% set avatar = review.author.profileImagePath %}
|
||||
{% if avatar %}
|
||||
<img src="{{ avatar }}" alt="Avatar for {{ review.author.displayName ?? review.author.userIdentifier }}" class="rounded-circle border" width="64" height="64" style="object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="rounded-circle bg-secondary text-white d-flex align-items-center justify-content-center" style="width:64px;height:64px;">
|
||||
{{ (review.author.displayName ?? review.author.userIdentifier)|slice(0,1)|upper }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<h1 class="h4 mb-1">{{ review.title }} <span class="text-secondary">(Rating {{ review.rating }}/10)</span></h1>
|
||||
<div class="text-secondary">
|
||||
by {{ review.author.displayName ?? review.author.userIdentifier }} • {{ review.createdAt|date('Y-m-d H:i') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary">
|
||||
{{ review.album.name }} — {{ review.album.artists|join(', ') }}
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId}) }}">View album</a>
|
||||
<a class="ms-1" href="{{ path('album_show', {id: review.album.spotifyId ?? review.album.localId}) }}">View album</a>
|
||||
</p>
|
||||
<article class="mb-3">
|
||||
<p>{{ review.content|nl2br }}</p>
|
||||
|
||||
Reference in New Issue
Block a user