Compare commits
21 Commits
d8353d45a1
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 391ecf1732 | |||
| 4ae7a44881 | |||
| fa54cb4167 | |||
| f109c933c1 | |||
| d52eb6bd81 | |||
| f77f3a9e40 | |||
| 336dcc4d3a | |||
| 54b1908793 | |||
| dda9ff06b5 | |||
| 796acaa9c0 | |||
| 3879c6c312 | |||
| da9af888c0 | |||
| dae8f3d999 | |||
| 1c98a634c3 | |||
| 054e970df9 | |||
| f15d9a9cfd | |||
| cd04fa5212 | |||
| cd13f1478a | |||
| 0cd77f8b30 | |||
| 6cccc3746d | |||
| 5d7cc1666b |
19
.dockerignore
Normal file
19
.dockerignore
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.DS_Store
|
||||||
|
.cursor
|
||||||
|
.env.local
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.idea
|
||||||
|
.public
|
||||||
|
backup_manifests/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
var/
|
||||||
|
# Symfony cache/logs generated inside the container should not come from the host
|
||||||
|
var/cache/
|
||||||
|
var/log/
|
||||||
|
|
||||||
|
# Uploaded files stay on the host, not baked into images
|
||||||
|
public/uploads/
|
||||||
|
|
||||||
24
.env.example
24
.env.example
@@ -1,16 +1,12 @@
|
|||||||
SPOTIFY_CLIENT_ID=
|
# Uncomment to override stored setting.
|
||||||
SPOTIFY_CLIENT_SECRET=
|
#SPOTIFY_CLIENT_ID=
|
||||||
|
#SPOTIFY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
APP_ENV=prod
|
||||||
APP_SECRET=changeme
|
APP_SECRET=changeme
|
||||||
DEFAULT_URI=http://localhost:8000
|
# APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting
|
||||||
DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
DEFAULT_URI=http://localhost:8085
|
||||||
|
ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by Spotify.
|
||||||
|
|
||||||
# POSTGRES_DB=
|
DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite.
|
||||||
# POSTGRES_USER=
|
# DATABASE_URL=postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8
|
||||||
# POSTGRES_PASSWORD=
|
|
||||||
|
|
||||||
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,20 +1,74 @@
|
|||||||
name: CI - Build Tonehaus Docker image
|
name: CI (Gitea)
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- prod
|
||||||
pull_request:
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- prod
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: tonehaus
|
APP_ENV: test
|
||||||
|
APP_SECRET: ci-secret
|
||||||
|
DATABASE_DRIVER: sqlite
|
||||||
|
DATABASE_SQLITE_PATH: ${{ gitea.workspace }}/var/data/database.test.sqlite
|
||||||
DOCKERFILE: docker/php/Dockerfile
|
DOCKERFILE: docker/php/Dockerfile
|
||||||
BUILD_TARGET: prod
|
BUILD_TARGET: prod
|
||||||
PLATFORMS: linux/amd64,linux/arm64
|
IMAGE_NAME: tonehaus-app
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
php-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||||
|
coverage: none
|
||||||
|
ini-values: memory_limit=512M
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Validate Composer manifest
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Cache Composer downloads
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/composer/files
|
||||||
|
~/.cache/composer/vcs
|
||||||
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
composer-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --prefer-dist --no-interaction --no-progress
|
||||||
|
|
||||||
|
- name: Prepare SQLite database
|
||||||
|
run: |
|
||||||
|
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||||
|
touch "$DATABASE_SQLITE_PATH"
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||||
|
|
||||||
|
- name: Run PHPUnit
|
||||||
|
run: vendor/bin/phpunit --colors=always
|
||||||
|
|
||||||
|
docker-image:
|
||||||
|
needs: php-tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.REGISTRY }}
|
||||||
|
REGISTRY_IMAGE: ${{ secrets.REGISTRY_IMAGE }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -22,44 +76,40 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Compute tags
|
- name: Build prod image (local)
|
||||||
id: meta
|
uses: docker/build-push-action@v6
|
||||||
run: |
|
with:
|
||||||
SHA="${GITHUB_SHA:-${GITEA_SHA:-unknown}}"
|
context: .
|
||||||
SHORT_SHA="${SHA:0:7}"
|
file: ${{ env.DOCKERFILE }}
|
||||||
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
target: ${{ env.BUILD_TARGET }}
|
||||||
|
tags: ${{ env.IMAGE_NAME }}:ci
|
||||||
|
load: true
|
||||||
|
|
||||||
- name: Optional registry login
|
- name: Verify baked APP_ENV
|
||||||
|
run: docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test "$APP_ENV" = "prod"'
|
||||||
|
|
||||||
|
- name: Verify Symfony artifacts exist
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/public/index.php'
|
||||||
|
docker run --rm --entrypoint sh ${{ env.IMAGE_NAME }}:ci -c 'test -f /var/www/html/bin/console'
|
||||||
|
|
||||||
|
- name: Smoke-test entrypoint & migrations
|
||||||
|
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh ${{ env.IMAGE_NAME }}:ci true
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
if: ${{ env.REGISTRY != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||||
env:
|
|
||||||
REGISTRY: ${{ secrets.REGISTRY }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
- name: Build single-arch images for artifacts (no registry)
|
- name: Push prod image
|
||||||
if: ${{ env.REGISTRY == '' }}
|
if: ${{ env.REGISTRY != '' && env.REGISTRY_IMAGE != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '' }}
|
||||||
run: |
|
uses: docker/build-push-action@v6
|
||||||
TAG_SHA=${{ steps.meta.outputs.short_sha }}
|
|
||||||
for P in linux/amd64; do \
|
|
||||||
ARCH=${P#linux/}; \
|
|
||||||
docker buildx build \
|
|
||||||
--platform "$P" \
|
|
||||||
--file "$DOCKERFILE" \
|
|
||||||
--target "$BUILD_TARGET" \
|
|
||||||
--build-arg APP_ENV=prod \
|
|
||||||
--output type=docker \
|
|
||||||
--tag "$IMAGE_NAME:$TAG_SHA-$ARCH" \
|
|
||||||
. ; \
|
|
||||||
docker save "$IMAGE_NAME:$TAG_SHA-$ARCH" -o "tonehaus-image-$ARCH.tar" ; \
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Upload artifacts
|
|
||||||
if: ${{ env.REGISTRY == '' }}
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: tonehaus-images
|
context: .
|
||||||
path: |
|
file: ${{ env.DOCKERFILE }}
|
||||||
tonehaus-image-amd64.tar
|
target: ${{ env.BUILD_TARGET }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:ci
|
||||||
|
${{ env.REGISTRY }}/${{ env.REGISTRY_IMAGE }}:${{ github.sha }}
|
||||||
|
|
||||||
|
|||||||
134
.github/workflows/ci.yml
vendored
Normal file
134
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- prod
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- prod
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_ENV: test
|
||||||
|
APP_SECRET: ci-secret
|
||||||
|
DATABASE_DRIVER: sqlite
|
||||||
|
DATABASE_SQLITE_PATH: ${{ github.workspace }}/var/data/database.test.sqlite
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
php-tests:
|
||||||
|
name: PHPUnit + migrations
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
id: setup-php
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.2'
|
||||||
|
extensions: intl, mbstring, pdo_pgsql, pdo_sqlite, zip, gd
|
||||||
|
coverage: none
|
||||||
|
ini-values: memory_limit=512M
|
||||||
|
tools: composer:v2
|
||||||
|
|
||||||
|
- name: Validate Composer manifest
|
||||||
|
run: composer validate --strict
|
||||||
|
|
||||||
|
- name: Cache Composer downloads
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/composer/files
|
||||||
|
~/.cache/composer/vcs
|
||||||
|
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
composer-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Cache vendor directory
|
||||||
|
id: cache-vendor
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: vendor
|
||||||
|
key: vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
vendor-${{ runner.os }}-${{ steps.setup-php.outputs.php-version }}-
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: composer install --prefer-dist --no-interaction --no-progress
|
||||||
|
env:
|
||||||
|
COMPOSER_NO_INTERACTION: 1
|
||||||
|
COMPOSER_MEMORY_LIMIT: -1
|
||||||
|
|
||||||
|
- name: Prepare SQLite database
|
||||||
|
run: |
|
||||||
|
mkdir -p "$(dirname "$DATABASE_SQLITE_PATH")"
|
||||||
|
touch "$DATABASE_SQLITE_PATH"
|
||||||
|
php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||||
|
|
||||||
|
- name: Run PHPUnit
|
||||||
|
run: vendor/bin/phpunit --colors=always
|
||||||
|
|
||||||
|
docker-image:
|
||||||
|
name: Build production image
|
||||||
|
needs: php-tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/tonehaus-app
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build prod image
|
||||||
|
id: build-prod
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/php/Dockerfile
|
||||||
|
target: prod
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: tonehaus-app:ci
|
||||||
|
|
||||||
|
- name: Verify baked APP_ENV
|
||||||
|
run: docker run --rm --entrypoint sh tonehaus-app:ci -c 'test "$APP_ENV" = "prod"'
|
||||||
|
|
||||||
|
- name: Verify Symfony artifacts exist
|
||||||
|
run: |
|
||||||
|
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/public/index.php'
|
||||||
|
docker run --rm --entrypoint sh tonehaus-app:ci -c 'test -f /var/www/html/bin/console'
|
||||||
|
|
||||||
|
- name: Smoke-test entrypoint & migrations
|
||||||
|
run: docker run --rm -e APP_SECRET=test-secret --entrypoint /entrypoint.sh tonehaus-app:ci true
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Tag latest image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: docker tag tonehaus-app:ci $IMAGE_NAME:latest
|
||||||
|
|
||||||
|
- name: Push latest image
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: docker push $IMAGE_NAME:latest
|
||||||
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@
|
|||||||
!var/cache/.gitkeep
|
!var/cache/.gitkeep
|
||||||
!var/logs/.gitkeep
|
!var/logs/.gitkeep
|
||||||
!var/sessions/.gitkeep
|
!var/sessions/.gitkeep
|
||||||
|
/var/data/
|
||||||
|
|
||||||
# Logs (Symfony4)
|
# Logs (Symfony4)
|
||||||
/var/log/*
|
/var/log/*
|
||||||
|
|||||||
5313
.idea/commandlinetools/Symfony_10_11_2025__13_00.xml
generated
Normal file
5313
.idea/commandlinetools/Symfony_10_11_2025__13_00.xml
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
.idea/commandlinetools/schemas/frameworkDescriptionVersion1.1.4.xsd
generated
Normal file
47
.idea/commandlinetools/schemas/frameworkDescriptionVersion1.1.4.xsd
generated
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified"
|
||||||
|
xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||||
|
<xs:element name="framework" type="frameworkType"/>
|
||||||
|
<xs:complexType name="commandType">
|
||||||
|
<xs:all>
|
||||||
|
<xs:element type="xs:string" name="name" minOccurs="1" maxOccurs="1"/>
|
||||||
|
<xs:element type="xs:string" name="params" minOccurs="0" maxOccurs="1"/>
|
||||||
|
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
||||||
|
<xs:element type="optionsBeforeType" name="optionsBefore" minOccurs="0" maxOccurs="1"/>
|
||||||
|
</xs:all>
|
||||||
|
</xs:complexType>
|
||||||
|
<xs:complexType name="frameworkType">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element type="xs:string" name="extraData" minOccurs="0" maxOccurs="1"/>
|
||||||
|
<xs:element type="commandType" name="command" maxOccurs="unbounded" minOccurs="0"/>
|
||||||
|
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute type="xs:string" name="name" use="required"/>
|
||||||
|
<xs:attribute type="xs:string" name="invoke" use="required"/>
|
||||||
|
<xs:attribute type="xs:string" name="alias" use="required"/>
|
||||||
|
<xs:attribute type="xs:boolean" name="enabled" use="required"/>
|
||||||
|
<xs:attribute type="xs:integer" name="version" use="required"/>
|
||||||
|
<xs:attribute type="xs:string" name="frameworkId" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
<xs:complexType name="optionsBeforeType">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element type="optionType" name="option" maxOccurs="unbounded" minOccurs="0"/>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
<xs:complexType name="optionType">
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element type="xs:string" name="help" minOccurs="0" maxOccurs="1"/>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute type="xs:string" name="name" use="required"/>
|
||||||
|
<xs:attribute type="xs:string" name="shortcut" use="optional"/>
|
||||||
|
<xs:attribute name="pattern" use="optional">
|
||||||
|
<xs:simpleType>
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="space"/>
|
||||||
|
<xs:enumeration value="equals"/>
|
||||||
|
<xs:enumeration value="unknown"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
</xs:attribute>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:schema>
|
||||||
16
.idea/musicratings.iml
generated
16
.idea/musicratings.iml
generated
@@ -18,17 +18,12 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/orm" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/persistence" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/sql-formatter" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/egulias/email-validator" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/masterminds/html5" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/myclabs/deep-copy" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/nikic/php-parser" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/manifest" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phar-io/version" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-code-coverage" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-file-iterator" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/phpunit/php-invoker" />
|
||||||
@@ -39,7 +34,6 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/clock" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/container" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/event-dispatcher" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/psr/log" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/cli-parser" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/code-unit" />
|
||||||
@@ -70,13 +64,11 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dependency-injection" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/deprecation-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dom-crawler" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/dotenv" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/error-handler" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/event-dispatcher-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/filesystem" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/finder" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/flex" />
|
||||||
@@ -87,13 +79,10 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-foundation" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/http-kernel" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/intl" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mailer" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/maker-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/messenger" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/mime" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/notifier" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/options-resolver" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/password-hasher" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-intl-grapheme" />
|
||||||
@@ -114,26 +103,21 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/security-http" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/serializer" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/service-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stimulus-bundle" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/stopwatch" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/string" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/translation-contracts" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bridge" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/twig-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/type-info" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/ux-turbo" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/validator" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-dumper" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/var-exporter" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-link" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/yaml" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/theseer/tokenizer" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/extra-bundle" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/string-extra" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/twig/twig" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/webmozart/assert" />
|
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
16
.idea/php.xml
generated
16
.idea/php.xml
generated
@@ -12,11 +12,9 @@
|
|||||||
</component>
|
</component>
|
||||||
<component name="PhpIncludePathManager">
|
<component name="PhpIncludePathManager">
|
||||||
<include_path>
|
<include_path>
|
||||||
<path value="$PROJECT_DIR$/vendor/phpstan/phpdoc-parser" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
<path value="$PROJECT_DIR$/vendor/twig/twig" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
<path value="$PROJECT_DIR$/vendor/twig/string-extra" />
|
||||||
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
<path value="$PROJECT_DIR$/vendor/twig/extra-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/webmozart/assert" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
<path value="$PROJECT_DIR$/vendor/masterminds/html5" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/type" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/diff" />
|
||||||
@@ -31,11 +29,8 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/recursion-context" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/code-unit-reverse-lookup" />
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/global-state" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-docblock" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/environment" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/type-resolver" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
<path value="$PROJECT_DIR$/vendor/sebastian/object-reflector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpdocumentor/reflection-common" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
<path value="$PROJECT_DIR$/vendor/theseer/tokenizer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
<path value="$PROJECT_DIR$/vendor/nikic/php-parser" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-timer" />
|
||||||
@@ -43,7 +38,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-text-template" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-invoker" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-file-iterator" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/expression-language" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
<path value="$PROJECT_DIR$/vendor/phpunit/php-code-coverage" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
<path value="$PROJECT_DIR$/vendor/symfony/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/deprecation-contracts" />
|
||||||
@@ -79,7 +73,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/persistence" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-foundation" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/sql-formatter" />
|
||||||
<path value="$PROJECT_DIR$/vendor/egulias/email-validator" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/framework-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
<path value="$PROJECT_DIR$/vendor/doctrine/inflector" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
<path value="$PROJECT_DIR$/vendor/symfony/validator" />
|
||||||
@@ -89,10 +82,7 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/translation-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
<path value="$PROJECT_DIR$/vendor/symfony/service-contracts" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
<path value="$PROJECT_DIR$/vendor/symfony/type-info" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/translation" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/stimulus-bundle" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
<path value="$PROJECT_DIR$/vendor/symfony/finder" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-php84" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
<path value="$PROJECT_DIR$/vendor/symfony/intl" />
|
||||||
@@ -106,7 +96,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
<path value="$PROJECT_DIR$/vendor/symfony/dependency-injection" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
<path value="$PROJECT_DIR$/vendor/symfony/config" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
<path value="$PROJECT_DIR$/vendor/symfony/security-http" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mailer" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
<path value="$PROJECT_DIR$/vendor/phar-io/version" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
<path value="$PROJECT_DIR$/vendor/symfony/mime" />
|
||||||
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
<path value="$PROJECT_DIR$/vendor/phar-io/manifest" />
|
||||||
@@ -116,17 +105,13 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-idn" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/web-profiler-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/doctrine-messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
<path value="$PROJECT_DIR$/vendor/symfony/maker-bundle" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
<path value="$PROJECT_DIR$/vendor/symfony/console" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
<path value="$PROJECT_DIR$/vendor/symfony/http-kernel" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
<path value="$PROJECT_DIR$/vendor/symfony/browser-kit" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
<path value="$PROJECT_DIR$/vendor/symfony/yaml" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/notifier" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/ux-turbo" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-intl-icu" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
<path value="$PROJECT_DIR$/vendor/symfony/string" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/messenger" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
<path value="$PROJECT_DIR$/vendor/symfony/twig-bridge" />
|
||||||
<path value="$PROJECT_DIR$/vendor/composer" />
|
<path value="$PROJECT_DIR$/vendor/composer" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
<path value="$PROJECT_DIR$/vendor/symfony/serializer" />
|
||||||
@@ -135,7 +120,6 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
<path value="$PROJECT_DIR$/vendor/symfony/dotenv" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
<path value="$PROJECT_DIR$/vendor/symfony/process" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
<path value="$PROJECT_DIR$/vendor/symfony/polyfill-mbstring" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/link" />
|
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
<path value="$PROJECT_DIR$/vendor/psr/clock" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
<path value="$PROJECT_DIR$/vendor/psr/event-dispatcher" />
|
||||||
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
<path value="$PROJECT_DIR$/vendor/psr/cache" />
|
||||||
|
|||||||
95
README.md
95
README.md
@@ -1,8 +1,8 @@
|
|||||||
# Tonehaus — Music Ratings
|
# Tonehaus — Music Ratings (Symfony 7)
|
||||||
|
|
||||||
Discover albums from Spotify, read and write reviews, and manage your account. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
Discover albums via Spotify, write and manage reviews, and administer your site. Built with Symfony 7, Twig, Doctrine, and Bootstrap.
|
||||||
|
|
||||||
## Quick start
|
## Quick start (Docker Compose)
|
||||||
|
|
||||||
1) Start the stack
|
1) Start the stack
|
||||||
|
|
||||||
@@ -10,65 +10,66 @@ Discover albums from Spotify, read and write reviews, and manage your account. B
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Create the database schema
|
2) Open the app
|
||||||
|
|
||||||
|
- App URL: `http://localhost:8085`
|
||||||
|
- Health: `http://localhost:8085/healthz`
|
||||||
|
|
||||||
|
3) Create your first admin
|
||||||
|
|
||||||
|
- Sign Up through Tonehaus
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3) Promote an admin (to access Site Settings)
|
4) Configure Spotify
|
||||||
|
|
||||||
|
- Go to `http://localhost:8085/admin/settings` and enter your Spotify Client ID/Secret, or
|
||||||
|
- Set env vars in `.env`: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
|
||||||
|
5) (Optional) Seed demo data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
```
|
```
|
||||||
|
|
||||||
4) Configure Spotify API credentials (admin only)
|
Notes:
|
||||||
|
- The packaged image uses SQLite by default and runs Doctrine migrations on start (idempotent).
|
||||||
- Open `http://localhost:8000/admin/settings` and enter your Spotify Client ID/Secret.
|
- To switch to Postgres, set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`.
|
||||||
- Alternatively, set env vars for the PHP container: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`.
|
|
||||||
|
|
||||||
5) Visit `http://localhost:8000` to search for albums.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Spotify search with Advanced filters (album, artist, year range) and per-album aggregates (avg/count)
|
- Spotify search with advanced filters (album, artist, year range) and per‑album aggregates (avg/count)
|
||||||
- Album page with details, reviews list, and inline new review (logged in)
|
- Album page with details, tracklist, reviews list, and inline new review (logged-in)
|
||||||
- Auth modal (Login/Sign up) with remember-me cookie, no separate pages
|
- Auth modal (Login/Sign up) with remember‑me; no separate pages
|
||||||
- Role-based access: authors manage their own reviews, admins can manage any
|
- Role-based access: authors manage their own reviews; moderators/admins can moderate content
|
||||||
- Admin Site Settings to manage Spotify credentials in DB
|
- Admin Site Settings: manage Spotify credentials and public registration toggle
|
||||||
- User Dashboard to update profile and change password (requires current password)
|
- User Dashboard: profile updates and password change
|
||||||
- Light/Dark theme toggle in Settings (cookie-backed)
|
- Light/Dark theme toggle (cookie-backed)
|
||||||
- Bootstrap UI
|
|
||||||
|
|
||||||
## Rate limiting & caching
|
## Documentation
|
||||||
|
|
||||||
- Server-side Client Credentials; access tokens are cached.
|
- Setup and configuration: `docs/setup.md`
|
||||||
- Requests pass through a throttle and 429 Retry-After backoff. GET responses are cached.
|
- Feature overview: `docs/features.md`
|
||||||
- Tunables (optional):
|
- Authentication and users: `docs/auth-and-users.md`
|
||||||
|
- Spotify integration: `docs/spotify-integration.md`
|
||||||
|
- Reviews and albums: `docs/reviews-and-albums.md`
|
||||||
|
- Admin & site settings: `docs/admin-and-settings.md`
|
||||||
|
- Troubleshooting: `docs/troubleshooting.md`
|
||||||
|
- Architecture: `docs/architecture.md`
|
||||||
|
- Deployment: `docs/deployment.md`
|
||||||
|
|
||||||
```bash
|
## Environment overview
|
||||||
# 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
|
- `APP_ENV` (dev|prod), `APP_SECRET` (required)
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
See `/docs` for how-tos and deeper notes:
|
- `APP_ALLOW_REGISTRATION` (1|0) — DB setting can be overridden by env
|
||||||
|
- `DATABASE_DRIVER` (sqlite|postgres), `DATABASE_URL` (when using Postgres)
|
||||||
- Setup and configuration: `docs/01-setup.md`
|
- `DATABASE_SQLITE_PATH` (optional, defaults to `var/data/database.sqlite`)
|
||||||
- Features and UX: `docs/02-features.md`
|
- `RUN_MIGRATIONS_ON_START` (1|0, defaults to 1)
|
||||||
- Authentication and users: `docs/03-auth-and-users.md`
|
|
||||||
- Spotify integration: `docs/04-spotify-integration.md`
|
|
||||||
- Reviews and albums: `docs/05-reviews-and-albums.md`
|
|
||||||
- Admin & site settings: `docs/06-admin-and-settings.md`
|
|
||||||
- Rate limits & caching: `docs/07-rate-limits-and-caching.md`
|
|
||||||
- Troubleshooting: `docs/08-troubleshooting.md`
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
5
assets/app.js
Normal file
5
assets/app.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Placeholder front-end entrypoint to satisfy importmap & asset mapper.
|
||||||
|
// You can add real JS behavior here; for now we only register a noop.
|
||||||
|
|
||||||
|
console.debug('Tonehaus assets initialized');
|
||||||
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"name": "tonehaus/tonehaus",
|
||||||
|
"description": "Tonehaus — discover albums, manage reviews, and administer site settings with a Symfony 7 stack.",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"license": "proprietary",
|
"license": "proprietary",
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
@@ -11,14 +13,10 @@
|
|||||||
"doctrine/doctrine-bundle": "^2.18",
|
"doctrine/doctrine-bundle": "^2.18",
|
||||||
"doctrine/doctrine-migrations-bundle": "^3.5",
|
"doctrine/doctrine-migrations-bundle": "^3.5",
|
||||||
"doctrine/orm": "^3.5",
|
"doctrine/orm": "^3.5",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
|
||||||
"symfony/asset": "7.3.*",
|
"symfony/asset": "7.3.*",
|
||||||
"symfony/asset-mapper": "7.3.*",
|
"symfony/asset-mapper": "7.3.*",
|
||||||
"symfony/console": "7.3.*",
|
"symfony/console": "7.3.*",
|
||||||
"symfony/doctrine-messenger": "7.3.*",
|
|
||||||
"symfony/dotenv": "7.3.*",
|
"symfony/dotenv": "7.3.*",
|
||||||
"symfony/expression-language": "7.3.*",
|
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/form": "7.3.*",
|
"symfony/form": "7.3.*",
|
||||||
"symfony/framework-bundle": "7.3.*",
|
"symfony/framework-bundle": "7.3.*",
|
||||||
@@ -33,10 +31,8 @@
|
|||||||
"symfony/security-bundle": "7.3.*",
|
"symfony/security-bundle": "7.3.*",
|
||||||
"symfony/serializer": "7.3.*",
|
"symfony/serializer": "7.3.*",
|
||||||
"symfony/string": "7.3.*",
|
"symfony/string": "7.3.*",
|
||||||
"symfony/translation": "7.3.*",
|
|
||||||
"symfony/twig-bundle": "7.3.*",
|
"symfony/twig-bundle": "7.3.*",
|
||||||
"symfony/validator": "7.3.*",
|
"symfony/validator": "7.3.*",
|
||||||
"symfony/web-link": "7.3.*",
|
|
||||||
"symfony/yaml": "7.3.*",
|
"symfony/yaml": "7.3.*",
|
||||||
"twig/extra-bundle": "^2.12|^3.0",
|
"twig/extra-bundle": "^2.12|^3.0",
|
||||||
"twig/string-extra": "^3.22",
|
"twig/string-extra": "^3.22",
|
||||||
@@ -99,6 +95,7 @@
|
|||||||
"symfony/css-selector": "7.3.*",
|
"symfony/css-selector": "7.3.*",
|
||||||
"symfony/debug-bundle": "7.3.*",
|
"symfony/debug-bundle": "7.3.*",
|
||||||
"symfony/maker-bundle": "^1.64",
|
"symfony/maker-bundle": "^1.64",
|
||||||
|
"symfony/stopwatch": "7.3.*",
|
||||||
"symfony/web-profiler-bundle": "7.3.*"
|
"symfony/web-profiler-bundle": "7.3.*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "project",
|
|
||||||
"license": "proprietary",
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"prefer-stable": true,
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.2",
|
|
||||||
"ext-ctype": "*",
|
|
||||||
"ext-iconv": "*",
|
|
||||||
"doctrine/dbal": "^3",
|
|
||||||
"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.*",
|
|
||||||
"symfony/http-client": "7.3.*",
|
|
||||||
"symfony/intl": "7.3.*",
|
|
||||||
"symfony/mailer": "7.3.*",
|
|
||||||
"symfony/mime": "7.3.*",
|
|
||||||
"symfony/monolog-bundle": "^3.0",
|
|
||||||
"symfony/notifier": "7.3.*",
|
|
||||||
"symfony/process": "7.3.*",
|
|
||||||
"symfony/property-access": "7.3.*",
|
|
||||||
"symfony/property-info": "7.3.*",
|
|
||||||
"symfony/runtime": "7.3.*",
|
|
||||||
"symfony/security-bundle": "7.3.*",
|
|
||||||
"symfony/serializer": "7.3.*",
|
|
||||||
"symfony/stimulus-bundle": "^2.31",
|
|
||||||
"symfony/string": "7.3.*",
|
|
||||||
"symfony/translation": "7.3.*",
|
|
||||||
"symfony/twig-bundle": "7.3.*",
|
|
||||||
"symfony/ux-turbo": "^2.31",
|
|
||||||
"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",
|
|
||||||
"twig/twig": "^2.12|^3.0"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true,
|
|
||||||
"symfony/flex": true,
|
|
||||||
"symfony/runtime": true
|
|
||||||
},
|
|
||||||
"bump-after-update": true,
|
|
||||||
"sort-packages": true
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"replace": {
|
|
||||||
"symfony/polyfill-ctype": "*",
|
|
||||||
"symfony/polyfill-iconv": "*",
|
|
||||||
"symfony/polyfill-php72": "*",
|
|
||||||
"symfony/polyfill-php73": "*",
|
|
||||||
"symfony/polyfill-php74": "*",
|
|
||||||
"symfony/polyfill-php80": "*",
|
|
||||||
"symfony/polyfill-php81": "*",
|
|
||||||
"symfony/polyfill-php82": "*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"auto-scripts": {
|
|
||||||
"cache:clear": "symfony-cmd",
|
|
||||||
"assets:install %PUBLIC_DIR%": "symfony-cmd",
|
|
||||||
"importmap:install": "symfony-cmd"
|
|
||||||
},
|
|
||||||
"post-install-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/symfony": "*"
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"symfony": {
|
|
||||||
"allow-contrib": false,
|
|
||||||
"require": "7.3.*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"phpunit/phpunit": "^11.5",
|
|
||||||
"symfony/browser-kit": "7.3.*",
|
|
||||||
"symfony/css-selector": "7.3.*",
|
|
||||||
"symfony/debug-bundle": "7.3.*",
|
|
||||||
"symfony/maker-bundle": "^1.64",
|
|
||||||
"symfony/stopwatch": "7.3.*",
|
|
||||||
"symfony/web-profiler-bundle": "7.3.*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ return [
|
|||||||
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true],
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
|
|
||||||
Symfony\UX\Turbo\TurboBundle::class => ['all' => true],
|
|
||||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ framework:
|
|||||||
token_id: submit
|
token_id: submit
|
||||||
|
|
||||||
csrf_protection:
|
csrf_protection:
|
||||||
|
check_header: true
|
||||||
stateless_token_ids:
|
stateless_token_ids:
|
||||||
- submit
|
- submit
|
||||||
- authenticate
|
- authenticate
|
||||||
|
|||||||
84
config/packages/doctrine.php
Normal file
84
config/packages/doctrine.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamic Doctrine DBAL configuration.
|
||||||
|
*
|
||||||
|
* This file complements `config/packages/doctrine.yaml`, not replacing it!:
|
||||||
|
* - YAML handles ORM mappings, naming strategy, caches, and env-specific tweaks.
|
||||||
|
* - This PHP config focuses on DBAL and runtime driver selection.
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - Chooses the database driver from `DATABASE_DRIVER` (`postgres` or `sqlite`).
|
||||||
|
* - For Postgres:
|
||||||
|
* - Uses `DATABASE_URL` (e.g. `postgresql://user:pass@host:5432/dbname`).
|
||||||
|
* - Pins `serverVersion` (currently `16`) to avoid auto-detection issues.
|
||||||
|
* - For SQLite:
|
||||||
|
* - Uses `DATABASE_SQLITE_PATH` when provided.
|
||||||
|
* - Otherwise, defaults to `<projectDir>/var/data/database.sqlite`, creating the
|
||||||
|
* directory and file if they do not already exist. (Recommended)
|
||||||
|
*
|
||||||
|
* This split keeps the mapping/caching config in YAML while allowing
|
||||||
|
* DBAL to adapt between Docker/postgres and local sqlite setups.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
|
use Symfony\Config\DoctrineConfig;
|
||||||
|
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
|
||||||
|
|
||||||
|
return static function (DoctrineConfig $doctrine): void {
|
||||||
|
// Normalize DATABASE_DRIVER and validate allowed values up front.
|
||||||
|
$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)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure the default DBAL connection.
|
||||||
|
$dbal = $doctrine->dbal();
|
||||||
|
$dbal->defaultConnection('default');
|
||||||
|
|
||||||
|
$connection = $dbal->connection('default');
|
||||||
|
$connection->profilingCollectBacktrace(param('kernel.debug'));
|
||||||
|
$connection->useSavepoints(true);
|
||||||
|
|
||||||
|
if ('sqlite' === $driver) {
|
||||||
|
// SQLite: use a file-backed database by default.
|
||||||
|
$connection->driver('pdo_sqlite');
|
||||||
|
|
||||||
|
$hasCustomPath = array_key_exists('DATABASE_SQLITE_PATH', $_ENV)
|
||||||
|
|| array_key_exists('DATABASE_SQLITE_PATH', $_SERVER);
|
||||||
|
|
||||||
|
if ($hasCustomPath) {
|
||||||
|
// Allow explicit database path via env overrides.
|
||||||
|
$connection->path('%env(resolve:DATABASE_SQLITE_PATH)%');
|
||||||
|
} else {
|
||||||
|
$projectDir = dirname(__DIR__, 2);
|
||||||
|
$databasePath = sprintf('%s/var/data/database.sqlite', $projectDir);
|
||||||
|
$databaseDir = dirname($databasePath);
|
||||||
|
|
||||||
|
$filesystem = new Filesystem();
|
||||||
|
if (!$filesystem->exists($databaseDir)) {
|
||||||
|
$filesystem->mkdir($databaseDir, 0o775);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$filesystem->exists($databasePath)) {
|
||||||
|
$filesystem->touch($databasePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$connection->path('%kernel.project_dir%/var/data/database.sqlite');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Postgres (or other server-based driver) via DATABASE_URL.
|
||||||
|
$connection->url('%env(resolve:DATABASE_URL)%');
|
||||||
|
// Keep the server version explicit so Doctrine does not need network calls to detect it.
|
||||||
|
$connection->serverVersion('16');
|
||||||
|
$connection->charset('utf8');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
doctrine:
|
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:
|
orm:
|
||||||
auto_generate_proxy_classes: true
|
auto_generate_proxy_classes: true
|
||||||
enable_lazy_ghost_objects: true
|
enable_lazy_ghost_objects: true
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||||
framework:
|
framework:
|
||||||
secret: '%env(APP_SECRET)%'
|
secret: '%env(APP_SECRET)%'
|
||||||
|
trusted_proxies: '%env(default::TRUSTED_PROXIES)%'
|
||||||
|
trusted_headers: ['x-forwarded-for','x-forwarded-proto','x-forwarded-port','x-forwarded-host','x-forwarded-prefix']
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
session: true
|
session: true
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
framework:
|
|
||||||
mailer:
|
|
||||||
dsn: '%env(MAILER_DSN)%'
|
|
||||||
@@ -1,30 +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:
|
|
||||||
Symfony\Component\Mailer\Messenger\SendEmailMessage: async
|
|
||||||
Symfony\Component\Notifier\Message\ChatMessage: async
|
|
||||||
Symfony\Component\Notifier\Message\SmsMessage: async
|
|
||||||
|
|
||||||
# Route your messages to the transports
|
|
||||||
# 'App\Message\YourMessage': async
|
|
||||||
@@ -42,7 +42,7 @@ when@prod:
|
|||||||
handlers:
|
handlers:
|
||||||
main:
|
main:
|
||||||
type: fingers_crossed
|
type: fingers_crossed
|
||||||
action_level: error
|
action_level: info
|
||||||
handler: nested
|
handler: nested
|
||||||
excluded_http_codes: [404, 405]
|
excluded_http_codes: [404, 405]
|
||||||
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
buffer_size: 50 # How many messages should be saved? Prevent memory leaks
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
framework:
|
|
||||||
notifier:
|
|
||||||
chatter_transports:
|
|
||||||
texter_transports:
|
|
||||||
channel_policy:
|
|
||||||
# use chat/slack, chat/telegram, sms/twilio or sms/nexmo
|
|
||||||
urgent: ['email']
|
|
||||||
high: ['email']
|
|
||||||
medium: ['email']
|
|
||||||
low: ['email']
|
|
||||||
admin_recipients:
|
|
||||||
- { email: admin@example.com }
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
security:
|
security:
|
||||||
|
role_hierarchy:
|
||||||
|
ROLE_ADMIN: ['ROLE_MODERATOR']
|
||||||
|
ROLE_MODERATOR: ['ROLE_USER']
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||||
password_hashers:
|
password_hashers:
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||||
@@ -17,11 +20,11 @@ security:
|
|||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
|
||||||
form_login:
|
form_login:
|
||||||
login_path: album_search
|
login_path: app_login
|
||||||
check_path: app_login
|
check_path: app_login
|
||||||
enable_csrf: true
|
enable_csrf: true
|
||||||
default_target_path: album_search
|
default_target_path: album_search
|
||||||
failure_path: album_search
|
failure_path: app_login
|
||||||
username_parameter: _username
|
username_parameter: _username
|
||||||
password_parameter: _password
|
password_parameter: _password
|
||||||
csrf_parameter: _csrf_token
|
csrf_parameter: _csrf_token
|
||||||
@@ -45,8 +48,9 @@ security:
|
|||||||
# Easy way to control access for large sections of your site
|
# Easy way to control access for large sections of your site
|
||||||
# Note: Only the *first* access control that matches will be used
|
# Note: Only the *first* access control that matches will be used
|
||||||
access_control:
|
access_control:
|
||||||
# - { path: ^/admin, roles: ROLE_ADMIN }
|
- { path: ^/admin/settings, roles: ROLE_ADMIN }
|
||||||
# - { path: ^/profile, roles: ROLE_USER }
|
- { path: ^/admin/users, roles: ROLE_MODERATOR }
|
||||||
|
- { path: ^/admin/dashboard, roles: ROLE_MODERATOR }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
framework:
|
|
||||||
default_locale: en
|
|
||||||
translator:
|
|
||||||
default_path: '%kernel.project_dir%/translations'
|
|
||||||
providers:
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Enable stateless CSRF protection for forms and logins/logouts
|
|
||||||
framework:
|
|
||||||
csrf_protection:
|
|
||||||
check_header: true
|
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||||
parameters:
|
parameters:
|
||||||
|
album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# default configuration for services in *this* file
|
# default configuration for services in *this* file
|
||||||
@@ -21,5 +22,14 @@ services:
|
|||||||
|
|
||||||
App\Service\SpotifyClient:
|
App\Service\SpotifyClient:
|
||||||
arguments:
|
arguments:
|
||||||
$clientId: '%env(SPOTIFY_CLIENT_ID)%'
|
$clientId: '%env(default::SPOTIFY_CLIENT_ID)%'
|
||||||
$clientSecret: '%env(SPOTIFY_CLIENT_SECRET)%'
|
$clientSecret: '%env(default::SPOTIFY_CLIENT_SECRET)%'
|
||||||
|
|
||||||
|
App\Service\UploadStorage:
|
||||||
|
arguments:
|
||||||
|
$storageRoot: '%kernel.project_dir%/public/uploads'
|
||||||
|
$publicPrefix: '/uploads'
|
||||||
|
|
||||||
|
App\Controller\AlbumController:
|
||||||
|
arguments:
|
||||||
|
$searchLimit: '%album_search_limit%'
|
||||||
|
|||||||
@@ -1,98 +1,21 @@
|
|||||||
## Docker Compose stack for Symfony app
|
|
||||||
## - php: PHP-FPM container that runs Symfony and Composer
|
|
||||||
## - nginx: Frontend web server proxying PHP to php-fpm
|
|
||||||
## - db: Postgres database for Doctrine
|
|
||||||
## - pgadmin: Optional UI to inspect the database
|
|
||||||
services:
|
services:
|
||||||
php:
|
tonehaus:
|
||||||
# Build multi-stage image defined in docker/php/Dockerfile
|
image: git.ntbx.io/boris/tonehaus:latest-arm64
|
||||||
build:
|
container_name: tonehaus
|
||||||
context: .
|
|
||||||
dockerfile: docker/php/Dockerfile
|
|
||||||
target: dev # change to "prod" for production build
|
|
||||||
args:
|
|
||||||
- APP_ENV=dev
|
|
||||||
container_name: app-php
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
|
||||||
# Symfony Messenger (dev-safe default so CLI commands don't fail)
|
|
||||||
MESSENGER_TRANSPORT_DSN: ${MESSENGER_TRANSPORT_DSN:-sync://}
|
|
||||||
# Doctrine DATABASE_URL consumed by Symfony/Doctrine
|
|
||||||
DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-symfony}:${POSTGRES_PASSWORD:-symfony}@db:5432/${POSTGRES_DB:-symfony}?serverVersion=16&charset=utf8}
|
|
||||||
volumes:
|
volumes:
|
||||||
# Mount only source and config; vendors are installed in-container
|
- uploads:/var/www/html/public/uploads
|
||||||
- ./bin:/var/www/html/bin
|
- sqlite_data:/var/www/html/var/data
|
||||||
- ./config:/var/www/html/config
|
|
||||||
- ./migrations:/var/www/html/migrations
|
|
||||||
- ./public:/var/www/html/public
|
|
||||||
- ./templates:/var/www/html/templates
|
|
||||||
- ./src:/var/www/html/src
|
|
||||||
- ./var:/var/www/html/var
|
|
||||||
- ./.env:/var/www/html/.env:ro
|
|
||||||
- ./vendor:/var/www/html/vendor
|
|
||||||
# Keep composer manifests on host for version control
|
|
||||||
- ./composer.json:/var/www/html/composer.json
|
|
||||||
- ./composer.lock:/var/www/html/composer.lock
|
|
||||||
- ./symfony.lock:/var/www/html/symfony.lock
|
|
||||||
# Speed up composer installs by caching download artifacts
|
|
||||||
- composer_cache:/tmp/composer
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "php -v || exit 1"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
container_name: nginx
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:80"
|
- "8085:80"
|
||||||
volumes:
|
env_file:
|
||||||
# Serve built assets and front controller from Symfony public dir
|
- .env
|
||||||
- ./public:/var/www/html/public
|
|
||||||
# Custom vhost with PHP FastCGI proxy
|
|
||||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
|
||||||
depends_on:
|
|
||||||
- php
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
|
test: ["CMD", "curl", "-f", "http://localhost:80/healthz"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-symfony}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-symfony}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-symfony}
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
volumes:
|
|
||||||
- db_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-symfony} -d ${POSTGRES_DB:-symfony}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
pgadmin:
|
|
||||||
image: dpage/pgadmin4
|
|
||||||
container_name: pgadmin
|
|
||||||
environment:
|
|
||||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-admin@example.com}
|
|
||||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-password}
|
|
||||||
ports:
|
|
||||||
- "8081:80"
|
|
||||||
volumes:
|
|
||||||
- pgadmin_data:/var/lib/pgadmin
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db_data:
|
sqlite_data:
|
||||||
composer_cache:
|
uploads:
|
||||||
pgadmin_data:
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost; # host header (not used locally)
|
server_name _; # host header (not used locally)
|
||||||
root /var/www/html/public; # Symfony's public/ dir (front controller)
|
root /var/www/html/public; # Symfony's public/ dir (front controller)
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -11,7 +11,7 @@ server {
|
|||||||
location ~ \.php$ {
|
location ~ \.php$ {
|
||||||
# Forward PHP requests to php-fpm service
|
# Forward PHP requests to php-fpm service
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_pass php:9000;
|
fastcgi_pass tonehaus:9000;
|
||||||
# Use resolved path to avoid path traversal issues
|
# Use resolved path to avoid path traversal issues
|
||||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||||
|
|||||||
@@ -1,52 +1,47 @@
|
|||||||
# FROM php:8.2-fpm
|
|
||||||
|
|
||||||
# # Install dependencies
|
|
||||||
# RUN apt-get update && apt-get install -y \
|
|
||||||
# git \
|
|
||||||
# unzip \
|
|
||||||
# libzip-dev \
|
|
||||||
# libpng-dev \
|
|
||||||
# libjpeg-dev \
|
|
||||||
# libonig-dev \
|
|
||||||
# libxml2-dev \
|
|
||||||
# zip \
|
|
||||||
# && docker-php-ext-install pdo pdo_mysql zip gd mbstring exif pcntl bcmath intl opcache
|
|
||||||
|
|
||||||
# # Install Composer
|
|
||||||
# COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
|
||||||
|
|
||||||
# # Copy PHP config
|
|
||||||
# COPY docker/php/php.ini /usr/local/etc/php/
|
|
||||||
|
|
||||||
# WORKDIR /var/www/html
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Base PHP-FPM with Composer + Symfony-friendly extensions
|
# Base PHP-FPM with Composer + Symfony-friendly extensions
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM php:8.2-fpm-alpine AS base
|
FROM php:8.2-fpm-alpine AS base
|
||||||
WORKDIR /var/www/html
|
|
||||||
|
|
||||||
# System dependencies
|
ARG APP_ENV=dev
|
||||||
RUN apk add --no-cache \
|
ENV APP_ENV=${APP_ENV}
|
||||||
bash git unzip icu-dev libpng-dev libjpeg-turbo-dev libwebp-dev \
|
|
||||||
libzip-dev oniguruma-dev libxml2-dev postgresql-dev zlib-dev
|
|
||||||
|
|
||||||
# PHP extensions commonly used by Symfony
|
WORKDIR /var/www/html
|
||||||
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
|
|
||||||
|
# System dependencies shared across images
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash \
|
||||||
|
git \
|
||||||
|
unzip \
|
||||||
|
icu-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libjpeg-turbo-dev \
|
||||||
|
libwebp-dev \
|
||||||
|
libzip-dev \
|
||||||
|
oniguruma-dev \
|
||||||
|
libxml2-dev \
|
||||||
|
postgresql-dev \
|
||||||
|
sqlite-dev \
|
||||||
|
zlib-dev \
|
||||||
|
su-exec
|
||||||
|
|
||||||
|
# PHP extensions commonly used by Symfony (plus both Postgres + SQLite)
|
||||||
|
RUN docker-php-ext-configure gd --with-jpeg --with-webp \
|
||||||
&& docker-php-ext-install -j"$(nproc)" \
|
&& docker-php-ext-install -j"$(nproc)" \
|
||||||
intl \
|
intl \
|
||||||
gd \
|
gd \
|
||||||
pdo_pgsql \
|
pdo_pgsql \
|
||||||
|
pdo_sqlite \
|
||||||
opcache \
|
opcache \
|
||||||
mbstring \
|
mbstring \
|
||||||
zip \
|
zip \
|
||||||
xml
|
xml
|
||||||
|
|
||||||
# Composer available in the running container (dev + prod)
|
# Composer available in every stage (dev + prod)
|
||||||
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2.7 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
# Recommended PHP settings (tweak as needed)
|
# Recommended PHP settings (tweak as needed)
|
||||||
RUN { \
|
RUN { \
|
||||||
echo "memory_limit=512M"; \
|
echo "memory_limit=512M"; \
|
||||||
echo "upload_max_filesize=50M"; \
|
echo "upload_max_filesize=50M"; \
|
||||||
echo "post_max_size=50M"; \
|
echo "post_max_size=50M"; \
|
||||||
@@ -63,56 +58,79 @@
|
|||||||
echo "opcache.jit_buffer_size=128M"; \
|
echo "opcache.jit_buffer_size=128M"; \
|
||||||
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
} > /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||||
|
|
||||||
# Small healthcheck file for Nginx
|
# Small healthcheck file for HTTP probes
|
||||||
RUN mkdir -p public && printf "OK" > public/healthz
|
RUN mkdir -p public && printf "OK" > public/healthz
|
||||||
|
|
||||||
# Ensure correct user
|
# Ensure unprivileged app user exists
|
||||||
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app
|
RUN addgroup -g 1000 app && adduser -D -G app -u 1000 app \
|
||||||
# php-fpm uses www-data; keep both available
|
&& chown -R www-data:www-data /var/www
|
||||||
RUN chown -R www-data:www-data /var/www
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Development image (mount your code via docker-compose volumes)
|
# Development image (mount your code via docker-compose volumes)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
FROM base AS dev
|
FROM base AS dev
|
||||||
ENV APP_ENV=dev
|
ARG APP_ENV=dev
|
||||||
# Optional: enable Xdebug (uncomment to use)
|
ENV APP_ENV=${APP_ENV}
|
||||||
# RUN apk add --no-cache $PHPIZE_DEPS \
|
ENV APP_DEBUG=1
|
||||||
# && pecl install xdebug \
|
|
||||||
# && docker-php-ext-enable xdebug \
|
|
||||||
# && { \
|
|
||||||
# echo "xdebug.mode=debug,develop"; \
|
|
||||||
# echo "xdebug.client_host=host.docker.internal"; \
|
|
||||||
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
|
||||||
# Composer cache directory (faster installs inside container)
|
|
||||||
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
|
||||||
CMD ["php-fpm"]
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# Optional: enable Xdebug by uncommenting below
|
||||||
# Production image (copies your app + installs deps + warms cache)
|
# RUN apk add --no-cache $PHPIZE_DEPS \
|
||||||
# -----------------------------------------------------------------------------
|
# && pecl install xdebug \
|
||||||
FROM base AS prod
|
# && docker-php-ext-enable xdebug \
|
||||||
ENV APP_ENV=prod
|
# && { \
|
||||||
# Copy only manifests first (better layer caching); ignore if missing
|
# echo "xdebug.mode=debug,develop"; \
|
||||||
COPY composer.json composer.lock* symfony.lock* ./
|
# echo "xdebug.client_host=host.docker.internal"; \
|
||||||
# Install vendors (no scripts here; run later with console if needed)
|
# } > /usr/local/etc/php/conf.d/xdebug.ini
|
||||||
RUN --mount=type=cache,target=/tmp/composer \
|
|
||||||
|
ENV COMPOSER_CACHE_DIR=/tmp/composer
|
||||||
|
CMD ["php-fpm"]
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Production image (copies your app + installs deps + warms cache)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
FROM base AS prod
|
||||||
|
ARG APP_ENV=prod
|
||||||
|
ENV APP_ENV=${APP_ENV}
|
||||||
|
ENV APP_DEBUG=0 \
|
||||||
|
DATABASE_DRIVER=sqlite \
|
||||||
|
DATABASE_SQLITE_PATH=/var/www/html/var/data/database.sqlite \
|
||||||
|
RUN_MIGRATIONS_ON_START=1
|
||||||
|
|
||||||
|
# Copy only composer manifests for layer caching
|
||||||
|
COPY composer.json composer.lock* symfony.lock* ./
|
||||||
|
|
||||||
|
# Install vendors (cached)
|
||||||
|
RUN --mount=type=cache,target=/tmp/composer \
|
||||||
if [ -f composer.json ]; then \
|
if [ -f composer.json ]; then \
|
||||||
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy the rest of the app
|
# Copy the rest of the app
|
||||||
COPY . /var/www/html
|
COPY . /var/www/html
|
||||||
|
|
||||||
# If Symfony console exists, finalize install & warm cache
|
# Finalize install & warm cache
|
||||||
RUN if [ -f bin/console ]; then \
|
RUN if [ -f bin/console ]; then \
|
||||||
set -ex; \
|
set -ex; \
|
||||||
composer dump-autoload --no-dev --optimize; \
|
composer dump-autoload --no-dev --optimize; \
|
||||||
php bin/console cache:clear --no-warmup; \
|
mkdir -p var var/data public/uploads; \
|
||||||
php bin/console cache:warmup; \
|
chown -R www-data:www-data var public/uploads; \
|
||||||
mkdir -p var && chown -R www-data:www-data var; \
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
USER www-data
|
# Runtime web stack (nginx + supervisor) for a single immutable container
|
||||||
CMD ["php-fpm"]
|
RUN apk add --no-cache nginx supervisor curl
|
||||||
|
|
||||||
|
COPY docker/prod/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
|
COPY docker/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||||
|
COPY docker/prod/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
RUN chmod +x /entrypoint.sh \
|
||||||
|
&& mkdir -p /run/nginx /var/log/supervisor \
|
||||||
|
&& chown -R www-data:www-data /run/nginx /var/log/supervisor /var/www/html
|
||||||
|
RUN mkdir -p /var/lib/nginx /var/log/nginx && chown -R www-data:www-data /var/lib/nginx /var/log/nginx
|
||||||
|
RUN sed -i 's|^error_log =.*|error_log = /proc/self/fd/2|' /usr/local/etc/php-fpm.conf
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||||
|
|
||||||
51
docker/prod/entrypoint.sh
Executable file
51
docker/prod/entrypoint.sh
Executable file
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
require_app_secret() {
|
||||||
|
if [ -z "${APP_SECRET:-}" ]; then
|
||||||
|
echo "APP_SECRET environment variable is required at runtime" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_runtime() {
|
||||||
|
if [ -f vendor/autoload_runtime.php ] && [ "${FORCE_COMPOSER_INSTALL:-0}" != "1" ]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing Composer dependencies..."
|
||||||
|
su-exec www-data composer install \
|
||||||
|
--no-dev \
|
||||||
|
--prefer-dist \
|
||||||
|
--no-interaction \
|
||||||
|
--no-progress
|
||||||
|
}
|
||||||
|
|
||||||
|
install_runtime
|
||||||
|
|
||||||
|
if [ -f bin/console ]; then
|
||||||
|
require_app_secret
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${RUN_MIGRATIONS_ON_START:-1}" = "1" ] && [ -f bin/console ]; then
|
||||||
|
if [ "${DATABASE_DRIVER:-sqlite}" = "sqlite" ]; then
|
||||||
|
SQLITE_PATH="${DATABASE_SQLITE_PATH:-/var/www/html/var/data/database.sqlite}"
|
||||||
|
SQLITE_DIR=$(dirname "${SQLITE_PATH}")
|
||||||
|
mkdir -p "${SQLITE_DIR}"
|
||||||
|
if [ ! -f "${SQLITE_PATH}" ]; then
|
||||||
|
touch "${SQLITE_PATH}"
|
||||||
|
fi
|
||||||
|
chown -R www-data:www-data "${SQLITE_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
su-exec www-data php bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f bin/console ]; then
|
||||||
|
su-exec www-data php bin/console cache:clear --no-warmup
|
||||||
|
su-exec www-data php bin/console cache:warmup
|
||||||
|
chown -R www-data:www-data var
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
|
|
||||||
29
docker/prod/nginx.conf
Normal file
29
docker/prod/nginx.conf
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/html/public;
|
||||||
|
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri /index.php$is_args$args;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
|
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /healthz {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
}
|
||||||
|
|
||||||
22
docker/prod/supervisord.conf
Normal file
22
docker/prod/supervisord.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
logfile=/var/log/supervisor/supervisord.log
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
user=root
|
||||||
|
|
||||||
|
[program:php-fpm]
|
||||||
|
command=/usr/local/sbin/php-fpm --nodaemonize
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=/usr/sbin/nginx -g "daemon off;"
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Setup
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
- Docker + Docker Compose
|
|
||||||
- Spotify Developer account (for a Client ID/Secret)
|
|
||||||
|
|
||||||
## Start services
|
|
||||||
```bash
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console doctrine:database:create --if-not-exists
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:diff --no-interaction
|
|
||||||
docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction
|
|
||||||
```
|
|
||||||
|
|
||||||
## Admin user
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console app:promote-admin you@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Spotify credentials
|
|
||||||
- Prefer admin UI: open `/admin/settings` and enter Client ID/Secret.
|
|
||||||
- Fallback to env vars:
|
|
||||||
```bash
|
|
||||||
export SPOTIFY_CLIENT_ID=your_client_id
|
|
||||||
export SPOTIFY_CLIENT_SECRET=your_client_secret
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Features
|
|
||||||
|
|
||||||
- Spotify album search with Advanced filters (album, artist, year range)
|
|
||||||
- Album page with details, list of reviews, and inline new review
|
|
||||||
- Review rating slider (1–10) with live badge
|
|
||||||
- Per-album aggregates: average rating and total review count
|
|
||||||
- Auth modal (Login/Sign up) with remember-me cookie
|
|
||||||
- Role-based access (author vs admin)
|
|
||||||
- Admin Site Settings to manage Spotify credentials
|
|
||||||
- User Dashboard for profile changes (email, display name, password)
|
|
||||||
- Light/Dark theme toggle (cookie-backed)
|
|
||||||
- Bootstrap UI
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Authentication & Users
|
|
||||||
|
|
||||||
## Modal auth
|
|
||||||
- Login and registration happen in a Bootstrap modal.
|
|
||||||
- AJAX submits keep users on the same page; state updates after reload.
|
|
||||||
- Remember-me cookie keeps users logged in across sessions.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
- `ROLE_USER`: default for registered users.
|
|
||||||
- `ROLE_ADMIN`: promoted via console `app:promote-admin`.
|
|
||||||
|
|
||||||
## Password changes
|
|
||||||
- On `/dashboard`, users can change email/display name.
|
|
||||||
- To set a new password, the current password must be provided.
|
|
||||||
|
|
||||||
## Logout
|
|
||||||
- `/logout` (link in user menu).
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Spotify Integration
|
|
||||||
|
|
||||||
## Credentials
|
|
||||||
- Prefer configuring via `/admin/settings` (stored in DB).
|
|
||||||
- Fallback to environment variables `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET`.
|
|
||||||
|
|
||||||
## API client
|
|
||||||
- `src/Service/SpotifyClient.php`
|
|
||||||
- Client Credentials token fetch (cached)
|
|
||||||
- `searchAlbums(q, limit)`
|
|
||||||
- `getAlbum(id)` and `getAlbums([ids])`
|
|
||||||
- Centralized request pipeline: throttling, 429 backoff, response caching
|
|
||||||
|
|
||||||
## Advanced search
|
|
||||||
- The search page builds Spotify fielded queries:
|
|
||||||
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
|
||||||
- Optional free-text added to the query
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# Reviews & Albums
|
|
||||||
|
|
||||||
## Album page
|
|
||||||
- Shows album artwork, metadata, average rating and review count.
|
|
||||||
- Lists reviews newest-first.
|
|
||||||
- Logged-in users can submit a review inline.
|
|
||||||
|
|
||||||
## Permissions
|
|
||||||
- Anyone can view.
|
|
||||||
- Authors can edit/delete their own reviews.
|
|
||||||
- Admins can edit/delete any review.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
- Rating uses a slider (1–10) with ticks; badge shows current value.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Admin & Settings
|
|
||||||
|
|
||||||
## Site settings (ROLE_ADMIN)
|
|
||||||
- URL: `/admin/settings`
|
|
||||||
- Manage Spotify credentials stored in DB.
|
|
||||||
|
|
||||||
## User management
|
|
||||||
- Promote an admin:
|
|
||||||
```bash
|
|
||||||
docker compose exec php php bin/console app:promote-admin user@example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Appearance
|
|
||||||
- `/settings` provides a dark/light mode toggle.
|
|
||||||
- Preference saved in a cookie; applied via `data-bs-theme`.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# Troubleshooting
|
|
||||||
|
|
||||||
## Cannot find template or routes
|
|
||||||
- Clear cache: `docker compose exec php php bin/console cache:clear`
|
|
||||||
- List routes: `docker compose exec php php bin/console debug:router`
|
|
||||||
|
|
||||||
## Missing vendors
|
|
||||||
- Install: `docker compose exec php composer install --no-interaction --prefer-dist`
|
|
||||||
|
|
||||||
## .env not read in container
|
|
||||||
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
|
||||||
|
|
||||||
## Login modal shows blank
|
|
||||||
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
|
||||||
|
|
||||||
## Rate limits / 429
|
|
||||||
- Client backs off using `Retry-After`. Reduce concurrent requests; increase window env vars if needed.
|
|
||||||
|
|
||||||
|
|
||||||
48
docs/admin-and-settings.md
Normal file
48
docs/admin-and-settings.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# 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`
|
||||||
|
- 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.
|
||||||
|
- Preference saved in a cookie; applied via `data-bs-theme`.
|
||||||
|
|
||||||
|
## Useful tips
|
||||||
|
- Registration toggle can be locked by environment (`APP_ALLOW_REGISTRATION`), in which case the UI explains that the value is immutable.
|
||||||
|
- Changing Spotify credentials in settings is effective immediately; no restart is required.
|
||||||
|
- Admin UI actions are CSRF‑protected and role‑checked; if a button appears disabled, hover for a tooltip explanation.
|
||||||
90
docs/architecture.md
Normal file
90
docs/architecture.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This project follows a conventional Symfony architecture with clear separation of concerns across controllers, entities, repositories, services, security, forms, and templates.
|
||||||
|
|
||||||
|
## Naming & reusability standards (PHP)
|
||||||
|
|
||||||
|
- **Classes**
|
||||||
|
- **Controllers** end with `Controller` (e.g. `AlbumController`) and expose HTTP‑oriented actions with verb‑based method names (`search`, `show`, `edit`, `delete`).
|
||||||
|
- **Services** are named by capability, not by caller, using nouns or noun‑phrases (e.g. `AlbumSearchService`, `ConsoleCommandRunner`, `RegistrationToggle`). When a service is tightly scoped to a third‑party, the integration appears in the name (e.g. `SpotifyClient`, `SpotifyMetadataRefresher`).
|
||||||
|
- **Entities** are singular domain nouns (`Album`, `Review`, `User`) and avoid transport or UI details.
|
||||||
|
- **Commands** describe what they do and the environment they are meant for (e.g. `SeedDemoUsersCommand`, `PromoteAdminCommand`).
|
||||||
|
|
||||||
|
- **Methods**
|
||||||
|
- Use **verb‑based, intention‑revealing names** that describe *what* the method does, not *how* it is used (e.g. `refreshAllSpotifyAlbums()`, `resetCatalog()`, `runConsoleCommand()`, `isEnabled()`, `findAlbumByPublicId()`).
|
||||||
|
- Accessors start with `get*`, `set*`, `is*` / `has*` for booleans (e.g. `getEnvOverride()`, `isSpotifyConfigured()`).
|
||||||
|
- Avoid ambiguous names like `run()`, `handle()`, or `process()` without a clear domain object; prefer `runConsoleCommand()`, `handleAlbumCoverUpload()`, etc.
|
||||||
|
|
||||||
|
- **Variables & parameters**
|
||||||
|
- Use **descriptive, domain‑level names** (e.g. `$albumRepository`, `$reviewCount`, `$spotifyAlbumPayload`) and avoid unclear abbreviations (`$em` is acceptable for `EntityManagerInterface` in local scope, but prefer full names for properties).
|
||||||
|
- Booleans read naturally (`$isEnabled`, `$shouldQuerySpotify`, `$needsSync`).
|
||||||
|
- Collections are pluralized (`$albums`, `$userReviews`, `$spotifyIds`).
|
||||||
|
|
||||||
|
- **Files & namespaces**
|
||||||
|
- File names match their primary class name and follow PSR‑4 (e.g. `src/Service/AlbumSearchService.php` for `App\Service\AlbumSearchService`).
|
||||||
|
- Helper classes that are not tied to HTTP or persistence live under `src/Service` or `src/Dto` with names that describe the abstraction, not the caller.
|
||||||
|
|
||||||
|
These conventions should be followed for all new PHP code and when refactoring existing classes to keep the codebase reusable and self‑documenting.
|
||||||
|
|
||||||
|
## High-level flow
|
||||||
|
1. Visitors search for albums (Spotify) and view an album page
|
||||||
|
2. Logged‑in users can write, edit, and delete reviews
|
||||||
|
3. Moderators and admins can moderate content and manage users
|
||||||
|
4. Admins configure site settings (Spotify credentials, registration toggle)
|
||||||
|
|
||||||
|
## Layers & components
|
||||||
|
|
||||||
|
### Controllers (`src/Controller/*`)
|
||||||
|
- `AlbumController` — search, album detail, inline review creation
|
||||||
|
- `ReviewController` — view, edit, and delete reviews
|
||||||
|
- `AccountController` — profile, password, and user settings pages
|
||||||
|
- `Admin/*` — site dashboard, user management, and settings
|
||||||
|
- `RegistrationController`, `SecurityController` — sign‑up and login/logout routes
|
||||||
|
|
||||||
|
### Entities (`src/Entity/*`)
|
||||||
|
- `User` — authentication principal and roles
|
||||||
|
- `Album`, `AlbumTrack` — normalized album metadata and track list
|
||||||
|
- `Review` — user‑authored review with rating and timestamps
|
||||||
|
- `Setting` — key/value store for site configuration (e.g., Spotify credentials)
|
||||||
|
|
||||||
|
### Repositories (`src/Repository/*`)
|
||||||
|
- Doctrine repositories for querying by domain (albums, tracks, reviews, settings, users)
|
||||||
|
|
||||||
|
### Forms (`src/Form/*`)
|
||||||
|
- `RegistrationFormType`, `ReviewType`, `ChangePasswordFormType`, `ProfileFormType`, `SiteSettingsType`, etc.
|
||||||
|
- Leverage Symfony validation constraints for robust server‑side validation
|
||||||
|
|
||||||
|
### Services (`src/Service/*`)
|
||||||
|
- `SpotifyClient` — Client Credentials token management (cached) and API calls
|
||||||
|
- `SpotifyMetadataRefresher`, `SpotifyGenreResolver` — helpers for richer album data
|
||||||
|
- `CatalogResetService` — admin action to reset/sync catalog state safely
|
||||||
|
- `ImageStorage` — avatar uploads and related image handling
|
||||||
|
- `RegistrationToggle` — DB‑backed registration flag with env override
|
||||||
|
|
||||||
|
### Security (`config/packages/security.yaml`, `src/Security/*`)
|
||||||
|
- Role hierarchy: `ROLE_ADMIN` ⊇ `ROLE_MODERATOR` ⊇ `ROLE_USER`
|
||||||
|
- `ReviewVoter` — edit/delete permissions for review owners and privileged roles
|
||||||
|
- Access control for `/admin/*` enforced via routes and controllers
|
||||||
|
|
||||||
|
### Views (`templates/*`)
|
||||||
|
- Twig templates for pages and partials (`base.html.twig`, `album/*`, `review/*`, `account/*`, `admin/*`)
|
||||||
|
- Auth modal in `templates/_partials/auth_modal.html.twig`
|
||||||
|
- Navbar with role‑aware links in `templates/_partials/navbar.html.twig`
|
||||||
|
|
||||||
|
### DTOs (`src/Dto/*`)
|
||||||
|
- Simple data transfer objects for admin tables and search results
|
||||||
|
|
||||||
|
## Data & persistence
|
||||||
|
- SQLite by default for local/packaged deployments; Postgres supported via `DATABASE_URL`
|
||||||
|
- Migrations run on startup by default (`RUN_MIGRATIONS_ON_START=1`)
|
||||||
|
|
||||||
|
## Error handling & UX
|
||||||
|
- 404 for missing albums
|
||||||
|
- Flash messages for success/error on actions
|
||||||
|
- Disabled/tooltip states in admin UI for protected actions (e.g., cannot delete an admin)
|
||||||
|
|
||||||
|
## Testing & tooling
|
||||||
|
- PHPUnit setup in `composer.json` (`phpunit/phpunit`), BrowserKit & CSS Selector for functional coverage
|
||||||
|
- Web Profiler enabled in dev
|
||||||
|
|
||||||
|
|
||||||
48
docs/auth-and-users.md
Normal file
48
docs/auth-and-users.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Authentication & Users
|
||||||
|
|
||||||
|
## Login & Registration (modal)
|
||||||
|
- Login and sign‑up are handled in a Bootstrap modal.
|
||||||
|
- AJAX submits keep users on the page; a successful login refreshes state.
|
||||||
|
- Remember‑me cookie keeps users logged in across sessions.
|
||||||
|
|
||||||
|
## Roles & Permissions
|
||||||
|
- `ROLE_USER` — default for registered users
|
||||||
|
- `ROLE_MODERATOR` — can access dashboard and user management, and moderate content
|
||||||
|
- `ROLE_ADMIN` — adds Site Settings access and moderator promotion/demotion
|
||||||
|
|
||||||
|
Promotion (from your host):
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-admin admin@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Access flow
|
||||||
|
- Visiting `/admin/*` while unauthenticated redirects through `/login`, which reopens the modal.
|
||||||
|
- Role hierarchy applies: Admin ⊇ Moderator ⊇ User.
|
||||||
|
- Controllers, templates, and voters enforce privilege boundaries (e.g., site settings are admin‑only).
|
||||||
|
|
||||||
|
## Public registration toggle
|
||||||
|
- Toggle in UI: `/admin/settings` (stored in DB)
|
||||||
|
- Env override: `APP_ALLOW_REGISTRATION=0|1` (env has priority on each boot)
|
||||||
|
- When disabled, the modal replaces “Sign up” with a tooltip explaining registration is closed. Staff can still create users via `/admin/users`.
|
||||||
|
|
||||||
|
## User management (moderator+)
|
||||||
|
- `/admin/users` lists accounts with album/review counts and actions:
|
||||||
|
- Create accounts inline (does not affect the current session)
|
||||||
|
- Delete users (guards prevent deleting self or administrators)
|
||||||
|
- Admins can Promote/Demote Moderator on non‑admins
|
||||||
|
|
||||||
|
## Profiles & Passwords
|
||||||
|
- `/account/profile`: update email and display name
|
||||||
|
- `/account/password`: change password (requires current password)
|
||||||
|
|
||||||
|
## Demo accounts & avatars
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-user-avatars --overwrite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logout
|
||||||
|
- Link in the user menu calls `/logout` (handled by Symfony security).
|
||||||
|
|
||||||
|
|
||||||
70
docs/deployment.md
Normal file
70
docs/deployment.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Deployment
|
||||||
|
|
||||||
|
This application ships with an immutable, single‑container image that includes PHP‑FPM, Nginx, and your code. By default it uses SQLite and auto‑runs migrations on start.
|
||||||
|
|
||||||
|
## Build (locally)
|
||||||
|
```bash
|
||||||
|
docker build \
|
||||||
|
--target=prod \
|
||||||
|
-t tonehaus-app:latest \
|
||||||
|
-f docker/php/Dockerfile \
|
||||||
|
.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name tonehaus \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e APP_ENV=prod \
|
||||||
|
-e APP_SECRET=change_me \
|
||||||
|
-e SPOTIFY_CLIENT_ID=your_client_id \
|
||||||
|
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
|
||||||
|
tonehaus-app:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Health endpoint: `GET /healthz` (e.g., `curl http://localhost:8080/healthz`)
|
||||||
|
- Migrations: `RUN_MIGRATIONS_ON_START=1` by default (safe to re‑run)
|
||||||
|
- Cache warmup is executed on boot; `APP_SECRET` is required
|
||||||
|
|
||||||
|
## Persistence options
|
||||||
|
### SQLite (default)
|
||||||
|
- Data file at `var/data/database.sqlite`
|
||||||
|
- Use a volume for durability:
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
-v tonehaus_sqlite:/var/www/html/var/data \
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Postgres
|
||||||
|
Provide `DATABASE_DRIVER=postgres` and a `DATABASE_URL`, e.g.:
|
||||||
|
```
|
||||||
|
postgresql://user:password@host:5432/dbname?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
You can disable automatic migrations with `RUN_MIGRATIONS_ON_START=0` and run them manually:
|
||||||
|
```bash
|
||||||
|
docker exec tonehaus php bin/console doctrine:migrations:migrate --no-interaction
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
- `APP_ENV` (`prod` recommended in production)
|
||||||
|
- `APP_SECRET` (required; random string)
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
- `APP_ALLOW_REGISTRATION` (env override for public registration)
|
||||||
|
- `DATABASE_DRIVER` (`sqlite` default, or `postgres`)
|
||||||
|
- `DATABASE_URL` (when using Postgres)
|
||||||
|
- `DATABASE_SQLITE_PATH` (optional)
|
||||||
|
- `RUN_MIGRATIONS_ON_START` (default `1`)
|
||||||
|
|
||||||
|
## Reverse proxy / TLS
|
||||||
|
- Place behind your ingress/proxy (e.g., Nginx, Traefik, or a cloud load balancer)
|
||||||
|
- Terminate TLS at the proxy and forward to the container’s port 8080
|
||||||
|
- Ensure proxy sends `X-Forwarded-*` headers
|
||||||
|
|
||||||
|
## Zero‑downtime tips
|
||||||
|
- Build then run a new container alongside the old one, switch traffic at the proxy
|
||||||
|
- Keep SQLite on a named volume, or use Postgres for shared state across replicas
|
||||||
|
|
||||||
|
|
||||||
31
docs/features.md
Normal file
31
docs/features.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Features
|
||||||
|
|
||||||
|
## Albums & Reviews
|
||||||
|
- Spotify album search with advanced filters (album, artist, year range)
|
||||||
|
- Album page: cover art, metadata, full tracklist (when available)
|
||||||
|
- Reviews list (newest first) and inline new review form (logged-in)
|
||||||
|
- Rating slider (1–10) with live badge
|
||||||
|
- Per‑album aggregates: average rating and total review count
|
||||||
|
|
||||||
|
## Authentication & Users
|
||||||
|
- Bootstrap auth modal for login/sign-up with AJAX submits
|
||||||
|
- Remember‑me cookie keeps users signed in
|
||||||
|
- Roles: User, Moderator, Admin (see `docs/auth-and-users.md`)
|
||||||
|
- Profile: update email, display name, and password (requires current password)
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
- Dashboard: latest reviews/albums and key counts (moderator+)
|
||||||
|
- Users: create/delete users, promote/demote moderators (admin constraints)
|
||||||
|
- Settings: manage Spotify credentials, toggle public registration (admin)
|
||||||
|
|
||||||
|
## Design & UX
|
||||||
|
- Responsive Bootstrap UI
|
||||||
|
- Light/Dark theme toggle (cookie-backed)
|
||||||
|
- CSRF protection on forms
|
||||||
|
- Access control via role hierarchy and security voters
|
||||||
|
|
||||||
|
## Screenshots (placeholders)
|
||||||
|
- Search page — `docs/img/search.png` (optional)
|
||||||
|
- Album page — `docs/img/album.png` (optional)
|
||||||
|
- Admin dashboard — `docs/img/admin-dashboard.png` (optional)
|
||||||
|
|
||||||
31
docs/reviews-and-albums.md
Normal file
31
docs/reviews-and-albums.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Reviews & Albums
|
||||||
|
|
||||||
|
## Album page
|
||||||
|
- Artwork, metadata, average rating, and review count
|
||||||
|
- Full Spotify tracklist when available
|
||||||
|
- Reviews list (newest first)
|
||||||
|
- Inline new review form for logged‑in users
|
||||||
|
|
||||||
|
## Writing a review
|
||||||
|
- Rating slider from 1–10
|
||||||
|
- Title (max 160 chars) and body (20–5000 chars)
|
||||||
|
- Server-side validation provides inline errors on failure
|
||||||
|
- Successful submissions persist, flash a success message, and reload the album page
|
||||||
|
|
||||||
|
## Editing & deleting reviews
|
||||||
|
- Authors can edit/delete their own reviews
|
||||||
|
- Moderators/Admins can edit/delete any review
|
||||||
|
- CSRF protection is required for deletion
|
||||||
|
|
||||||
|
## Aggregates
|
||||||
|
- The album page computes:
|
||||||
|
- Total number of reviews for the album
|
||||||
|
- Average rating rounded to one decimal
|
||||||
|
|
||||||
|
## Demo data
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
|
```
|
||||||
|
- Use `--only-empty` to focus on albums that currently have no reviews.
|
||||||
|
|
||||||
63
docs/setup.md
Normal file
63
docs/setup.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Spotify Developer account (Client ID/Secret)
|
||||||
|
- A unique `APP_SECRET` value in your environment (for prod builds)
|
||||||
|
|
||||||
|
## 1) Start the stack
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
App: `http://localhost:8085`
|
||||||
|
Health: `http://localhost:8085/healthz`
|
||||||
|
|
||||||
|
## 2) Create an admin
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-admin you@example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Configure Spotify
|
||||||
|
- Preferred: open `/admin/settings` and enter your Client ID/Secret (stored in DB)
|
||||||
|
- Env fallback (in `.env` or your shell):
|
||||||
|
```bash
|
||||||
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
|
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) (Optional) Seed demo data
|
||||||
|
```bash
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-users --count=50
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-albums --count=40 --attach-users
|
||||||
|
docker compose exec tonehaus php bin/console app:seed-demo-reviews --cover-percent=70 --max-per-album=8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database drivers
|
||||||
|
- SQLite (default): set `DATABASE_DRIVER=sqlite` (default) — data stored at `var/data/database.sqlite`
|
||||||
|
- Postgres: set `DATABASE_DRIVER=postgres` and provide `DATABASE_URL`
|
||||||
|
- If you enable the commented `db` service in `docker-compose.yml`, a typical URL is:
|
||||||
|
```
|
||||||
|
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
- `APP_ENV=dev|prod`
|
||||||
|
- `APP_SECRET=<random_string>`
|
||||||
|
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
- `APP_ALLOW_REGISTRATION=1|0` (env can override DB setting)
|
||||||
|
- `DATABASE_DRIVER=sqlite|postgres`
|
||||||
|
- `DATABASE_SQLITE_PATH` (optional)
|
||||||
|
- `RUN_MIGRATIONS_ON_START=1|0` (default 1)
|
||||||
|
|
||||||
|
## Useful commands
|
||||||
|
```bash
|
||||||
|
# Symfony cache
|
||||||
|
docker compose exec tonehaus php bin/console cache:clear
|
||||||
|
|
||||||
|
# Inspect routes
|
||||||
|
docker compose exec tonehaus php bin/console debug:router
|
||||||
|
|
||||||
|
# Promote moderator
|
||||||
|
docker compose exec tonehaus php bin/console app:promote-moderator mod@example.com
|
||||||
|
```
|
||||||
30
docs/spotify-integration.md
Normal file
30
docs/spotify-integration.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Spotify Integration
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
- Preferred: Manage in `/admin/settings` (persisted in DB; no restart required)
|
||||||
|
- Env fallback: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## API Client
|
||||||
|
- `src/Service/SpotifyClient.php`
|
||||||
|
- Client Credentials token fetch with caching
|
||||||
|
- `searchAlbums(q, limit)` — album search endpoint
|
||||||
|
- `getAlbum(id)` / `getAlbums([ids])` — metadata fetch
|
||||||
|
- `getAlbumWithTracks(id)` — metadata + hydrated tracklist
|
||||||
|
- `getAlbumTracks(id)` — raw paginated tracks (when needed)
|
||||||
|
|
||||||
|
### Caching & Rate Limits
|
||||||
|
- Access tokens are cached until expiry to avoid unnecessary auth calls.
|
||||||
|
- Downstream requests should be mindful of Spotify rate limits; user actions are debounced in the UI and server calls are focused on album/track data needed by the current page.
|
||||||
|
|
||||||
|
## Advanced search syntax
|
||||||
|
- Fielded queries are composed as:
|
||||||
|
- `album:"..."`, `artist:"..."`, `year:YYYY` or `year:YYYY-YYYY`
|
||||||
|
- Optional free text is appended to the query
|
||||||
|
- Examples:
|
||||||
|
- `album:"in rainbows" artist:"radiohead"`
|
||||||
|
- `year:1999-2004 post rock`
|
||||||
|
|
||||||
|
## Admin settings
|
||||||
|
- Update credentials in `/admin/settings`
|
||||||
|
- Settings are stored in the database; `APP_ENV` reload or container restart is not required
|
||||||
|
|
||||||
46
docs/troubleshooting.md
Normal file
46
docs/troubleshooting.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## Cannot find template or routes
|
||||||
|
- Clear cache: `docker compose exec tonehaus php bin/console cache:clear`
|
||||||
|
- List routes: `docker compose exec tonehaus php bin/console debug:router`
|
||||||
|
|
||||||
|
## Missing vendors
|
||||||
|
- Install: `docker compose exec tonehaus composer install --no-interaction --prefer-dist`
|
||||||
|
|
||||||
|
## .env not read in container
|
||||||
|
- Ensure we mount `.env` or set env vars in compose; we mount `.env` in `docker-compose.yml`.
|
||||||
|
|
||||||
|
## Login modal shows blank
|
||||||
|
- Make sure Bootstrap JS loads before the modal script (handled in `base.html.twig`).
|
||||||
|
|
||||||
|
## 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 `admin-and-settings.md` to grant roles.
|
||||||
|
|
||||||
|
## SQLite file permissions
|
||||||
|
- The default SQLite path is `var/data/database.sqlite`.
|
||||||
|
- If migrations fail at startup: ensure the `sqlite_data` volume is attached and the path is writable by the container user.
|
||||||
|
|
||||||
|
## Postgres connection issues
|
||||||
|
- If you enable the `db` service in `docker-compose.yml`, verify `DATABASE_URL` matches the service name and credentials.
|
||||||
|
- Example URL:
|
||||||
|
```
|
||||||
|
postgresql://symfony:symfony@db:5432/symfony?serverVersion=16&charset=utf8
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spotify errors
|
||||||
|
- Verify credentials in `/admin/settings` or env vars `SPOTIFY_CLIENT_ID` / `SPOTIFY_CLIENT_SECRET`.
|
||||||
|
- Client Credentials tokens are cached; if revoked, wait for expiry or restart the container.
|
||||||
|
|
||||||
|
## ARM64 Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo docker buildx build \
|
||||||
|
--platform linux/arm64 \
|
||||||
|
--target prod \
|
||||||
|
-t tonehaus/tonehaus:dev-arm64 \
|
||||||
|
-f docker/php/Dockerfile \
|
||||||
|
. \
|
||||||
|
--load
|
||||||
|
```
|
||||||
14
importmap.php
Normal file
14
importmap.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importmap configuration for the asset mapper.
|
||||||
|
*
|
||||||
|
* The single "app" entrypoint is enough for Bootstrap + custom JS tweaks.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'app' => [
|
||||||
|
'path' => './assets/app.js',
|
||||||
|
'entrypoint' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
@@ -19,7 +19,16 @@ final class Version20251031224841 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite uses the dedicated schema bootstrap migration later in the chain.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if the tables somehow already exist to keep reruns idempotent.
|
||||||
|
if ($schema->hasTable('reviews') || $schema->hasTable('users') || $schema->hasTable('messenger_messages')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->addSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
$this->addSql('CREATE TABLE reviews (id SERIAL NOT NULL, author_id INT NOT NULL, spotify_album_id VARCHAR(64) NOT NULL, album_name VARCHAR(255) NOT NULL, album_artist VARCHAR(255) NOT NULL, title VARCHAR(160) NOT NULL, content TEXT NOT NULL, rating SMALLINT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
|
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
|
||||||
$this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\'');
|
$this->addSql('COMMENT ON COLUMN reviews.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
@@ -46,11 +55,17 @@ final class Version20251031224841 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
|
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EB0FF675F31B');
|
||||||
$this->addSql('DROP TABLE reviews');
|
$this->addSql('DROP TABLE reviews');
|
||||||
$this->addSql('DROP TABLE users');
|
$this->addSql('DROP TABLE users');
|
||||||
$this->addSql('DROP TABLE messenger_messages');
|
$this->addSql('DROP TABLE messenger_messages');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ final class Version20251031231033 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->addSql('CREATE SCHEMA public');
|
$this->addSql('CREATE SCHEMA public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ final class Version20251031231715 extends AbstractMigration
|
|||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->addSql('CREATE SCHEMA public');
|
$this->addSql('CREATE SCHEMA public');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,27 @@ final class Version20251101001514 extends AbstractMigration
|
|||||||
|
|
||||||
public function up(Schema $schema): void
|
public function up(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite bootstraps settings in Version20251127235840.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($schema->hasTable('settings')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))');
|
$this->addSql('CREATE TABLE settings (id SERIAL NOT NULL, name VARCHAR(100) NOT NULL, value TEXT DEFAULT NULL, PRIMARY KEY(id))');
|
||||||
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
|
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
public function down(Schema $schema): void
|
||||||
{
|
{
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
if ($this->isSqlite()) {
|
||||||
$this->addSql('CREATE SCHEMA public');
|
return;
|
||||||
|
}
|
||||||
$this->addSql('DROP TABLE settings');
|
$this->addSql('DROP TABLE settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
migrations/Version20251114111853.php
Normal file
51
migrations/Version20251114111853.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251114111853 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite installs get albums from Version20251127235840.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Idempotent guard: if table already exists (from previous migration), skip
|
||||||
|
if ($schema->hasTable('albums')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE TABLE albums (id SERIAL NOT NULL, spotify_id VARCHAR(64) NOT NULL, name VARCHAR(255) NOT NULL, artists JSON NOT NULL, release_date VARCHAR(20) DEFAULT NULL, total_tracks INT NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Be defensive: only drop the table if it exists
|
||||||
|
if ($schema->hasTable('albums')) {
|
||||||
|
$this->addSql('DROP TABLE albums');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
44
migrations/Version20251114112016.php
Normal file
44
migrations/Version20251114112016.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251114112016 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||||
|
$this->addSql('ALTER INDEX uniq_album_spotify_id RENAME TO UNIQ_F4E2474FA905FC5C');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER created_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER updated_at TYPE TIMESTAMP(0) WITHOUT TIME ZONE');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.created_at IS NULL');
|
||||||
|
$this->addSql('COMMENT ON COLUMN albums.updated_at IS NULL');
|
||||||
|
$this->addSql('ALTER INDEX uniq_f4e2474fa905fc5c RENAME TO uniq_album_spotify_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
93
migrations/Version20251114113000.php
Normal file
93
migrations/Version20251114113000.php
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251114113000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Normalize reviews: add album_id FK, backfill from albums.spotify_id';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$schema->hasTable('reviews')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$reviews = $schema->getTable('reviews');
|
||||||
|
if ($reviews->hasColumn('album_id')) {
|
||||||
|
// Already migrated (common for SQLite dev DBs)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
|
||||||
|
// SQLite cannot add FK constraints after table creation; add the column + index and rely on app-level validation.
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD COLUMN album_id INTEGER DEFAULT NULL');
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_6970EF78E0C31AF9 ON reviews (album_id)');
|
||||||
|
$this->addSql('UPDATE reviews SET album_id = (SELECT a.id FROM albums a WHERE a.spotify_id = reviews.spotify_album_id) WHERE album_id IS NULL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add nullable album_id first (PostgreSQL / others that support full DDL)
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD album_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD CONSTRAINT FK_6970EF78E0C31AF9 FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6970EF78E0C31AF9 ON reviews (album_id)');
|
||||||
|
|
||||||
|
// Backfill using existing spotify_album_id if both columns exist
|
||||||
|
// Some environments may not have the legacy column; guard with DO blocks
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name='reviews' AND column_name='spotify_album_id'
|
||||||
|
) THEN
|
||||||
|
UPDATE reviews r
|
||||||
|
SET album_id = a.id
|
||||||
|
FROM albums a
|
||||||
|
WHERE a.spotify_id = r.spotify_album_id
|
||||||
|
AND r.album_id IS NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Optionally set NOT NULL if all rows are linked
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM reviews WHERE album_id IS NULL) THEN
|
||||||
|
ALTER TABLE reviews ALTER COLUMN album_id SET NOT NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$schema->hasTable('reviews')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$reviews = $schema->getTable('reviews');
|
||||||
|
if (!$reviews->hasColumn('album_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->connection->getDatabasePlatform()->getName() === 'sqlite') {
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
|
||||||
|
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql('ALTER TABLE reviews DROP CONSTRAINT FK_6970EF78E0C31AF9');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS IDX_6970EF78E0C31AF9');
|
||||||
|
$this->addSql('ALTER TABLE reviews DROP COLUMN album_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
57
migrations/Version20251114114000.php
Normal file
57
migrations/Version20251114114000.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251114114000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Drop legacy duplicated review columns: spotify_album_id, album_name, album_artist';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite schema never created the legacy columns.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Guard: drop columns only if they exist
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='spotify_album_id') THEN
|
||||||
|
ALTER TABLE reviews DROP COLUMN spotify_album_id;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_name') THEN
|
||||||
|
ALTER TABLE reviews DROP COLUMN album_name;
|
||||||
|
END IF;
|
||||||
|
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='reviews' AND column_name='album_artist') THEN
|
||||||
|
ALTER TABLE reviews DROP COLUMN album_artist;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Recreate columns as nullable in down migration
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD spotify_album_id VARCHAR(64) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD album_name VARCHAR(255) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE reviews ADD album_artist VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
53
migrations/Version20251114120500.php
Normal file
53
migrations/Version20251114120500.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251114120500 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add user-created album fields: local_id, source, created_by_id; make spotify_id nullable';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite schema already ships with these columns/defaults.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql("ALTER TABLE albums ADD local_id VARCHAR(64) DEFAULT NULL");
|
||||||
|
$this->addSql("ALTER TABLE albums ADD source VARCHAR(16) NOT NULL DEFAULT 'spotify'");
|
||||||
|
$this->addSql("ALTER TABLE albums ADD created_by_id INT DEFAULT NULL");
|
||||||
|
$this->addSql("ALTER TABLE albums ALTER spotify_id DROP NOT NULL");
|
||||||
|
$this->addSql("CREATE UNIQUE INDEX uniq_album_local_id ON albums (local_id)");
|
||||||
|
$this->addSql("ALTER TABLE albums ADD CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE");
|
||||||
|
$this->addSql("CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)");
|
||||||
|
$this->addSql("UPDATE albums SET source = 'spotify' WHERE source IS NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql("ALTER TABLE albums DROP CONSTRAINT FK_F4E2474FB03A8386");
|
||||||
|
$this->addSql("DROP INDEX IF EXISTS uniq_album_local_id");
|
||||||
|
$this->addSql("DROP INDEX IF EXISTS IDX_F4E2474FB03A8386");
|
||||||
|
$this->addSql("ALTER TABLE albums DROP COLUMN local_id");
|
||||||
|
$this->addSql("ALTER TABLE albums DROP COLUMN source");
|
||||||
|
$this->addSql("ALTER TABLE albums DROP COLUMN created_by_id");
|
||||||
|
$this->addSql("ALTER TABLE albums ALTER spotify_id SET NOT NULL");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
42
migrations/Version20251120174722.php
Normal file
42
migrations/Version20251120174722.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251120174722 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE reviews ALTER album_id SET NOT NULL');
|
||||||
|
$this->addSql('ALTER INDEX idx_6970ef78e0c31af9 RENAME TO IDX_6970EB0F1137ABCF');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE reviews ALTER album_id DROP NOT NULL');
|
||||||
|
$this->addSql('ALTER INDEX idx_6970eb0f1137abcf RENAME TO idx_6970ef78e0c31af9');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
38
migrations/Version20251120175034.php
Normal file
38
migrations/Version20251120175034.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251120175034 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE SCHEMA public');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
42
migrations/Version20251127191813.php
Normal file
42
migrations/Version20251127191813.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251127191813 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER source DROP DEFAULT');
|
||||||
|
$this->addSql('ALTER INDEX uniq_album_local_id RENAME TO UNIQ_F4E2474F5D5A2101');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE albums ALTER source SET DEFAULT \'spotify\'');
|
||||||
|
$this->addSql('ALTER INDEX uniq_f4e2474f5d5a2101 RENAME TO uniq_album_local_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
63
migrations/Version20251127235840.php
Normal file
63
migrations/Version20251127235840.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251127235840 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($schema->hasTable('users')) {
|
||||||
|
// Already initialized.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, created_by_id INTEGER DEFAULT NULL, spotify_id VARCHAR(64) DEFAULT NULL, local_id VARCHAR(64) DEFAULT NULL, source VARCHAR(16) NOT NULL, name VARCHAR(255) NOT NULL, artists CLOB NOT NULL --(DC2Type:json)
|
||||||
|
, release_date VARCHAR(20) DEFAULT NULL, total_tracks INTEGER NOT NULL, cover_url VARCHAR(1024) DEFAULT NULL, cover_image_path VARCHAR(255) DEFAULT NULL, external_url VARCHAR(1024) DEFAULT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||||
|
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||||
|
, CONSTRAINT FK_F4E2474FB03A8386 FOREIGN KEY (created_by_id) REFERENCES users (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474FA905FC5C ON albums (spotify_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_F4E2474F5D5A2101 ON albums (local_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_F4E2474FB03A8386 ON albums (created_by_id)');
|
||||||
|
$this->addSql('CREATE TABLE reviews (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, author_id INTEGER NOT NULL, album_id INTEGER NOT NULL, title VARCHAR(160) NOT NULL, content CLOB NOT NULL, rating SMALLINT NOT NULL, created_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||||
|
, updated_at DATETIME NOT NULL --(DC2Type:datetime_immutable)
|
||||||
|
, CONSTRAINT FK_6970EB0FF675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE, CONSTRAINT FK_6970EB0F1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6970EB0FF675F31B ON reviews (author_id)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_6970EB0F1137ABCF ON reviews (album_id)');
|
||||||
|
$this->addSql('CREATE TABLE settings (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name VARCHAR(100) NOT NULL, value CLOB DEFAULT NULL)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_setting_name ON settings (name)');
|
||||||
|
$this->addSql('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles CLOB NOT NULL --(DC2Type:json)
|
||||||
|
, password VARCHAR(255) NOT NULL, display_name VARCHAR(120) DEFAULT NULL, profile_image_path VARCHAR(255) DEFAULT NULL)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9E7927C74 ON users (email)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$this->isSqlite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('DROP TABLE albums');
|
||||||
|
$this->addSql('DROP TABLE reviews');
|
||||||
|
$this->addSql('DROP TABLE settings');
|
||||||
|
$this->addSql('DROP TABLE users');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
54
migrations/Version20251205123000.php
Normal file
54
migrations/Version20251205123000.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251205123000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add profile image path to users and cover image path to albums';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->shouldAddColumn($schema, 'users', 'profile_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE users ADD profile_image_path VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
if ($this->shouldAddColumn($schema, 'albums', 'cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums ADD cover_image_path VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite cannot drop columns; leave them in place.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($schema->hasTable('users') && $schema->getTable('users')->hasColumn('profile_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE users DROP profile_image_path');
|
||||||
|
}
|
||||||
|
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('cover_image_path')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP cover_image_path');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
|
||||||
|
{
|
||||||
|
if (!$schema->hasTable($tableName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !$schema->getTable($tableName)->hasColumn($column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
50
migrations/Version20251205133000.php
Normal file
50
migrations/Version20251205133000.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251205133000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create album_tracks table to persist Spotify tracklists';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
if ($schema->hasTable('album_tracks')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE TABLE album_tracks (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, album_id INTEGER NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INTEGER NOT NULL, track_number INTEGER NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INTEGER NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE)');
|
||||||
|
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('CREATE TABLE album_tracks (id SERIAL NOT NULL, album_id INT NOT NULL, spotify_track_id VARCHAR(64) DEFAULT NULL, disc_number INT NOT NULL, track_number INT NOT NULL, name VARCHAR(512) NOT NULL, duration_ms INT NOT NULL, preview_url VARCHAR(1024) DEFAULT NULL, PRIMARY KEY(id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_5E4A3B3B1137ABCF ON album_tracks (album_id)');
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX uniq_album_disc_track ON album_tracks (album_id, disc_number, track_number)');
|
||||||
|
$this->addSql('ALTER TABLE album_tracks ADD CONSTRAINT FK_5E4A3B3B1137ABCF FOREIGN KEY (album_id) REFERENCES albums (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
$this->addSql('DROP TABLE IF EXISTS album_tracks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->addSql('ALTER TABLE album_tracks DROP CONSTRAINT FK_5E4A3B3B1137ABCF');
|
||||||
|
$this->addSql('DROP TABLE album_tracks');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
58
migrations/Version20251205134500.php
Normal file
58
migrations/Version20251205134500.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251205134500 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add genres JSON column to albums for advanced search filters';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
if (!$this->shouldAddColumn($schema, 'albums', 'genres')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
$this->addSql("ALTER TABLE albums ADD genres CLOB NOT NULL DEFAULT '[]'");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql("ALTER TABLE albums ADD genres JSON NOT NULL DEFAULT '[]'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
if ($this->isSqlite()) {
|
||||||
|
// SQLite cannot drop columns without rebuilding the table; leave as-is.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schema->hasTable('albums') && $schema->getTable('albums')->hasColumn('genres')) {
|
||||||
|
$this->addSql('ALTER TABLE albums DROP genres');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return $this->connection->getDatabasePlatform()->getName() === 'sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldAddColumn(Schema $schema, string $tableName, string $column): bool
|
||||||
|
{
|
||||||
|
if (!$schema->hasTable($tableName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$schema->getTable($tableName)->hasColumn($column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
303
public/css/app.css
Normal file
303
public/css/app.css
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
: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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Material-inspired data tables */
|
||||||
|
.mui-table-wrapper {
|
||||||
|
border: 1px solid var(--md-card-border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: var(--md-card);
|
||||||
|
box-shadow: var(--md-shadow-ambient);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table thead th {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--md-text-secondary);
|
||||||
|
padding: 0.85rem 1.25rem;
|
||||||
|
background-color: var(--md-surface-variant);
|
||||||
|
border-bottom: 1px solid var(--md-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table tbody td {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--md-outline) 75%, transparent);
|
||||||
|
color: var(--md-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table--compact tbody td {
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table tbody tr {
|
||||||
|
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table tbody tr:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-color) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table--striped tbody tr:nth-child(even) {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-color) 4%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table--striped tbody tr:nth-child(even):hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-color) 9%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--md-text-secondary);
|
||||||
|
width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__metric {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--md-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__title-avatar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__title-avatar img {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid var(--md-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-table__subtitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--md-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--md-outline);
|
||||||
|
color: var(--accent-color);
|
||||||
|
background-color: transparent;
|
||||||
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-icon-button:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-color) 12%, transparent);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mui-icon-button svg,
|
||||||
|
.mui-icon-button span {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
3
public/uploads/.gitignore
vendored
Normal file
3
public/uploads/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
|
|
||||||
@@ -13,20 +13,32 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||||||
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
|
#[AsCommand(name: 'app:promote-admin', description: 'Grant ROLE_ADMIN to a user by email')]
|
||||||
class PromoteAdminCommand extends Command
|
class PromoteAdminCommand extends Command
|
||||||
{
|
{
|
||||||
public function __construct(private readonly UserRepository $users, private readonly EntityManagerInterface $em)
|
/**
|
||||||
|
* Stores injected dependencies for later use.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares the required email argument.
|
||||||
|
*/
|
||||||
protected function configure(): void
|
protected function configure(): void
|
||||||
{
|
{
|
||||||
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
|
$this->addArgument('email', InputArgument::REQUIRED, 'Email of the user to promote');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promotes the provided account to administrator if found.
|
||||||
|
*/
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
{
|
{
|
||||||
$email = (string) $input->getArgument('email');
|
$email = (string) $input->getArgument('email');
|
||||||
$user = $this->users->findOneByEmail($email);
|
$user = $this->userRepository->findOneByEmail($email);
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$output->writeln('<error>User not found: ' . $email . '</error>');
|
$output->writeln('<error>User not found: ' . $email . '</error>');
|
||||||
return Command::FAILURE;
|
return Command::FAILURE;
|
||||||
@@ -36,7 +48,7 @@ class PromoteAdminCommand extends Command
|
|||||||
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
if (!in_array('ROLE_ADMIN', $roles, true)) {
|
||||||
$roles[] = 'ROLE_ADMIN';
|
$roles[] = 'ROLE_ADMIN';
|
||||||
$user->setRoles($roles);
|
$user->setRoles($roles);
|
||||||
$this->em->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
$output->writeln('<info>Granted ROLE_ADMIN to ' . $email . '</info>');
|
||||||
|
|||||||
60
src/Command/PromoteModeratorCommand.php
Normal file
60
src/Command/PromoteModeratorCommand.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?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 $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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->userRepository->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->entityManager->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('<info>Granted ROLE_MODERATOR to ' . $email . '</info>');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
168
src/Command/SeedDemoAlbumsCommand.php
Normal file
168
src/Command/SeedDemoAlbumsCommand.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-albums',
|
||||||
|
description: 'Create demo albums with randomized metadata for local development.'
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with synthetic user-sourced albums.
|
||||||
|
*
|
||||||
|
* - Always marked as "user" source with a unique localId.
|
||||||
|
* - Include randomized names, artists, genres, release dates, and cover URLs.
|
||||||
|
* - Optionally link to existing users as creators when --attach-users is set.
|
||||||
|
*/
|
||||||
|
class SeedDemoAlbumsCommand extends Command
|
||||||
|
{
|
||||||
|
private const GENRES = [
|
||||||
|
'Dreamwave', 'Synth Pop', 'Lo-Fi', 'Indie Rock', 'Chillhop', 'Neo Jazz',
|
||||||
|
'Electro Funk', 'Ambient', 'Future Soul', 'Post Folk', 'Shoegaze', 'Hyperpop',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ADJECTIVES = [
|
||||||
|
'Electric', 'Velvet', 'Crimson', 'Solar', 'Golden', 'Neon', 'Silent', 'Liquid', 'Violet', 'Paper',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const NOUNS = [
|
||||||
|
'Echoes', 'Horizons', 'Magnets', 'Parades', 'Cities', 'Signals', 'Fragments', 'Constellations',
|
||||||
|
'Gardens', 'Drifters', 'Reflections', 'Blueprints',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo albums to create', 40)
|
||||||
|
->addOption('attach-users', null, InputOption::VALUE_NONE, 'If set, randomly assigns existing users as creators');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$count = max(1, (int) $input->getOption('count'));
|
||||||
|
$attachUsers = (bool) $input->getOption('attach-users');
|
||||||
|
$users = $attachUsers ? $this->userRepository->findAll() : [];
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
// Track generated localIds so we never attempt to persist obvious duplicates.
|
||||||
|
$seenLocalIds = [];
|
||||||
|
|
||||||
|
while ($created < $count) {
|
||||||
|
// Generate a localId that is unique in-memory and in the database to avoid constraint violations.
|
||||||
|
$localId = $this->generateLocalId();
|
||||||
|
if (isset($seenLocalIds[$localId]) || $this->albumRepository->findOneBy(['localId' => $localId]) !== null) {
|
||||||
|
// Only accept IDs that are unique both in-memory and in the DB to avoid constraint errors.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$album = new Album();
|
||||||
|
$album->setSource('user');
|
||||||
|
$album->setLocalId($localId);
|
||||||
|
$album->setName($this->generateAlbumName());
|
||||||
|
$album->setArtists($this->generateArtists());
|
||||||
|
$album->setGenres($this->generateGenres());
|
||||||
|
$album->setReleaseDate($this->generateReleaseDate());
|
||||||
|
$album->setTotalTracks(random_int(6, 16));
|
||||||
|
$album->setCoverUrl($this->generateCoverUrl($localId));
|
||||||
|
$album->setExternalUrl(sprintf('https://example.com/demo-albums/%s', $localId));
|
||||||
|
|
||||||
|
if ($attachUsers && $users !== []) {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $users[array_rand($users)];
|
||||||
|
$album->setCreatedBy($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($album);
|
||||||
|
$seenLocalIds[$localId] = true;
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('Created %d demo albums%s.', $created, $attachUsers ? ' with random owners' : ''));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateLocalId(): string
|
||||||
|
{
|
||||||
|
return 'demo_' . bin2hex(random_bytes(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateAlbumName(): string
|
||||||
|
{
|
||||||
|
$adj = self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)];
|
||||||
|
$noun = self::NOUNS[random_int(0, count(self::NOUNS) - 1)];
|
||||||
|
$genre = self::GENRES[random_int(0, count(self::GENRES) - 1)];
|
||||||
|
|
||||||
|
return sprintf('%s %s of %s', $adj, $noun, $genre);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function generateArtists(): array
|
||||||
|
{
|
||||||
|
$artists = [];
|
||||||
|
$artistCount = random_int(1, 3);
|
||||||
|
for ($i = 0; $i < $artistCount; $i++) {
|
||||||
|
$artists[] = sprintf(
|
||||||
|
'%s %s',
|
||||||
|
self::ADJECTIVES[random_int(0, count(self::ADJECTIVES) - 1)],
|
||||||
|
self::NOUNS[random_int(0, count(self::NOUNS) - 1)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($artists));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateReleaseDate(): string
|
||||||
|
{
|
||||||
|
$year = random_int(1990, (int) date('Y'));
|
||||||
|
$month = random_int(1, 12);
|
||||||
|
$day = random_int(1, 28);
|
||||||
|
|
||||||
|
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function generateGenres(): array
|
||||||
|
{
|
||||||
|
$count = random_int(1, 3);
|
||||||
|
$genres = [];
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$genres[] = self::GENRES[random_int(0, count(self::GENRES) - 1)];
|
||||||
|
}
|
||||||
|
return array_values(array_unique($genres));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCoverUrl(string $seed): string
|
||||||
|
{
|
||||||
|
return sprintf('https://picsum.photos/seed/%s/640/640', $seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
232
src/Command/SeedDemoReviewsCommand.php
Normal file
232
src/Command/SeedDemoReviewsCommand.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use App\Entity\Review;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-reviews',
|
||||||
|
description: 'Generate demo reviews across existing albums.'
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with demo reviews attached to existing albums and users.
|
||||||
|
*
|
||||||
|
* Controls:
|
||||||
|
* - --cover-percent: roughly what percentage of albums receive reviews.
|
||||||
|
* - --min-per-album / --max-per-album: bounds for randomly chosen review counts.
|
||||||
|
* - --only-empty: restricts seeding to albums that currently have no reviews.
|
||||||
|
*
|
||||||
|
* The command avoids:
|
||||||
|
* - Creating multiple reviews from the same user on a single album.
|
||||||
|
* - Touching albums/users when there is no suitable data to seed.
|
||||||
|
*/
|
||||||
|
class SeedDemoReviewsCommand extends Command
|
||||||
|
{
|
||||||
|
private const SUBJECTS = [
|
||||||
|
'Textures', 'Melodies', 'Lyrics', 'Drums', 'Synths', 'Vocals', 'Atmosphere', 'Production',
|
||||||
|
'Hooks', 'Transitions', 'Energy', 'Dynamics', 'Story', 'Beats', 'Guitars',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const VERBS = [
|
||||||
|
'ignite', 'carry', 'elevate', 'anchor', 'transform', 'frame', 'redefine', 'ground', 'highlight',
|
||||||
|
'soften', 'energize', 'contrast', 'bend', 'reshape', 'underline',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const QUALIFIERS = [
|
||||||
|
'beautifully', 'with surprising restraint', 'like neon waves', 'with cinematic flair',
|
||||||
|
'through dusty speakers', 'in unexpected directions', 'along a familiar path', 'with swagger',
|
||||||
|
'with delicate pulses', 'through midnight haze', 'under fluorescent skies', 'with raw urgency',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AlbumRepository $albumRepository,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('max-per-album', null, InputOption::VALUE_OPTIONAL, 'Maximum reviews per album', 10)
|
||||||
|
->addOption('min-per-album', null, InputOption::VALUE_OPTIONAL, 'Minimum reviews per selected album', 1)
|
||||||
|
->addOption('cover-percent', null, InputOption::VALUE_OPTIONAL, 'Percent of albums that should receive reviews (0-100)', 60)
|
||||||
|
->addOption('only-empty', null, InputOption::VALUE_NONE, 'Only seed albums that currently have no reviews');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
// Pull all albums/users once up front so downstream helpers filter as needed.
|
||||||
|
$albums = $this->albumRepository->findAll();
|
||||||
|
$users = $this->userRepository->findAll();
|
||||||
|
|
||||||
|
if ($albums === [] || $users === []) {
|
||||||
|
$io->warning('Need at least one album and one user to seed reviews.');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize and clamp CLI options so downstream math is always safe. (min/max/clamp)
|
||||||
|
$minPerAlbum = max(0, (int) $input->getOption('min-per-album'));
|
||||||
|
$maxPerAlbum = max($minPerAlbum, (int) $input->getOption('max-per-album'));
|
||||||
|
$coverPercent = max(0, min(100, (int) $input->getOption('cover-percent')));
|
||||||
|
|
||||||
|
// Apply coverage and "only empty" filters before creating any Review entities. (filter)
|
||||||
|
$selectedAlbums = $this->selectAlbums($albums, $coverPercent);
|
||||||
|
$onlyEmpty = (bool) $input->getOption('only-empty');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
// Count how many albums actually received new reviews for clearer operator feedback. (count)
|
||||||
|
$processedAlbums = 0;
|
||||||
|
foreach ($selectedAlbums as $album) {
|
||||||
|
if ($onlyEmpty && $this->albumHasReviews($album)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$targetReviews = random_int($minPerAlbum, max($minPerAlbum, $maxPerAlbum));
|
||||||
|
$created += $this->seedForAlbum($album, $users, $targetReviews);
|
||||||
|
$processedAlbums++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
if ($created === 0) {
|
||||||
|
$io->warning('No reviews were created. Try relaxing the filters or ensure there are albums without reviews.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->success(sprintf('Created %d demo reviews across %d albums.', $created, max($processedAlbums, 1)));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Album> $albums
|
||||||
|
* @return list<Album>
|
||||||
|
*/
|
||||||
|
private function selectAlbums(array $albums, int $coverPercent): array
|
||||||
|
{
|
||||||
|
if ($coverPercent >= 100) {
|
||||||
|
return $albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Randomly sample albums until the requested coverage threshold is met.
|
||||||
|
$selected = [];
|
||||||
|
foreach ($albums as $album) {
|
||||||
|
if (random_int(1, 100) <= $coverPercent) {
|
||||||
|
$selected[] = $album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we always seed at least one album when any albums exist.
|
||||||
|
return $selected === [] ? [$albums[array_rand($albums)]] : $selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<User> $users
|
||||||
|
*/
|
||||||
|
private function seedForAlbum(Album $album, array $users, int $targetReviews): int
|
||||||
|
{
|
||||||
|
$created = 0;
|
||||||
|
$existingAuthors = $this->fetchExistingAuthors($album);
|
||||||
|
// Filter out users who have already reviewed this album so we only ever
|
||||||
|
// create one review per (album, author) pair.
|
||||||
|
$availableUsers = array_filter($users, fn(User $user) => !isset($existingAuthors[$user->getId() ?? -1]));
|
||||||
|
|
||||||
|
if ($availableUsers === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit requested reviews to the number of eligible authors, then randomly
|
||||||
|
// choose a stable subset for this run.
|
||||||
|
$targetReviews = min($targetReviews, count($availableUsers));
|
||||||
|
shuffle($availableUsers);
|
||||||
|
$selectedUsers = array_slice($availableUsers, 0, $targetReviews);
|
||||||
|
|
||||||
|
foreach ($selectedUsers as $user) {
|
||||||
|
// Prevent duplicate reviews per author by only iterating over filtered unique users.
|
||||||
|
$review = new Review();
|
||||||
|
$review->setAlbum($album);
|
||||||
|
$review->setAuthor($user);
|
||||||
|
$review->setRating(random_int(4, 10));
|
||||||
|
$review->setTitle($this->generateTitle());
|
||||||
|
$review->setContent($this->generateContent($album));
|
||||||
|
|
||||||
|
$this->entityManager->persist($review);
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $created;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,bool>
|
||||||
|
*/
|
||||||
|
private function fetchExistingAuthors(Album $album): array
|
||||||
|
{
|
||||||
|
// Fetch all distinct author IDs that have already reviewed this album so we
|
||||||
|
// can cheaply check for duplicates in PHP without loading full Review objects.
|
||||||
|
$qb = $this->entityManager->createQueryBuilder()
|
||||||
|
->select('IDENTITY(r.author) AS authorId')
|
||||||
|
->from(Review::class, 'r')
|
||||||
|
->where('r.album = :album')
|
||||||
|
->setParameter('album', $album);
|
||||||
|
|
||||||
|
$rows = $qb->getQuery()->getScalarResult();
|
||||||
|
$out = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$out[(int) $row['authorId']] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function albumHasReviews(Album $album): bool
|
||||||
|
{
|
||||||
|
$count = (int) $this->entityManager->createQueryBuilder()
|
||||||
|
->select('COUNT(r.id)')
|
||||||
|
->from(Review::class, 'r')
|
||||||
|
->where('r.album = :album')
|
||||||
|
->setParameter('album', $album)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
|
||||||
|
return $count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateTitle(): string
|
||||||
|
{
|
||||||
|
$subject = self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)];
|
||||||
|
$verb = self::VERBS[random_int(0, count(self::VERBS) - 1)];
|
||||||
|
|
||||||
|
return sprintf('%s %s the vibe', $subject, $verb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateContent(Album $album): string
|
||||||
|
{
|
||||||
|
$qualifier = self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)];
|
||||||
|
|
||||||
|
return sprintf(
|
||||||
|
'Listening to "%s" feels like %s. %s %s %s, and by the end it lingers far longer than expected.',
|
||||||
|
$album->getName(),
|
||||||
|
$qualifier,
|
||||||
|
self::SUBJECTS[random_int(0, count(self::SUBJECTS) - 1)],
|
||||||
|
self::VERBS[random_int(0, count(self::VERBS) - 1)],
|
||||||
|
self::QUALIFIERS[random_int(0, count(self::QUALIFIERS) - 1)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
109
src/Command/SeedDemoUsersCommand.php
Normal file
109
src/Command/SeedDemoUsersCommand.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-demo-users',
|
||||||
|
description: 'Create demo users with random emails and display names.'
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds the database with demo users for local development and testing.
|
||||||
|
*
|
||||||
|
* - Generates unique, non-conflicting demo email addresses.
|
||||||
|
* - Assigns a predictable default password (overridable via --password).
|
||||||
|
* - Creates users with a single ROLE_USER role.
|
||||||
|
*/
|
||||||
|
class SeedDemoUsersCommand extends Command
|
||||||
|
{
|
||||||
|
private const FIRST_NAMES = [
|
||||||
|
'Alex', 'Jamie', 'Taylor', 'Jordan', 'Morgan', 'Casey', 'Riley', 'Parker', 'Robin', 'Avery',
|
||||||
|
'Charlie', 'Dakota', 'Emerson', 'Finley', 'Harper', 'Jules', 'Kai', 'Logan', 'Quinn', 'Rowan',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const LAST_NAMES = [
|
||||||
|
'Rivera', 'Nguyen', 'Patel', 'Khan', 'Smith', 'Garcia', 'Fernandez', 'Kim', 'Singh', 'Williams',
|
||||||
|
'Hughes', 'Silva', 'Bennett', 'Wright', 'Clark', 'Murphy', 'Price', 'Reid', 'Gallagher', 'Foster',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('count', null, InputOption::VALUE_OPTIONAL, 'Number of demo users to create', 50)
|
||||||
|
->addOption('password', null, InputOption::VALUE_OPTIONAL, 'Plain password assigned to every demo user', 'password');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$count = (int) $input->getOption('count');
|
||||||
|
$count = $count > 0 ? $count : 50;
|
||||||
|
$plainPassword = (string) $input->getOption('password');
|
||||||
|
|
||||||
|
$created = 0;
|
||||||
|
// Track generated emails so we never attempt to persist obvious duplicates.
|
||||||
|
$seenEmails = [];
|
||||||
|
|
||||||
|
while ($created < $count) {
|
||||||
|
// Keep generating new tokens until we find an email that is unique
|
||||||
|
// for both this run and the existing database.
|
||||||
|
$email = $this->generateEmail();
|
||||||
|
if (isset($seenEmails[$email]) || $this->userRepository->findOneBy(['email' => $email]) !== null) {
|
||||||
|
// Collisions are rare but possible because we only randomize 8 hex chars; try again.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->setEmail($email);
|
||||||
|
$user->setDisplayName($this->generateDisplayName());
|
||||||
|
$user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword));
|
||||||
|
$user->setRoles(['ROLE_USER']);
|
||||||
|
|
||||||
|
$this->entityManager->persist($user);
|
||||||
|
$seenEmails[$email] = true;
|
||||||
|
$created++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$io->success(sprintf('Created %d demo users. Default password: %s', $created, $plainPassword));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateEmail(): string
|
||||||
|
{
|
||||||
|
$token = bin2hex(random_bytes(4));
|
||||||
|
|
||||||
|
return sprintf('demo+%s@example.com', $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateDisplayName(): string
|
||||||
|
{
|
||||||
|
$first = self::FIRST_NAMES[random_int(0, count(self::FIRST_NAMES) - 1)];
|
||||||
|
$last = self::LAST_NAMES[random_int(0, count(self::LAST_NAMES) - 1)];
|
||||||
|
|
||||||
|
return sprintf('%s %s', $first, $last);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
95
src/Command/SeedUserAvatarsCommand.php
Normal file
95
src/Command/SeedUserAvatarsCommand.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:seed-user-avatars',
|
||||||
|
description: 'Assign generated profile images to existing users.'
|
||||||
|
)]
|
||||||
|
/**
|
||||||
|
* Seeds or refreshes user profile images using the DiceBear avatar API.
|
||||||
|
*
|
||||||
|
* - Skips users that already have an image unless --overwrite is provided.
|
||||||
|
* - Builds deterministic avatar URLs based on user identity and an optional seed prefix.
|
||||||
|
* - Does not download or cache the avatars locally; URLs are stored directly.
|
||||||
|
*/
|
||||||
|
class SeedUserAvatarsCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserRepository $userRepository,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('overwrite', null, InputOption::VALUE_NONE, 'Overwrite users that already have a profile image set')
|
||||||
|
->addOption('style', null, InputOption::VALUE_OPTIONAL, 'DiceBear style to use for avatars', 'thumbs')
|
||||||
|
->addOption('seed-prefix', null, InputOption::VALUE_OPTIONAL, 'Prefix added to the avatar seed for variety', 'musicratings');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$overwrite = (bool) $input->getOption('overwrite');
|
||||||
|
$style = (string) $input->getOption('style');
|
||||||
|
$seedPrefix = (string) $input->getOption('seed-prefix');
|
||||||
|
|
||||||
|
$users = $this->userRepository->findAll();
|
||||||
|
if ($users === []) {
|
||||||
|
$io->warning('No users found.');
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$updated = 0;
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$overwrite && $user->getProfileImagePath()) {
|
||||||
|
// Respect existing uploads unless the operator explicitly allows clobbering them.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$user->setProfileImagePath($this->buildAvatarUrl($user, $style, $seedPrefix));
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($updated === 0) {
|
||||||
|
$io->info('No avatars needed updating.');
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$io->success(sprintf('Assigned avatars to %d user(s).', $updated));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildAvatarUrl(User $user, string $style, string $seedPrefix): string
|
||||||
|
{
|
||||||
|
// Use a stable identifier (display name when present, email as fallback)
|
||||||
|
// so the same user is always mapped to the same avatar for a given prefix.
|
||||||
|
$identifier = trim((string) ($user->getDisplayName() ?? $user->getEmail()));
|
||||||
|
// Combine prefix, identifier, and primary key into a deterministic hash
|
||||||
|
// and trim it to a shorter seed value accepted by DiceBear.
|
||||||
|
$seed = substr(hash('sha256', $seedPrefix . '|' . strtolower($identifier) . '|' . (string) $user->getId()), 0, 32);
|
||||||
|
|
||||||
|
return sprintf('https://api.dicebear.com/7.x/%s/svg?seed=%s', rawurlencode($style), $seed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -4,48 +4,119 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\ProfileFormType;
|
use App\Form\ProfileFormType;
|
||||||
|
use App\Repository\ReviewRepository;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Service\UploadStorage;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Form\FormError;
|
use Symfony\Component\Form\FormError;
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AccountController hosts authenticated self-service pages.
|
||||||
|
*/
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
class AccountController extends AbstractController
|
class AccountController extends AbstractController
|
||||||
{
|
{
|
||||||
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET', 'POST'])]
|
/**
|
||||||
public function dashboard(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher): Response
|
* Summarizes the signed-in user's recent activity.
|
||||||
|
*/
|
||||||
|
#[Route('/dashboard', name: 'account_dashboard', methods: ['GET'])]
|
||||||
|
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums): Response
|
||||||
{
|
{
|
||||||
/** @var User $user */
|
/** @var User $user */
|
||||||
$user = $this->getUser();
|
$user = $this->getUser();
|
||||||
|
|
||||||
|
$reviewCount = (int) $reviews->createQueryBuilder('r')
|
||||||
|
->select('COUNT(r.id)')
|
||||||
|
->where('r.author = :u')->setParameter('u', $user)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
$albumCount = (int) $albums->createQueryBuilder('a')
|
||||||
|
->select('COUNT(a.id)')
|
||||||
|
->where('a.source = :src')->setParameter('src', 'user')
|
||||||
|
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
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')
|
||||||
|
->setMaxResults(10)
|
||||||
|
->getQuery()->getResult();
|
||||||
|
$userAlbums = $albums->createQueryBuilder('a')
|
||||||
|
->where('a.source = :src')->setParameter('src', 'user')
|
||||||
|
->andWhere('a.createdBy = :u')->setParameter('u', $user)
|
||||||
|
->orderBy('a.createdAt', 'DESC')
|
||||||
|
->setMaxResults(10)
|
||||||
|
->getQuery()->getResult();
|
||||||
|
|
||||||
|
return $this->render('account/dashboard.html.twig', [
|
||||||
|
'email' => $user->getEmail(),
|
||||||
|
'displayName' => $user->getDisplayName(),
|
||||||
|
'profileImage' => $user->getProfileImagePath(),
|
||||||
|
'reviewCount' => $reviewCount,
|
||||||
|
'albumCount' => $albumCount,
|
||||||
|
'userType' => $userType,
|
||||||
|
'userReviews' => $userReviews,
|
||||||
|
'userAlbums' => $userAlbums,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows users to update profile details and avatar.
|
||||||
|
*/
|
||||||
|
#[Route('/account/profile', name: 'account_profile', methods: ['GET', 'POST'])]
|
||||||
|
public function profile(Request $request, EntityManagerInterface $em, UserPasswordHasherInterface $hasher, UploadStorage $uploadStorage): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
$form = $this->createForm(ProfileFormType::class, $user);
|
$form = $this->createForm(ProfileFormType::class, $user);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
$newPassword = (string) $form->get('newPassword')->getData();
|
$newPassword = (string) $form->get('newPassword')->getData();
|
||||||
if ($newPassword !== '') {
|
if ($newPassword !== '') {
|
||||||
$current = (string) $form->get('currentPassword')->getData();
|
$current = (string) $form->get('currentPassword')->getData();
|
||||||
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
if ($current === '' || !$hasher->isPasswordValid($user, $current)) {
|
||||||
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
$form->get('currentPassword')->addError(new FormError('Current password is incorrect.'));
|
||||||
return $this->render('account/dashboard.html.twig', [
|
} else {
|
||||||
'form' => $form->createView(),
|
// Allow password updates inside the same form submission instead of forcing a separate flow.
|
||||||
]);
|
|
||||||
}
|
|
||||||
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
$user->setPassword($hasher->hashPassword($user, $newPassword));
|
||||||
}
|
}
|
||||||
$em->flush();
|
|
||||||
$this->addFlash('success', 'Profile updated.');
|
|
||||||
return $this->redirectToRoute('account_dashboard');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('account/dashboard.html.twig', [
|
if ($form->isValid()) {
|
||||||
|
$upload = $form->get('profileImage')->getData();
|
||||||
|
if ($upload instanceof UploadedFile) {
|
||||||
|
$uploadStorage->remove($user->getProfileImagePath());
|
||||||
|
$user->setProfileImagePath($uploadStorage->storeProfileImage($upload));
|
||||||
|
}
|
||||||
|
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Profile updated.');
|
||||||
|
return $this->redirectToRoute('account_profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('account/profile.html.twig', [
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'profileImage' => $user->getProfileImagePath(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows account-level settings options.
|
||||||
|
*/
|
||||||
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
|
#[Route('/settings', name: 'account_settings', methods: ['GET'])]
|
||||||
public function settings(): Response
|
public function settings(): Response
|
||||||
{
|
{
|
||||||
|
|||||||
76
src/Controller/Admin/DashboardController.php
Normal file
76
src/Controller/Admin/DashboardController.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\ReviewRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Service\SpotifyMetadataRefresher;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DashboardController shows high-level site activity to admins.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_MODERATOR')]
|
||||||
|
class DashboardController extends AbstractController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Renders overall activity metrics for administrators.
|
||||||
|
*/
|
||||||
|
#[Route('/admin/dashboard', name: 'admin_dashboard', methods: ['GET'])]
|
||||||
|
public function dashboard(ReviewRepository $reviews, AlbumRepository $albums, UserRepository $users): Response
|
||||||
|
{
|
||||||
|
// Raw COUNT(*) queries are cheaper than hydrating entities just to compute totals.
|
||||||
|
$totalReviews = (int) $reviews->createQueryBuilder('r')
|
||||||
|
->select('COUNT(r.id)')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$totalAlbums = (int) $albums->createQueryBuilder('a')
|
||||||
|
->select('COUNT(a.id)')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$totalUsers = (int) $users->createQueryBuilder('u')
|
||||||
|
->select('COUNT(u.id)')
|
||||||
|
->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
// Latest rows are pulled separately so the dashboard can show concrete activity.
|
||||||
|
$recentReviews = $reviews->findLatest(50);
|
||||||
|
$recentAlbums = $albums->createQueryBuilder('a')
|
||||||
|
->orderBy('a.createdAt', 'DESC')
|
||||||
|
->setMaxResults(50)
|
||||||
|
->getQuery()->getResult();
|
||||||
|
|
||||||
|
return $this->render('admin/site_dashboard.html.twig', [
|
||||||
|
'totalReviews' => $totalReviews,
|
||||||
|
'totalAlbums' => $totalAlbums,
|
||||||
|
'totalUsers' => $totalUsers,
|
||||||
|
'recentReviews' => $recentReviews,
|
||||||
|
'recentAlbums' => $recentAlbums,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/dashboard/refresh-spotify', name: 'admin_dashboard_refresh_spotify', methods: ['POST'])]
|
||||||
|
public function refreshSpotify(
|
||||||
|
Request $request,
|
||||||
|
SpotifyMetadataRefresher $refresher
|
||||||
|
): Response {
|
||||||
|
$token = (string) $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('dashboard_refresh_spotify', $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh runs synchronously; keep user feedback short so the POST remains snappy.
|
||||||
|
$updated = $refresher->refreshAllSpotifyAlbums();
|
||||||
|
if ($updated === 0) {
|
||||||
|
$this->addFlash('info', 'No Spotify albums needed refresh or none are saved.');
|
||||||
|
} else {
|
||||||
|
$this->addFlash('success', sprintf('Refreshed %d Spotify albums.', $updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/Controller/Admin/SettingsController.php
Normal file
171
src/Controller/Admin/SettingsController.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use App\Form\SiteSettingsType;
|
||||||
|
use App\Repository\SettingRepository;
|
||||||
|
use App\Service\CatalogResetService;
|
||||||
|
use App\Service\CommandRunner;
|
||||||
|
use App\Service\RegistrationToggle;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SettingsController lets admins adjust key integration settings.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_ADMIN')]
|
||||||
|
class SettingsController extends AbstractController
|
||||||
|
{
|
||||||
|
// Metadata for demo seeding actions; drives both the UI form and CLI invocation options.
|
||||||
|
private const DEMO_COMMANDS = [
|
||||||
|
'users' => [
|
||||||
|
'command' => 'app:seed-demo-users',
|
||||||
|
'label' => 'Demo users',
|
||||||
|
'description' => 'Creates demo accounts with randomized emails.',
|
||||||
|
'fields' => [
|
||||||
|
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||||
|
['name' => 'password', 'label' => 'Password', 'type' => 'text', 'placeholder' => 'password', 'default' => 'password'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'albums' => [
|
||||||
|
'command' => 'app:seed-demo-albums',
|
||||||
|
'label' => 'Demo albums',
|
||||||
|
'description' => 'Creates user albums with randomized metadata.',
|
||||||
|
'fields' => [
|
||||||
|
['name' => 'count', 'label' => 'Count', 'type' => 'number', 'placeholder' => '40', 'default' => 40],
|
||||||
|
['name' => 'attach-users', 'label' => 'Attach existing users', 'type' => 'checkbox', 'default' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'reviews' => [
|
||||||
|
'command' => 'app:seed-demo-reviews',
|
||||||
|
'label' => 'Demo reviews',
|
||||||
|
'description' => 'Adds sample reviews for existing albums.',
|
||||||
|
'fields' => [
|
||||||
|
['name' => 'cover-percent', 'label' => 'Album coverage %', 'type' => 'number', 'placeholder' => '50', 'default' => 50],
|
||||||
|
['name' => 'min-per-album', 'label' => 'Min per album', 'type' => 'number', 'placeholder' => '1', 'default' => 1],
|
||||||
|
['name' => 'max-per-album', 'label' => 'Max per album', 'type' => 'number', 'placeholder' => '3', 'default' => 3],
|
||||||
|
['name' => 'only-empty', 'label' => 'Only albums without reviews', 'type' => 'checkbox'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'avatars' => [
|
||||||
|
'command' => 'app:seed-user-avatars',
|
||||||
|
'label' => 'Profile pictures',
|
||||||
|
'description' => 'Assigns generated avatars to users (skips existing).',
|
||||||
|
'fields' => [
|
||||||
|
['name' => 'overwrite', 'label' => 'Overwrite existing avatars', 'type' => 'checkbox'],
|
||||||
|
['name' => 'style', 'label' => 'DiceBear style', 'type' => 'text', 'placeholder' => 'thumbs', 'default' => 'thumbs'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays and persists Spotify credential settings.
|
||||||
|
*/
|
||||||
|
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
||||||
|
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->getEnvOverride();
|
||||||
|
$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) {
|
||||||
|
// Persist only when the flag is not locked by APP_ALLOW_REGISTRATION.
|
||||||
|
$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,
|
||||||
|
'demoCommands' => self::DEMO_COMMANDS,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/settings/reset-catalog', name: 'admin_settings_reset_catalog', methods: ['POST'])]
|
||||||
|
public function resetCatalog(Request $request, CatalogResetService $resetService): Response
|
||||||
|
{
|
||||||
|
$token = (string) $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('admin_settings_reset_catalog', $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $resetService->resetCatalog();
|
||||||
|
$this->addFlash('success', sprintf(
|
||||||
|
'Reset catalog: deleted %d reviews and %d albums.',
|
||||||
|
$result['reviews'],
|
||||||
|
$result['albums']
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/admin/settings/generate-demo/{type}', name: 'admin_settings_generate_demo', methods: ['POST'])]
|
||||||
|
public function generateDemo(
|
||||||
|
string $type,
|
||||||
|
Request $request,
|
||||||
|
CommandRunner $runner
|
||||||
|
): Response {
|
||||||
|
$config = self::DEMO_COMMANDS[$type] ?? null;
|
||||||
|
if ($config === null) {
|
||||||
|
throw $this->createNotFoundException('Unknown demo data type.');
|
||||||
|
}
|
||||||
|
$token = (string) $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('admin_settings_generate_' . $type, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$options = $this->buildCommandOptions($config, $request);
|
||||||
|
$runner->runConsoleCommand($config['command'], $options);
|
||||||
|
$this->addFlash('success', sprintf('%s generation complete.', $config['label']));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->addFlash('danger', sprintf(
|
||||||
|
'%s failed: %s',
|
||||||
|
$config['label'],
|
||||||
|
$e->getMessage()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $config
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function buildCommandOptions(array $config, Request $request): array
|
||||||
|
{
|
||||||
|
$options = [];
|
||||||
|
foreach (($config['fields'] ?? []) as $field) {
|
||||||
|
$name = (string) $field['name'];
|
||||||
|
$type = $field['type'] ?? 'text';
|
||||||
|
$value = $request->request->get($name);
|
||||||
|
if ($type === 'checkbox') {
|
||||||
|
if ($value) {
|
||||||
|
// Symfony console options expect "--flag" style boolean toggles.
|
||||||
|
$options['--' . $name] = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$options['--' . $name] = $value;
|
||||||
|
}
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller\Admin;
|
|
||||||
|
|
||||||
use App\Form\SiteSettingsType;
|
|
||||||
use App\Repository\SettingRepository;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
|
||||||
class SiteSettingsController extends AbstractController
|
|
||||||
{
|
|
||||||
#[Route('/admin/settings', name: 'admin_settings', methods: ['GET', 'POST'])]
|
|
||||||
public function settings(Request $request, SettingRepository $settings): Response
|
|
||||||
{
|
|
||||||
$form = $this->createForm(SiteSettingsType::class);
|
|
||||||
$form->get('SPOTIFY_CLIENT_ID')->setData($settings->getValue('SPOTIFY_CLIENT_ID'));
|
|
||||||
$form->get('SPOTIFY_CLIENT_SECRET')->setData($settings->getValue('SPOTIFY_CLIENT_SECRET'));
|
|
||||||
|
|
||||||
$form->handleRequest($request);
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
|
||||||
$settings->setValue('SPOTIFY_CLIENT_ID', (string) $form->get('SPOTIFY_CLIENT_ID')->getData());
|
|
||||||
$settings->setValue('SPOTIFY_CLIENT_SECRET', (string) $form->get('SPOTIFY_CLIENT_SECRET')->getData());
|
|
||||||
$this->addFlash('success', 'Settings saved.');
|
|
||||||
return $this->redirectToRoute('admin_settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('admin/settings.html.twig', [
|
|
||||||
'form' => $form->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
124
src/Controller/Admin/UserController.php
Normal file
124
src/Controller/Admin/UserController.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?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\Component\Security\Http\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 $entityManager,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()) {
|
||||||
|
// Form collects only high-level metadata; everything else is defaulted here.
|
||||||
|
$plainPassword = (string) $form->get('plainPassword')->getData();
|
||||||
|
$newUser = new User();
|
||||||
|
$newUser->setEmail($formData->email);
|
||||||
|
$newUser->setDisplayName($formData->displayName);
|
||||||
|
$newUser->setPassword($this->passwordHasher->hashPassword($newUser, $plainPassword));
|
||||||
|
$this->entityManager->persist($newUser);
|
||||||
|
$this->entityManager->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()) {
|
||||||
|
// Protect against accidental lockouts by blocking self-deletes.
|
||||||
|
$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->entityManager->remove($target);
|
||||||
|
$this->entityManager->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) {
|
||||||
|
// Toggle-style UX: hitting the endpoint again demotes the moderator.
|
||||||
|
$filtered = array_values(array_filter($roles, static fn(string $role) => $role !== 'ROLE_MODERATOR'));
|
||||||
|
$target->setRoles($filtered);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->addFlash('success', 'Moderator privileges removed.');
|
||||||
|
} else {
|
||||||
|
$roles[] = 'ROLE_MODERATOR';
|
||||||
|
$target->setRoles(array_values(array_unique($roles)));
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$this->addFlash('success', 'User promoted to moderator.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->redirectToRoute('admin_users');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -2,84 +2,121 @@
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Service\SpotifyClient;
|
use App\Dto\AlbumSearchCriteria;
|
||||||
|
use App\Entity\Album;
|
||||||
use App\Entity\Review;
|
use App\Entity\Review;
|
||||||
|
use App\Entity\User;
|
||||||
use App\Form\ReviewType;
|
use App\Form\ReviewType;
|
||||||
|
use App\Form\AlbumType;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
|
use App\Service\AlbumSearchService;
|
||||||
|
use App\Service\UploadStorage;
|
||||||
|
use App\Service\SpotifyClient;
|
||||||
|
use App\Service\SpotifyGenreResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Form\FormInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumController orchestrates search, CRUD, and review entry on albums.
|
||||||
|
*/
|
||||||
class AlbumController extends AbstractController
|
class AlbumController extends AbstractController
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly UploadStorage $uploadStorage,
|
||||||
|
private readonly AlbumSearchService $albumSearch,
|
||||||
|
private readonly SpotifyGenreResolver $genreResolver,
|
||||||
|
private readonly int $searchLimit = 20
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches Spotify plus local albums and decorates results with review stats.
|
||||||
|
*/
|
||||||
#[Route('/', name: 'album_search', methods: ['GET'])]
|
#[Route('/', name: 'album_search', methods: ['GET'])]
|
||||||
public function search(Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviewRepository): Response
|
public function search(Request $request): Response
|
||||||
{
|
{
|
||||||
$query = trim((string) $request->query->get('q', ''));
|
$criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit);
|
||||||
$albumName = trim($request->query->getString('album', ''));
|
$result = $this->albumSearch->search($criteria);
|
||||||
$artist = trim($request->query->getString('artist', ''));
|
|
||||||
// Accept empty strings and validate manually to avoid FILTER_NULL_ON_FAILURE issues
|
|
||||||
$yearFromRaw = trim((string) $request->query->get('year_from', ''));
|
|
||||||
$yearToRaw = trim((string) $request->query->get('year_to', ''));
|
|
||||||
$yearFrom = (preg_match('/^\d{4}$/', $yearFromRaw)) ? (int) $yearFromRaw : 0;
|
|
||||||
$yearTo = (preg_match('/^\d{4}$/', $yearToRaw)) ? (int) $yearToRaw : 0;
|
|
||||||
$albums = [];
|
|
||||||
$stats = [];
|
|
||||||
|
|
||||||
// Build Spotify fielded search if advanced inputs are supplied
|
|
||||||
$advancedUsed = ($albumName !== '' || $artist !== '' || $yearFrom > 0 || $yearTo > 0);
|
|
||||||
$q = $query;
|
|
||||||
if ($advancedUsed) {
|
|
||||||
$parts = [];
|
|
||||||
if ($albumName !== '') { $parts[] = 'album:' . $albumName; }
|
|
||||||
if ($artist !== '') { $parts[] = 'artist:' . $artist; }
|
|
||||||
if ($yearFrom > 0 || $yearTo > 0) {
|
|
||||||
if ($yearFrom > 0 && $yearTo > 0 && $yearTo >= $yearFrom) {
|
|
||||||
$parts[] = 'year:' . $yearFrom . '-' . $yearTo;
|
|
||||||
} else {
|
|
||||||
$y = $yearFrom > 0 ? $yearFrom : $yearTo;
|
|
||||||
$parts[] = 'year:' . $y;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// also include free-text if provided
|
|
||||||
if ($query !== '') { $parts[] = $query; }
|
|
||||||
$q = implode(' ', $parts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($q !== '') {
|
|
||||||
$result = $spotifyClient->searchAlbums($q, 20);
|
|
||||||
$albums = $result['albums']['items'] ?? [];
|
|
||||||
if ($albums) {
|
|
||||||
$ids = array_values(array_map(static fn($a) => $a['id'] ?? null, $albums));
|
|
||||||
$ids = array_filter($ids, static fn($v) => is_string($v) && $v !== '');
|
|
||||||
if ($ids) {
|
|
||||||
$stats = $reviewRepository->getAggregatesForAlbumIds($ids);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('album/search.html.twig', [
|
return $this->render('album/search.html.twig', [
|
||||||
'query' => $query,
|
'query' => $criteria->query,
|
||||||
'album' => $albumName,
|
'album' => $criteria->albumName,
|
||||||
'artist' => $artist,
|
'artist' => $criteria->artist,
|
||||||
'year_from' => $yearFrom ?: '',
|
'genre' => $criteria->genre,
|
||||||
'year_to' => $yearTo ?: '',
|
'year_from' => $criteria->yearFrom ?? '',
|
||||||
'albums' => $albums,
|
'year_to' => $criteria->yearTo ?? '',
|
||||||
'stats' => $stats,
|
'albums' => $result->albums,
|
||||||
|
'stats' => $result->stats,
|
||||||
|
'savedIds' => $result->savedIds,
|
||||||
|
'source' => $criteria->source,
|
||||||
|
'spotifyConfigured' => $this->albumSearch->isSpotifyConfigured(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
|
/**
|
||||||
public function show(string $id, Request $request, SpotifyClient $spotifyClient, ReviewRepository $reviews, EntityManagerInterface $em): Response
|
* Creates a user-authored album entry.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[Route('/albums/new', name: 'album_new', methods: ['GET', 'POST'])]
|
||||||
|
public function create(Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
$album = $spotifyClient->getAlbum($id);
|
$album = new Album();
|
||||||
if ($album === null) {
|
$album->setSource('user');
|
||||||
throw $this->createNotFoundException('Album not found');
|
$form = $this->createForm(AlbumType::class, $album);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$this->normalizeAlbumFormData($album);
|
||||||
|
$user = $this->getUser();
|
||||||
|
if ($user instanceof User) {
|
||||||
|
$album->setCreatedBy($user);
|
||||||
|
}
|
||||||
|
$this->handleAlbumCoverUpload($album, $form);
|
||||||
|
$album->setLocalId($this->generateLocalId($albumRepo));
|
||||||
|
$em->persist($album);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Album created.');
|
||||||
|
return $this->redirectToRoute('album_show', ['id' => $album->getLocalId()]);
|
||||||
|
}
|
||||||
|
return $this->render('album/new.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$existing = $reviews->findBy(['spotifyAlbumId' => $id], ['createdAt' => 'DESC']);
|
/**
|
||||||
|
* Renders a detailed album view plus inline review form.
|
||||||
|
*/
|
||||||
|
#[Route('/albums/{id}', name: 'album_show', methods: ['GET', 'POST'])]
|
||||||
|
public function show(string $id, Request $request, SpotifyClient $spotify, ReviewRepository $reviewRepo, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$albumEntity = $this->findAlbum($id, $albumRepo);
|
||||||
|
$isSaved = $albumEntity !== null;
|
||||||
|
if (!$albumEntity) {
|
||||||
|
// Album has never been saved locally, so hydrate it via Spotify before rendering.
|
||||||
|
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||||
|
if ($spotifyAlbum === null) {
|
||||||
|
throw $this->createNotFoundException('Album not found');
|
||||||
|
}
|
||||||
|
$albumEntity = $this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||||
|
$em->flush();
|
||||||
|
} else {
|
||||||
|
if ($this->syncSpotifyTracklistIfNeeded($albumEntity, $albumRepo, $trackRepo, $spotify)) {
|
||||||
|
// Track sync mutated the entity: persist before we build template arrays.
|
||||||
|
$em->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$albumCard = $albumEntity->toTemplateArray();
|
||||||
|
$canManage = $this->canManageAlbum($albumEntity);
|
||||||
|
$trackRows = array_map(static fn($track) => $track->toTemplateArray(), $albumEntity->getTracks()->toArray());
|
||||||
|
|
||||||
|
$existing = $reviewRepo->findBy(['album' => $albumEntity], ['createdAt' => 'DESC']);
|
||||||
$count = count($existing);
|
$count = count($existing);
|
||||||
$avg = 0.0;
|
$avg = 0.0;
|
||||||
if ($count > 0) {
|
if ($count > 0) {
|
||||||
@@ -88,11 +125,8 @@ class AlbumController extends AbstractController
|
|||||||
$avg = round($sum / $count, 1);
|
$avg = round($sum / $count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-populate required album metadata before validation so entity constraints pass
|
|
||||||
$review = new Review();
|
$review = new Review();
|
||||||
$review->setSpotifyAlbumId($id);
|
$review->setAlbum($albumEntity);
|
||||||
$review->setAlbumName($album['name'] ?? '');
|
|
||||||
$review->setAlbumArtist(implode(', ', array_map(fn($a) => $a['name'], $album['artists'] ?? [])));
|
|
||||||
|
|
||||||
$form = $this->createForm(ReviewType::class, $review);
|
$form = $this->createForm(ReviewType::class, $review);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
@@ -106,14 +140,258 @@ class AlbumController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('album/show.html.twig', [
|
return $this->render('album/show.html.twig', [
|
||||||
'album' => $album,
|
'album' => $albumCard,
|
||||||
'albumId' => $id,
|
'albumId' => $id,
|
||||||
|
'isSaved' => $isSaved,
|
||||||
|
'allowedEdit' => $canManage,
|
||||||
|
'allowedDelete' => $canManage,
|
||||||
'reviews' => $existing,
|
'reviews' => $existing,
|
||||||
'avg' => $avg,
|
'avg' => $avg,
|
||||||
'count' => $count,
|
'count' => $count,
|
||||||
'form' => $form->createView(),
|
'form' => $form->createView(),
|
||||||
|
'albumOwner' => $albumEntity->getCreatedBy(),
|
||||||
|
'albumCreatedAt' => $albumEntity->getCreatedAt(),
|
||||||
|
'tracks' => $trackRows,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a Spotify album locally for quicker access.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[Route('/albums/{id}/save', name: 'album_save', methods: ['POST'])]
|
||||||
|
public function save(string $id, Request $request, SpotifyClient $spotify, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$token = (string) $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('save-album-' . $id, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
$existing = $albumRepo->findOneBySpotifyId($id);
|
||||||
|
if (!$existing) {
|
||||||
|
$spotifyAlbum = $spotify->getAlbumWithTracks($id);
|
||||||
|
if ($spotifyAlbum === null) {
|
||||||
|
throw $this->createNotFoundException('Album not found');
|
||||||
|
}
|
||||||
|
$this->persistSpotifyAlbumPayload($spotifyAlbum, $albumRepo, $trackRepo);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Album saved.');
|
||||||
|
} else {
|
||||||
|
$this->addFlash('info', 'Album already saved.');
|
||||||
|
}
|
||||||
|
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a user-created album when authorized.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[Route('/albums/{id}/delete', name: 'album_delete', methods: ['POST'])]
|
||||||
|
public function delete(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$token = (string) $request->request->get('_token');
|
||||||
|
if (!$this->isCsrfTokenValid('delete-album-' . $id, $token)) {
|
||||||
|
throw $this->createAccessDeniedException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
$album = $this->findAlbum($id, $albumRepo);
|
||||||
|
if ($album) {
|
||||||
|
$this->ensureCanManageAlbum($album);
|
||||||
|
if ($album->getSource() === 'user') {
|
||||||
|
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||||
|
}
|
||||||
|
$em->remove($album);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Album deleted.');
|
||||||
|
} else {
|
||||||
|
$this->addFlash('info', 'Album not found.');
|
||||||
|
}
|
||||||
|
return $this->redirectToRoute('album_search');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique user album identifier.
|
||||||
|
*/
|
||||||
|
private function generateLocalId(AlbumRepository $albumRepo): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$id = 'u_' . bin2hex(random_bytes(6));
|
||||||
|
} while ($albumRepo->findOneByLocalId($id) !== null);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a human-entered release date (YYYY[-MM[-DD]]).
|
||||||
|
*/
|
||||||
|
private function normalizeReleaseDate(?string $input): ?string
|
||||||
|
{
|
||||||
|
if ($input === null || trim($input) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$s = trim($input);
|
||||||
|
// YYYY
|
||||||
|
if (preg_match('/^\d{4}$/', $s)) {
|
||||||
|
return $s . '-01-01';
|
||||||
|
}
|
||||||
|
// YYYY-MM
|
||||||
|
if (preg_match('/^\d{4}-\d{2}$/', $s)) {
|
||||||
|
return $s . '-01';
|
||||||
|
}
|
||||||
|
// YYYY-MM-DD
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) {
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
// Fallback: attempt to parse
|
||||||
|
try {
|
||||||
|
// Trust PHP's parser only as a last resort (it accepts many human formats).
|
||||||
|
$dt = new \DateTimeImmutable($s);
|
||||||
|
return $dt->format('Y-m-d');
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edits a saved album when the current user may manage it.
|
||||||
|
*/
|
||||||
|
#[IsGranted('ROLE_USER')]
|
||||||
|
#[Route('/albums/{id}/edit', name: 'album_edit', methods: ['GET', 'POST'])]
|
||||||
|
public function edit(string $id, Request $request, AlbumRepository $albumRepo, EntityManagerInterface $em): Response
|
||||||
|
{
|
||||||
|
$album = $this->findAlbum($id, $albumRepo);
|
||||||
|
if (!$album) {
|
||||||
|
throw $this->createNotFoundException('Album not found');
|
||||||
|
}
|
||||||
|
$this->ensureCanManageAlbum($album);
|
||||||
|
|
||||||
|
$form = $this->createForm(AlbumType::class, $album);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$this->normalizeAlbumFormData($album);
|
||||||
|
$this->handleAlbumCoverUpload($album, $form);
|
||||||
|
$em->flush();
|
||||||
|
$this->addFlash('success', 'Album updated.');
|
||||||
|
return $this->redirectToRoute('album_show', ['id' => $id]);
|
||||||
|
}
|
||||||
|
return $this->render('album/edit.html.twig', [
|
||||||
|
'form' => $form->createView(),
|
||||||
|
'albumId' => $id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up an album by either local or Spotify identifier.
|
||||||
|
*/
|
||||||
|
private function findAlbum(string $id, AlbumRepository $albumRepo): ?Album
|
||||||
|
{
|
||||||
|
$local = $albumRepo->findOneByLocalId($id);
|
||||||
|
if ($local instanceof Album) {
|
||||||
|
return $local;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $albumRepo->findOneBySpotifyId($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when the authenticated user can manage the album.
|
||||||
|
*/
|
||||||
|
private function canManageAlbum(Album $album): bool
|
||||||
|
{
|
||||||
|
if ($this->isGranted('ROLE_MODERATOR')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $album->getSource() === 'user' && $this->isAlbumOwner($album);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws if the authenticated user cannot manage the album.
|
||||||
|
*/
|
||||||
|
private function ensureCanManageAlbum(Album $album): void
|
||||||
|
{
|
||||||
|
if (!$this->canManageAlbum($album)) {
|
||||||
|
throw $this->createAccessDeniedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether the current user created the album.
|
||||||
|
*/
|
||||||
|
private function isAlbumOwner(Album $album): bool
|
||||||
|
{
|
||||||
|
$user = $this->getUser();
|
||||||
|
return $user instanceof User && $album->getCreatedBy()?->getId() === $user->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeAlbumFormData(Album $album): void
|
||||||
|
{
|
||||||
|
$album->setReleaseDate($this->normalizeReleaseDate($album->getReleaseDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleAlbumCoverUpload(Album $album, FormInterface $form): void
|
||||||
|
{
|
||||||
|
if ($album->getSource() !== 'user' || !$form->has('coverUpload')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$file = $form->get('coverUpload')->getData();
|
||||||
|
if ($file instanceof UploadedFile) {
|
||||||
|
$this->uploadStorage->remove($album->getCoverImagePath());
|
||||||
|
$album->setCoverImagePath($this->uploadStorage->storeAlbumCover($file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $spotifyAlbum
|
||||||
|
*/
|
||||||
|
private function persistSpotifyAlbumPayload(array $spotifyAlbum, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo): Album
|
||||||
|
{
|
||||||
|
// Bring genres along when we persist Spotify albums so templates can display them immediately.
|
||||||
|
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
|
||||||
|
$albumId = (string) ($spotifyAlbum['id'] ?? '');
|
||||||
|
$album = $albumRepo->upsertFromSpotifyAlbum(
|
||||||
|
$spotifyAlbum,
|
||||||
|
$albumId !== '' ? ($genresMap[$albumId] ?? []) : []
|
||||||
|
);
|
||||||
|
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||||
|
if (is_array($tracks) && $tracks !== []) {
|
||||||
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
|
$album->setTotalTracks(count($tracks));
|
||||||
|
}
|
||||||
|
return $album;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncSpotifyTracklistIfNeeded(Album $album, AlbumRepository $albumRepo, AlbumTrackRepository $trackRepo, SpotifyClient $spotify): bool
|
||||||
|
{
|
||||||
|
if ($album->getSource() !== 'spotify') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$spotifyId = $album->getSpotifyId();
|
||||||
|
if ($spotifyId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$storedCount = $album->getTracks()->count();
|
||||||
|
$needsSync = $storedCount === 0;
|
||||||
|
if (!$needsSync && $album->getTotalTracks() > 0 && $storedCount !== $album->getTotalTracks()) {
|
||||||
|
// Spotify track counts do not match what we have stored; re-sync to avoid stale data.
|
||||||
|
$needsSync = true;
|
||||||
|
}
|
||||||
|
if (!$needsSync) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$spotifyAlbum = $spotify->getAlbumWithTracks($spotifyId);
|
||||||
|
if ($spotifyAlbum === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Rehydrate genres during syncs as well, in case Spotify has updated the metadata.
|
||||||
|
$genresMap = $this->genreResolver->resolveGenresForAlbums([$spotifyAlbum]);
|
||||||
|
$albumGenres = $genresMap[$spotifyId] ?? [];
|
||||||
|
$albumRepo->upsertFromSpotifyAlbum($spotifyAlbum, $albumGenres);
|
||||||
|
$tracks = $spotifyAlbum['tracks']['items'] ?? [];
|
||||||
|
if (!is_array($tracks) || $tracks === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$trackRepo->replaceAlbumTracks($album, $tracks);
|
||||||
|
$album->setTotalTracks(count($tracks));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Form\RegistrationFormType;
|
use App\Form\RegistrationFormType;
|
||||||
|
use App\Service\RegistrationToggle;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -12,11 +13,26 @@ use Symfony\Component\HttpFoundation\JsonResponse;
|
|||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrationController handles signup workflows (HTML + XHR).
|
||||||
|
*/
|
||||||
class RegistrationController extends AbstractController
|
class RegistrationController extends AbstractController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Processes registration submissions or serves the form modal.
|
||||||
|
*/
|
||||||
#[Route('/register', name: 'app_register')]
|
#[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
|
// For GET (non-XHR), redirect to home and let the modal open
|
||||||
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
|
if ($request->isMethod('GET') && !$request->isXmlHttpRequest()) {
|
||||||
return $this->redirectToRoute('album_search', ['auth' => 'register']);
|
return $this->redirectToRoute('album_search', ['auth' => 'register']);
|
||||||
|
|||||||
@@ -4,31 +4,35 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\Review;
|
use App\Entity\Review;
|
||||||
use App\Form\ReviewType;
|
use App\Form\ReviewType;
|
||||||
use App\Repository\ReviewRepository;
|
|
||||||
use App\Service\SpotifyClient;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Bundle\SecurityBundle\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReviewController wires up CRUD routes for reviews and keeps users inside the album-focused flow.
|
||||||
|
*/
|
||||||
#[Route('/reviews')]
|
#[Route('/reviews')]
|
||||||
class ReviewController extends AbstractController
|
class ReviewController extends AbstractController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Maintains backwards compatibility by redirecting to the dashboard.
|
||||||
|
*/
|
||||||
#[Route('', name: 'review_index', methods: ['GET'])]
|
#[Route('', name: 'review_index', methods: ['GET'])]
|
||||||
public function index(ReviewRepository $reviewRepository): Response
|
public function index(): Response
|
||||||
{
|
{
|
||||||
$reviews = $reviewRepository->findLatest(50);
|
return $this->redirectToRoute('account_dashboard');
|
||||||
return $this->render('review/index.html.twig', [
|
|
||||||
'reviews' => $reviews,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nudges users back to the album flow so new reviews always start from a specific album.
|
||||||
|
*/
|
||||||
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
#[Route('/new', name: 'review_new', methods: ['GET', 'POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function new(Request $request): Response
|
public function create(Request $request): Response
|
||||||
{
|
{
|
||||||
$albumId = (string) $request->query->get('album_id', '');
|
$albumId = (string) $request->query->get('album_id', '');
|
||||||
if ($albumId !== '') {
|
if ($albumId !== '') {
|
||||||
@@ -38,6 +42,9 @@ class ReviewController extends AbstractController
|
|||||||
return $this->redirectToRoute('album_search');
|
return $this->redirectToRoute('album_search');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a read-only page for a single review.
|
||||||
|
*/
|
||||||
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
#[Route('/{id}', name: 'review_show', requirements: ['id' => '\\d+'], methods: ['GET'])]
|
||||||
public function show(Review $review): Response
|
public function show(Review $review): Response
|
||||||
{
|
{
|
||||||
@@ -46,6 +53,9 @@ class ReviewController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles review edits by running the voter, binding the form, and saving valid updates.
|
||||||
|
*/
|
||||||
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
|
#[Route('/{id}/edit', name: 'review_edit', requirements: ['id' => '\\d+'], methods: ['GET', 'POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
|
public function edit(Request $request, Review $review, EntityManagerInterface $em): Response
|
||||||
@@ -67,6 +77,9 @@ class ReviewController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a review after both the CSRF token and voter checks pass.
|
||||||
|
*/
|
||||||
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
#[Route('/{id}/delete', name: 'review_delete', requirements: ['id' => '\\d+'], methods: ['POST'])]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('ROLE_USER')]
|
||||||
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
|
public function delete(Request $request, Review $review, EntityManagerInterface $em): RedirectResponse
|
||||||
@@ -77,10 +90,9 @@ class ReviewController extends AbstractController
|
|||||||
$em->flush();
|
$em->flush();
|
||||||
$this->addFlash('success', 'Review deleted.');
|
$this->addFlash('success', 'Review deleted.');
|
||||||
}
|
}
|
||||||
return $this->redirectToRoute('review_index');
|
return $this->redirectToRoute('account_dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchAlbumById no longer needed; album view handles retrieval and creation
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ use Symfony\Component\HttpFoundation\RedirectResponse;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SecurityController keeps login/logout routes alive for the firewall.
|
||||||
|
*/
|
||||||
class SecurityController extends AbstractController
|
class SecurityController extends AbstractController
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Redirects GET requests to the SPA and lets Symfony handle POST auth.
|
||||||
|
*/
|
||||||
#[Route('/login', name: 'app_login')]
|
#[Route('/login', name: 'app_login')]
|
||||||
public function login(Request $request, AuthenticationUtils $authenticationUtils): Response
|
public function login(Request $request): Response
|
||||||
{
|
{
|
||||||
// Keep this route so the firewall can use it as check_path for POST.
|
// Keep this route so the firewall can use it as check_path for POST.
|
||||||
// For GET requests, redirect to the main page and let the modal handle UI.
|
// For GET requests, redirect to the main page and let the modal handle UI.
|
||||||
@@ -22,6 +27,9 @@ class SecurityController extends AbstractController
|
|||||||
return new Response(status: 204);
|
return new Response(status: 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symfony intercepts this route to log the user out.
|
||||||
|
*/
|
||||||
#[Route('/logout', name: 'app_logout')]
|
#[Route('/logout', name: 'app_logout')]
|
||||||
public function logout(): void
|
public function logout(): void
|
||||||
{
|
{
|
||||||
|
|||||||
31
src/Dto/AdminUserData.php
Normal file
31
src/Dto/AdminUserData.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminUserData carries the lightweight fields needed when admins create or edit
|
||||||
|
* users from the back office without touching the `User` entity directly.
|
||||||
|
* Used to allow user creation in the user management panel without invalidating active token.
|
||||||
|
* (This took too long to figure out)
|
||||||
|
|
||||||
|
*/
|
||||||
|
class AdminUserData
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Email address for the managed user.
|
||||||
|
*/
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
#[Assert\Email]
|
||||||
|
public string $email = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional public display name.
|
||||||
|
*/
|
||||||
|
#[Assert\Length(max: 120)]
|
||||||
|
public ?string $displayName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
107
src/Dto/AlbumSearchCriteria.php
Normal file
107
src/Dto/AlbumSearchCriteria.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumSearchCriteria captures normalized filters for album discovery.
|
||||||
|
*/
|
||||||
|
final class AlbumSearchCriteria
|
||||||
|
{
|
||||||
|
/** Free-form query that mixes album, artist, and keyword matches. */
|
||||||
|
public readonly string $query;
|
||||||
|
|
||||||
|
/** Explicit album title filter supplied via the advanced panel. */
|
||||||
|
public readonly string $albumName;
|
||||||
|
|
||||||
|
/** Explicit artist filter supplied via the advanced panel. */
|
||||||
|
public readonly string $artist;
|
||||||
|
|
||||||
|
/** Genre substring to match within stored Spotify/user genres. */
|
||||||
|
public readonly string $genre;
|
||||||
|
|
||||||
|
/** Lower bound (inclusive) of the release year filter, if any. */
|
||||||
|
public readonly ?int $yearFrom;
|
||||||
|
|
||||||
|
/** Upper bound (inclusive) of the release year filter, if any. */
|
||||||
|
public readonly ?int $yearTo;
|
||||||
|
|
||||||
|
/** Requested source scope: `all`, `spotify`, or `user`. */
|
||||||
|
public readonly string $source;
|
||||||
|
|
||||||
|
/** Maximum number of results the search should return. */
|
||||||
|
public readonly int $limit;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
string $query,
|
||||||
|
string $albumName,
|
||||||
|
string $artist,
|
||||||
|
string $genre,
|
||||||
|
?int $yearFrom,
|
||||||
|
?int $yearTo,
|
||||||
|
string $source,
|
||||||
|
int $limit
|
||||||
|
) {
|
||||||
|
$this->query = $query;
|
||||||
|
$this->albumName = $albumName;
|
||||||
|
$this->artist = $artist;
|
||||||
|
$this->genre = $genre;
|
||||||
|
$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', '')),
|
||||||
|
genre: trim($request->query->getString('genre', '')),
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the search should include Spotify-sourced albums.
|
||||||
|
*/
|
||||||
|
public function shouldUseSpotify(): bool
|
||||||
|
{
|
||||||
|
return $this->source === 'all' || $this->source === 'spotify';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether the search should include user-created albums.
|
||||||
|
*/
|
||||||
|
public function shouldUseUserCatalog(): 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
29
src/Dto/AlbumSearchResult.php
Normal file
29
src/Dto/AlbumSearchResult.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumSearchResult is the value object returned by AlbumSearchService.
|
||||||
|
* It keeps the original criteria alongside the merged album payloads,
|
||||||
|
* per-album review stats, and a list of Spotify IDs stored locally.
|
||||||
|
*/
|
||||||
|
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(
|
||||||
|
/** Filters that produced this result set. */
|
||||||
|
public readonly AlbumSearchCriteria $criteria,
|
||||||
|
/** Album payloads ready for Twig rendering. */
|
||||||
|
public readonly array $albums,
|
||||||
|
/** Per-album review aggregates keyed by album ID. */
|
||||||
|
public readonly array $stats,
|
||||||
|
/** List of Spotify IDs saved locally for quick lookup. */
|
||||||
|
public readonly array $savedIds
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
393
src/Entity/Album.php
Normal file
393
src/Entity/Album.php
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Entity\AlbumTrack;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\AlbumRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Album aggregates Spotify or user-submitted metadata persisted in the catalog.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: AlbumRepository::class)]
|
||||||
|
#[ORM\Table(name: 'albums')]
|
||||||
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
class Album
|
||||||
|
{
|
||||||
|
#[ORM\OneToMany(mappedBy: 'album', targetEntity: AlbumTrack::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||||
|
#[ORM\OrderBy(['discNumber' => 'ASC', 'trackNumber' => 'ASC'])]
|
||||||
|
private Collection $tracks;
|
||||||
|
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
// For Spotify-sourced albums; null for user-created
|
||||||
|
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||||
|
private ?string $spotifyId = null;
|
||||||
|
|
||||||
|
// Public identifier for user-created albums (e.g., "u_abc123"); null for Spotify
|
||||||
|
#[ORM\Column(type: 'string', length: 64, unique: true, nullable: true)]
|
||||||
|
private ?string $localId = null;
|
||||||
|
|
||||||
|
// 'spotify' or 'user'
|
||||||
|
#[ORM\Column(type: 'string', length: 16)]
|
||||||
|
private string $source = 'spotify';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255)]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $artists = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
#[ORM\Column(type: 'json')]
|
||||||
|
private array $genres = [];
|
||||||
|
|
||||||
|
// Stored as given by Spotify: YYYY or YYYY-MM or YYYY-MM-DD
|
||||||
|
#[ORM\Column(type: 'string', length: 20, nullable: true)]
|
||||||
|
private ?string $releaseDate = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $totalTracks = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
|
private ?string $coverUrl = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $coverImagePath = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
|
private ?string $externalUrl = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
|
private ?User $createdBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->tracks = new ArrayCollection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes timestamps right before first persistence.
|
||||||
|
*/
|
||||||
|
#[ORM\PrePersist]
|
||||||
|
public function onPrePersist(): void
|
||||||
|
{
|
||||||
|
$now = new \DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes the updated timestamp prior to every update.
|
||||||
|
*/
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function onPreUpdate(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the database identifier.
|
||||||
|
*/
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Spotify album identifier when sourced from Spotify.
|
||||||
|
*/
|
||||||
|
public function getSpotifyId(): ?string
|
||||||
|
{
|
||||||
|
return $this->spotifyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the Spotify album identifier.
|
||||||
|
*/
|
||||||
|
public function setSpotifyId(?string $spotifyId): void
|
||||||
|
{
|
||||||
|
$this->spotifyId = $spotifyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the local unique identifier for user-created albums.
|
||||||
|
*/
|
||||||
|
public function getLocalId(): ?string
|
||||||
|
{
|
||||||
|
return $this->localId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the local identifier for user-created albums.
|
||||||
|
*/
|
||||||
|
public function setLocalId(?string $localId): void
|
||||||
|
{
|
||||||
|
$this->localId = $localId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the album source flag ("spotify" or "user").
|
||||||
|
*/
|
||||||
|
public function getSource(): string
|
||||||
|
{
|
||||||
|
return $this->source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the album source flag ("spotify" or "user").
|
||||||
|
*/
|
||||||
|
public function setSource(string $source): void
|
||||||
|
{
|
||||||
|
$this->source = $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the human readable album title.
|
||||||
|
*/
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the human readable album title.
|
||||||
|
*/
|
||||||
|
public function setName(string $name): void
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string> Ordered performer names.
|
||||||
|
*/
|
||||||
|
public function getArtists(): array
|
||||||
|
{
|
||||||
|
return $this->artists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $artists Ordered performer names.
|
||||||
|
*/
|
||||||
|
public function setArtists(array $artists): void
|
||||||
|
{
|
||||||
|
$this->artists = array_values($artists);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function getGenres(): array
|
||||||
|
{
|
||||||
|
return $this->genres;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $genres
|
||||||
|
*/
|
||||||
|
public function setGenres(array $genres): void
|
||||||
|
{
|
||||||
|
$normalized = array_map(
|
||||||
|
static fn($genre) => trim((string) $genre),
|
||||||
|
$genres
|
||||||
|
);
|
||||||
|
$filtered = array_values(array_filter($normalized, static fn($genre) => $genre !== ''));
|
||||||
|
$this->genres = $filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored release date string.
|
||||||
|
*/
|
||||||
|
public function getReleaseDate(): ?string
|
||||||
|
{
|
||||||
|
return $this->releaseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the release date string (YYYY[-MM[-DD]]).
|
||||||
|
*/
|
||||||
|
public function setReleaseDate(?string $releaseDate): void
|
||||||
|
{
|
||||||
|
$this->releaseDate = $releaseDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the total number of tracks.
|
||||||
|
*/
|
||||||
|
public function getTotalTracks(): int
|
||||||
|
{
|
||||||
|
return $this->totalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the track count.
|
||||||
|
*/
|
||||||
|
public function setTotalTracks(int $totalTracks): void
|
||||||
|
{
|
||||||
|
$this->totalTracks = $totalTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the preferred cover art URL.
|
||||||
|
*/
|
||||||
|
public function getCoverUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->coverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the preferred cover art URL.
|
||||||
|
*/
|
||||||
|
public function setCoverUrl(?string $coverUrl): void
|
||||||
|
{
|
||||||
|
$this->coverUrl = $coverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an external link (defaults to Spotify).
|
||||||
|
*/
|
||||||
|
public function getExternalUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->externalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the external reference link.
|
||||||
|
*/
|
||||||
|
public function setExternalUrl(?string $externalUrl): void
|
||||||
|
{
|
||||||
|
$this->externalUrl = $externalUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the user that created the album, when applicable.
|
||||||
|
*/
|
||||||
|
public function getCreatedBy(): ?User
|
||||||
|
{
|
||||||
|
return $this->createdBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the locally stored cover image path for user albums.
|
||||||
|
*/
|
||||||
|
public function getCoverImagePath(): ?string
|
||||||
|
{
|
||||||
|
return $this->coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the locally stored cover image path for user albums.
|
||||||
|
*/
|
||||||
|
public function setCoverImagePath(?string $coverImagePath): void
|
||||||
|
{
|
||||||
|
$this->coverImagePath = $coverImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the owner responsible for the album.
|
||||||
|
*/
|
||||||
|
public function setCreatedBy(?User $user): void
|
||||||
|
{
|
||||||
|
$this->createdBy = $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the creation timestamp.
|
||||||
|
*/
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the last update timestamp.
|
||||||
|
*/
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shapes the entity to the payload Twig templates expect.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function toTemplateArray(): array
|
||||||
|
{
|
||||||
|
$images = [];
|
||||||
|
$imageUrl = $this->coverUrl;
|
||||||
|
if ($this->source === 'user' && $this->coverImagePath) {
|
||||||
|
$imageUrl = $this->coverImagePath;
|
||||||
|
}
|
||||||
|
if ($imageUrl) {
|
||||||
|
$images = [
|
||||||
|
['url' => $imageUrl],
|
||||||
|
['url' => $imageUrl],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$artists = array_map(static fn(string $n) => ['name' => $n], $this->artists);
|
||||||
|
$external = $this->externalUrl;
|
||||||
|
if ($external === null && $this->source === 'spotify' && $this->spotifyId) {
|
||||||
|
$external = 'https://open.spotify.com/album/' . $this->spotifyId;
|
||||||
|
}
|
||||||
|
$publicId = $this->source === 'user' ? (string) $this->localId : (string) $this->spotifyId;
|
||||||
|
$genres = array_slice($this->genres, 0, 5);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $publicId,
|
||||||
|
'name' => $this->name,
|
||||||
|
'images' => $images,
|
||||||
|
'artists' => $artists,
|
||||||
|
'genres' => $genres,
|
||||||
|
'release_date' => $this->releaseDate,
|
||||||
|
'total_tracks' => $this->totalTracks,
|
||||||
|
'external_urls' => [ 'spotify' => $external ],
|
||||||
|
'source' => $this->source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, AlbumTrack>
|
||||||
|
*/
|
||||||
|
public function getTracks(): Collection
|
||||||
|
{
|
||||||
|
return $this->tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addTrack(AlbumTrack $track): void
|
||||||
|
{
|
||||||
|
if (!$this->tracks->contains($track)) {
|
||||||
|
$this->tracks->add($track);
|
||||||
|
$track->setAlbum($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeTrack(AlbumTrack $track): void
|
||||||
|
{
|
||||||
|
if ($this->tracks->removeElement($track) && $track->getAlbum() === $this) {
|
||||||
|
$track->setAlbum(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
140
src/Entity/AlbumTrack.php
Normal file
140
src/Entity/AlbumTrack.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\AlbumTrackRepository;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumTrack persists individual tracks fetched from Spotify.
|
||||||
|
*/
|
||||||
|
#[ORM\Entity(repositoryClass: AlbumTrackRepository::class)]
|
||||||
|
#[ORM\Table(name: 'album_tracks')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_album_disc_track', columns: ['album_id', 'disc_number', 'track_number'])]
|
||||||
|
class AlbumTrack
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Album::class, inversedBy: 'tracks')]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Album $album = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 64, nullable: true)]
|
||||||
|
private ?string $spotifyTrackId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $discNumber = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $trackNumber = 1;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 512)]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $durationMs = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 1024, nullable: true)]
|
||||||
|
private ?string $previewUrl = null;
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAlbum(): ?Album
|
||||||
|
{
|
||||||
|
return $this->album;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAlbum(?Album $album): void
|
||||||
|
{
|
||||||
|
$this->album = $album;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSpotifyTrackId(): ?string
|
||||||
|
{
|
||||||
|
return $this->spotifyTrackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSpotifyTrackId(?string $spotifyTrackId): void
|
||||||
|
{
|
||||||
|
$this->spotifyTrackId = $spotifyTrackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDiscNumber(): int
|
||||||
|
{
|
||||||
|
return $this->discNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDiscNumber(int $discNumber): void
|
||||||
|
{
|
||||||
|
$this->discNumber = max(1, $discNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTrackNumber(): int
|
||||||
|
{
|
||||||
|
return $this->trackNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrackNumber(int $trackNumber): void
|
||||||
|
{
|
||||||
|
$this->trackNumber = max(1, $trackNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): void
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDurationMs(): int
|
||||||
|
{
|
||||||
|
return $this->durationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDurationMs(int $durationMs): void
|
||||||
|
{
|
||||||
|
$this->durationMs = max(0, $durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPreviewUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setPreviewUrl(?string $previewUrl): void
|
||||||
|
{
|
||||||
|
$this->previewUrl = $previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes the track for template rendering.
|
||||||
|
*
|
||||||
|
* @return array{disc:int,track:int,name:string,duration_label:string,duration_seconds:int,preview_url:?string}
|
||||||
|
*/
|
||||||
|
public function toTemplateArray(): array
|
||||||
|
{
|
||||||
|
$seconds = (int) floor($this->durationMs / 1000);
|
||||||
|
$minutes = intdiv($seconds, 60);
|
||||||
|
$remainingSeconds = $seconds % 60;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'disc' => $this->discNumber,
|
||||||
|
'track' => $this->trackNumber,
|
||||||
|
'name' => $this->name,
|
||||||
|
'duration_label' => sprintf('%d:%02d', $minutes, $remainingSeconds),
|
||||||
|
'duration_seconds' => $seconds,
|
||||||
|
'preview_url' => $this->previewUrl,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3,9 +3,13 @@
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use App\Repository\ReviewRepository;
|
use App\Repository\ReviewRepository;
|
||||||
|
use App\Entity\Album;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review captures a user-authored rating and narrative about an album.
|
||||||
|
*/
|
||||||
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
|
#[ORM\Entity(repositoryClass: ReviewRepository::class)]
|
||||||
#[ORM\Table(name: 'reviews')]
|
#[ORM\Table(name: 'reviews')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
@@ -20,17 +24,9 @@ class Review
|
|||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private ?User $author = null;
|
private ?User $author = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 64)]
|
#[ORM\ManyToOne(targetEntity: Album::class)]
|
||||||
#[Assert\NotBlank]
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
private string $spotifyAlbumId = '';
|
private ?Album $album = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
private string $albumName = '';
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 255)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
private string $albumArtist = '';
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'string', length: 160)]
|
#[ORM\Column(type: 'string', length: 160)]
|
||||||
#[Assert\NotBlank]
|
#[Assert\NotBlank]
|
||||||
@@ -52,6 +48,9 @@ class Review
|
|||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets timestamps prior to the first persist.
|
||||||
|
*/
|
||||||
#[ORM\PrePersist]
|
#[ORM\PrePersist]
|
||||||
public function onPrePersist(): void
|
public function onPrePersist(): void
|
||||||
{
|
{
|
||||||
@@ -60,29 +59,118 @@ class Review
|
|||||||
$this->updatedAt = $now;
|
$this->updatedAt = $now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the modified timestamp before every update.
|
||||||
|
*/
|
||||||
#[ORM\PreUpdate]
|
#[ORM\PreUpdate]
|
||||||
public function onPreUpdate(): void
|
public function onPreUpdate(): void
|
||||||
{
|
{
|
||||||
$this->updatedAt = new \DateTimeImmutable();
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int { return $this->id; }
|
/**
|
||||||
public function getAuthor(): ?User { return $this->author; }
|
* Returns the database identifier.
|
||||||
public function setAuthor(User $author): void { $this->author = $author; }
|
*/
|
||||||
public function getSpotifyAlbumId(): string { return $this->spotifyAlbumId; }
|
public function getId(): ?int
|
||||||
public function setSpotifyAlbumId(string $spotifyAlbumId): void { $this->spotifyAlbumId = $spotifyAlbumId; }
|
{
|
||||||
public function getAlbumName(): string { return $this->albumName; }
|
return $this->id;
|
||||||
public function setAlbumName(string $albumName): void { $this->albumName = $albumName; }
|
}
|
||||||
public function getAlbumArtist(): string { return $this->albumArtist; }
|
|
||||||
public function setAlbumArtist(string $albumArtist): void { $this->albumArtist = $albumArtist; }
|
/**
|
||||||
public function getTitle(): string { return $this->title; }
|
* Returns the authoring user.
|
||||||
public function setTitle(string $title): void { $this->title = $title; }
|
*/
|
||||||
public function getContent(): string { return $this->content; }
|
public function getAuthor(): ?User
|
||||||
public function setContent(string $content): void { $this->content = $content; }
|
{
|
||||||
public function getRating(): int { return $this->rating; }
|
return $this->author;
|
||||||
public function setRating(int $rating): void { $this->rating = $rating; }
|
}
|
||||||
public function getCreatedAt(): ?\DateTimeImmutable { return $this->createdAt; }
|
|
||||||
public function getUpdatedAt(): ?\DateTimeImmutable { return $this->updatedAt; }
|
/**
|
||||||
|
* Assigns the authoring user.
|
||||||
|
*/
|
||||||
|
public function setAuthor(User $author): void
|
||||||
|
{
|
||||||
|
$this->author = $author;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reviewed album.
|
||||||
|
*/
|
||||||
|
public function getAlbum(): ?Album
|
||||||
|
{
|
||||||
|
return $this->album;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the reviewed album.
|
||||||
|
*/
|
||||||
|
public function setAlbum(Album $album): void
|
||||||
|
{
|
||||||
|
$this->album = $album;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the short review title.
|
||||||
|
*/
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return $this->title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the short review title.
|
||||||
|
*/
|
||||||
|
public function setTitle(string $title): void
|
||||||
|
{
|
||||||
|
$this->title = $title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the long-form review content.
|
||||||
|
*/
|
||||||
|
public function getContent(): string
|
||||||
|
{
|
||||||
|
return $this->content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the review content body.
|
||||||
|
*/
|
||||||
|
public function setContent(string $content): void
|
||||||
|
{
|
||||||
|
$this->content = $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the 1-10 numeric rating.
|
||||||
|
*/
|
||||||
|
public function getRating(): int
|
||||||
|
{
|
||||||
|
return $this->rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns the 1-10 numeric rating.
|
||||||
|
*/
|
||||||
|
public function setRating(int $rating): void
|
||||||
|
{
|
||||||
|
$this->rating = $rating;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the creation timestamp.
|
||||||
|
*/
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the last updated timestamp.
|
||||||
|
*/
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ use App\Repository\SettingRepository;
|
|||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setting stores lightweight key/value configuration entries.
|
||||||
|
*/
|
||||||
#[ORM\Entity(repositoryClass: SettingRepository::class)]
|
#[ORM\Entity(repositoryClass: SettingRepository::class)]
|
||||||
#[ORM\Table(name: 'settings')]
|
#[ORM\Table(name: 'settings')]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
|
#[ORM\UniqueConstraint(name: 'uniq_setting_name', columns: ['name'])]
|
||||||
@@ -23,11 +26,45 @@ class Setting
|
|||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $value = null;
|
private ?string $value = null;
|
||||||
|
|
||||||
public function getId(): ?int { return $this->id; }
|
/**
|
||||||
public function getName(): string { return $this->name; }
|
* Returns the unique identifier.
|
||||||
public function setName(string $name): void { $this->name = $name; }
|
*/
|
||||||
public function getValue(): ?string { return $this->value; }
|
public function getId(): ?int
|
||||||
public function setValue(?string $value): void { $this->value = $value; }
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the configuration key.
|
||||||
|
*/
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the configuration key.
|
||||||
|
*/
|
||||||
|
public function setName(string $name): void
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the stored configuration value.
|
||||||
|
*/
|
||||||
|
public function getValue(): ?string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the stored configuration value.
|
||||||
|
*/
|
||||||
|
public function setValue(?string $value): void
|
||||||
|
{
|
||||||
|
$this->value = $value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
|||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User models an authenticated account that can create reviews and albums.
|
||||||
|
*/
|
||||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||||
#[ORM\Table(name: 'users')]
|
#[ORM\Table(name: 'users')]
|
||||||
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
|
#[UniqueEntity(fields: ['email'], message: 'This email is already registered.')]
|
||||||
@@ -40,30 +43,49 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[Assert\Length(max: 120)]
|
#[Assert\Length(max: 120)]
|
||||||
private ?string $displayName = null;
|
private ?string $displayName = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||||
|
private ?string $profileImagePath = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the database identifier.
|
||||||
|
*/
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
{
|
{
|
||||||
return $this->id;
|
return $this->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the normalized email address.
|
||||||
|
*/
|
||||||
public function getEmail(): string
|
public function getEmail(): string
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets and normalizes the email address.
|
||||||
|
*/
|
||||||
public function setEmail(string $email): void
|
public function setEmail(string $email): void
|
||||||
{
|
{
|
||||||
$this->email = strtolower($email);
|
$this->email = strtolower($email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symfony security identifier alias for the email.
|
||||||
|
*/
|
||||||
public function getUserIdentifier(): string
|
public function getUserIdentifier(): string
|
||||||
{
|
{
|
||||||
return $this->email;
|
return $this->email;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique role list plus the implicit ROLE_USER.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
public function getRoles(): array
|
public function getRoles(): array
|
||||||
{
|
{
|
||||||
$roles = $this->roles;
|
$roles = $this->roles;
|
||||||
// guarantee every user at least has ROLE_USER
|
|
||||||
if (!in_array('ROLE_USER', $roles, true)) {
|
if (!in_array('ROLE_USER', $roles, true)) {
|
||||||
$roles[] = 'ROLE_USER';
|
$roles[] = 'ROLE_USER';
|
||||||
}
|
}
|
||||||
@@ -71,6 +93,8 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Replaces the granted role list.
|
||||||
|
*
|
||||||
* @param list<string> $roles
|
* @param list<string> $roles
|
||||||
*/
|
*/
|
||||||
public function setRoles(array $roles): void
|
public function setRoles(array $roles): void
|
||||||
@@ -78,6 +102,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->roles = array_values(array_unique($roles));
|
$this->roles = array_values(array_unique($roles));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a single role if not already present.
|
||||||
|
*/
|
||||||
public function addRole(string $role): void
|
public function addRole(string $role): void
|
||||||
{
|
{
|
||||||
$roles = $this->getRoles();
|
$roles = $this->getRoles();
|
||||||
@@ -87,30 +114,56 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
$this->roles = $roles;
|
$this->roles = $roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the hashed password string.
|
||||||
|
*/
|
||||||
public function getPassword(): string
|
public function getPassword(): string
|
||||||
{
|
{
|
||||||
return $this->password;
|
return $this->password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the hashed password string.
|
||||||
|
*/
|
||||||
public function setPassword(string $hashedPassword): void
|
public function setPassword(string $hashedPassword): void
|
||||||
{
|
{
|
||||||
$this->password = $hashedPassword;
|
$this->password = $hashedPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes any sensitive transient data (no-op here).
|
||||||
|
*/
|
||||||
|
#[\Deprecated(reason: 'No transient credentials stored; method retained for BC.')]
|
||||||
public function eraseCredentials(): void
|
public function eraseCredentials(): void
|
||||||
{
|
{
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the optional display name.
|
||||||
|
*/
|
||||||
public function getDisplayName(): ?string
|
public function getDisplayName(): ?string
|
||||||
{
|
{
|
||||||
return $this->displayName;
|
return $this->displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the optional display name.
|
||||||
|
*/
|
||||||
public function setDisplayName(?string $displayName): void
|
public function setDisplayName(?string $displayName): void
|
||||||
{
|
{
|
||||||
$this->displayName = $displayName;
|
$this->displayName = $displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getProfileImagePath(): ?string
|
||||||
|
{
|
||||||
|
return $this->profileImagePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProfileImagePath(?string $profileImagePath): void
|
||||||
|
{
|
||||||
|
$this->profileImagePath = $profileImagePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
63
src/Form/AdminUserType.php
Normal file
63
src/Form/AdminUserType.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Declares the admin-only account fields plus password confirmation.
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the AdminUserData DTO as the underlying data object.
|
||||||
|
*/
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => AdminUserData::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
133
src/Form/AlbumType.php
Normal file
133
src/Form/AlbumType.php
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use App\Entity\Album;
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\Form\FormEvent;
|
||||||
|
use Symfony\Component\Form\FormEvents;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AlbumType powers the user-facing album CRUD form, including CSV-style artist/genre helpers.
|
||||||
|
*/
|
||||||
|
class AlbumType extends AbstractType
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Defines the album creation/editing fields.
|
||||||
|
*/
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('name', TextType::class, [
|
||||||
|
'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 255)],
|
||||||
|
])
|
||||||
|
->add('artistsCsv', TextType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'label' => 'Artists (comma-separated)',
|
||||||
|
'constraints' => [new Assert\NotBlank()],
|
||||||
|
])
|
||||||
|
->add('genresCsv', TextType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Genres (comma-separated)',
|
||||||
|
'help' => 'Optional: e.g. Dream pop, Shoegaze',
|
||||||
|
])
|
||||||
|
->add('releaseDate', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'help' => 'YYYY or YYYY-MM or YYYY-MM-DD',
|
||||||
|
])
|
||||||
|
->add('totalTracks', IntegerType::class, [
|
||||||
|
'constraints' => [new Assert\Range(min: 0, max: 500)],
|
||||||
|
])
|
||||||
|
->add('coverUpload', FileType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Album cover',
|
||||||
|
'help' => 'JPEG or PNG up to 5MB.',
|
||||||
|
'constraints' => [new Assert\Image(maxSize: '5M')],
|
||||||
|
])
|
||||||
|
->add('externalUrl', TextType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'External link',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Seed the CSV helper fields with existing entity values before rendering.
|
||||||
|
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||||
|
$album = $event->getData();
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$form = $event->getForm();
|
||||||
|
if ($form->has('artistsCsv')) {
|
||||||
|
$form->get('artistsCsv')->setData($this->implodeCsv($album->getArtists()));
|
||||||
|
}
|
||||||
|
if ($form->has('genresCsv')) {
|
||||||
|
$form->get('genresCsv')->setData($this->implodeCsv($album->getGenres()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert the CSV helper fields back into normalized arrays when saving.
|
||||||
|
$builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void {
|
||||||
|
$album = $event->getData();
|
||||||
|
if (!$album instanceof Album) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$form = $event->getForm();
|
||||||
|
if ($form->has('artistsCsv')) {
|
||||||
|
$album->setArtists($this->splitCsv((string) $form->get('artistsCsv')->getData()));
|
||||||
|
}
|
||||||
|
if ($form->has('genresCsv')) {
|
||||||
|
$album->setGenres($this->splitCsv((string) $form->get('genresCsv')->getData()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Points the form to the Album entity.
|
||||||
|
*/
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([
|
||||||
|
'data_class' => Album::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a comma-separated string into a normalized, de-duplicated list.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function splitCsv(string $input): array
|
||||||
|
{
|
||||||
|
if ($input === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$normalized = str_replace(["\n", "\r", ';'], ',', $input);
|
||||||
|
$parts = array_map(static fn(string $value) => trim($value), explode(',', $normalized));
|
||||||
|
$filtered = array_values(array_filter($parts, static fn(string $value) => $value !== ''));
|
||||||
|
|
||||||
|
return array_values(array_unique($filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins a list back into a user-friendly CSV string for display.
|
||||||
|
*
|
||||||
|
* @param list<string> $items
|
||||||
|
*/
|
||||||
|
private function implodeCsv(array $items): string
|
||||||
|
{
|
||||||
|
if ($items === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', array_map(static fn(string $item) => trim($item), $items));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
43
src/Form/ChangePasswordFormType.php
Normal file
43
src/Form/ChangePasswordFormType.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Builds the password change fields with validation.
|
||||||
|
*/
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('currentPassword', PasswordType::class, [
|
||||||
|
'label' => 'Current password',
|
||||||
|
])
|
||||||
|
->add('newPassword', RepeatedType::class, [
|
||||||
|
'type' => PasswordType::class,
|
||||||
|
'first_options' => ['label' => 'New password'],
|
||||||
|
'second_options' => ['label' => 'Repeat new password'],
|
||||||
|
'invalid_message' => 'The password fields must match.',
|
||||||
|
'constraints' => [new Assert\Length(min: 8)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves default form options untouched.
|
||||||
|
*/
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,6 +5,7 @@ namespace App\Form;
|
|||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
@@ -12,8 +13,14 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProfileFormType lets authenticated users edit their account details and password.
|
||||||
|
*/
|
||||||
class ProfileFormType extends AbstractType
|
class ProfileFormType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Defines profile fields including optional password updates.
|
||||||
|
*/
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -24,6 +31,12 @@ class ProfileFormType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'constraints' => [new Assert\Length(max: 120)],
|
'constraints' => [new Assert\Length(max: 120)],
|
||||||
])
|
])
|
||||||
|
->add('profileImage', FileType::class, [
|
||||||
|
'mapped' => false,
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Profile picture',
|
||||||
|
'constraints' => [new Assert\Image(maxSize: '4M')],
|
||||||
|
])
|
||||||
->add('currentPassword', PasswordType::class, [
|
->add('currentPassword', PasswordType::class, [
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
@@ -40,6 +53,9 @@ class ProfileFormType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the form to the User entity.
|
||||||
|
*/
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RegistrationFormType defines the public signup form and its validation rules.
|
||||||
|
*/
|
||||||
class RegistrationFormType extends AbstractType
|
class RegistrationFormType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Configures the registration form fields and validation rules.
|
||||||
|
*/
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -42,6 +48,9 @@ class RegistrationFormType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Binds the form to the User entity.
|
||||||
|
*/
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ use Symfony\Component\Form\FormBuilderInterface;
|
|||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReviewType captures the fields needed to author or edit a review.
|
||||||
|
*/
|
||||||
class ReviewType extends AbstractType
|
class ReviewType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Declares the review submission fields and validation.
|
||||||
|
*/
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -35,6 +41,9 @@ class ReviewType extends AbstractType
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associates the form with the Review entity.
|
||||||
|
*/
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
|
|||||||
@@ -3,12 +3,19 @@
|
|||||||
namespace App\Form;
|
namespace App\Form;
|
||||||
|
|
||||||
use Symfony\Component\Form\AbstractType;
|
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\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SiteSettingsType exposes toggles for operations staff (Spotify creds, registration).
|
||||||
|
*/
|
||||||
class SiteSettingsType extends AbstractType
|
class SiteSettingsType extends AbstractType
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Exposes Spotify credential inputs for administrators.
|
||||||
|
*/
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
@@ -21,9 +28,17 @@ class SiteSettingsType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'label' => 'Spotify Client Secret',
|
'label' => 'Spotify Client Secret',
|
||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
|
])
|
||||||
|
->add('REGISTRATION_ENABLED', CheckboxType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'label' => 'Allow self-service registration',
|
||||||
|
'mapped' => false,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves default options unchanged.
|
||||||
|
*/
|
||||||
public function configureOptions(OptionsResolver $resolver): void
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([]);
|
$resolver->setDefaults([]);
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
namespace App;
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MicroKernelTrait used over KernelTrait for smaller footprint; full HttpKernel is not needed.
|
||||||
|
*/
|
||||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user