diff --git a/.env.test b/.env.test index 181af84..d588890 100755 --- a/.env.test +++ b/.env.test @@ -1,6 +1,10 @@ +###> symfony/framework-bundle ### +APP_ENV=test +APP_SECRET='$ecretf0rt3st' +###< symfony/framework-bundle ### + # define your env variables for the test env here KERNEL_CLASS='App\Kernel' -APP_SECRET='$ecretf0rt3st' SYMFONY_DEPRECATIONS_HELPER=999999 PANTHER_APP_ENV=panther PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots @@ -11,9 +15,9 @@ PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots DATABASE_HOST=postgres DATABASE_PORT=5432 DATABASE_NAME=larpilot -DATABASE_USER=postgres +DATABASE_USER=larpilot DATABASE_PASSWORD=password -DATABASE_SERVER_VERSION=13 +DATABASE_SERVER_VERSION=15 # Messenger & Mailer (test stubs) MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml deleted file mode 100755 index 0d66968..0000000 --- a/.github/workflows/phpunit.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: PHPUnit - -on: - pull_request: - branches: [ "**" ] - push: - branches: [ main, master ] - -jobs: - tests: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: larpilot_test - ports: - - "5432:5432" - options: >- - --health-cmd="pg_isready -U postgres -d larpilot_test" - --health-interval=10s - --health-timeout=5s - --health-retries=5 - - steps: - - uses: actions/checkout@v4 - - - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - coverage: none - tools: composer:v2 - - - name: Copy .env.test if needed - run: | - cp -n .env.test.dist .env.test || true - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist - - - name: Wait for PostgreSQL - run: | - for i in `seq 1 30`; do - pg_isready -h 127.0.0.1 -p 5432 -U postgres -d larpilot_test && break - sleep 1 - done - - - name: Prepare test database - env: - APP_ENV: test - run: | - php bin/console doctrine:database:create --if-not-exists --env=test - php bin/console doctrine:migrations:migrate --no-interaction --env=test - - - name: Prepare Frontend - env: - APP_ENV: test - run: | - php bin/console importmap:install - php bin/console sass:build || true - php bin/console asset-map:compile - composer dump-autoload --optimize - - - name: Run tests - env: - APP_ENV: test - run: vendor/bin/phpunit --colors=always \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba6583d..bffe4a3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -56,9 +56,9 @@ jobs: postgres: image: postgres:15 env: - POSTGRES_USER: larpilot_test - POSTGRES_PASSWORD: larpilot_test - POSTGRES_DB: larpilot_test + POSTGRES_USER: larpilot + POSTGRES_PASSWORD: password + POSTGRES_DB: larpilot options: >- --health-cmd pg_isready --health-interval 10s @@ -94,75 +94,37 @@ jobs: - name: Setup test environment run: | - cp .env .env.test.local || true - echo "APP_ENV=test" >> .env.test.local - echo "DATABASE_URL=postgresql://larpilot_test:larpilot_test@127.0.0.1:5432/larpilot_test?serverVersion=15&charset=utf8" >> .env.test.local + # Create .env.test.local to override .env.test values for GitHub Actions + echo "DATABASE_HOST=127.0.0.1" > .env.test.local - name: Create test database + env: + DATABASE_HOST: 127.0.0.1 run: | php bin/console doctrine:database:create --env=test --if-not-exists php bin/console doctrine:migrations:migrate --env=test --no-interaction + - name: Build assets (SASS) + env: + DATABASE_HOST: 127.0.0.1 + run: | + php bin/console sass:build --env=test + php bin/console asset-map:compile --env=test + - name: Build Codeception Actors run: vendor/bin/codecept build - - name: Run All Codeception Tests + - name: Run Codeception Tests + env: + DATABASE_HOST: 127.0.0.1 run: | APP_ENV=test vendor/bin/codecept run --colors - - name: Run Unit Tests Only - run: | - APP_ENV=test vendor/bin/codecept run unit --colors - - - name: Run Functional Tests Only - run: | - APP_ENV=test vendor/bin/codecept run functional --colors - - docker-tests: - name: Docker Environment Tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Copy .env file - run: cp .env .env.local || true - - - name: Build Docker containers - run: docker compose build - - - name: Start Docker containers - run: docker compose up -d - - - name: Wait for services to be ready - run: | - sleep 30 - docker compose ps - - - name: Install dependencies in container - run: docker compose exec -T php composer install --no-interaction - - - name: Run migrations in container - run: | - docker compose exec -T php php bin/console doctrine:database:create --env=test --if-not-exists - docker compose exec -T php php bin/console doctrine:migrations:migrate --env=test --no-interaction - - - name: Build Codeception Actors in container - run: docker compose exec -T php vendor/bin/codecept build - - - name: Run Codeception tests in Docker container - run: | - docker compose exec -T php bash -lc "APP_ENV=test vendor/bin/codecept run --colors" - - - name: Stop Docker containers - if: always() - run: docker compose down summary: name: Test Summary runs-on: ubuntu-latest - needs: [code-quality, tests, docker-tests] + needs: [code-quality, tests] if: always() steps: @@ -176,8 +138,4 @@ jobs: echo "Codeception tests failed" exit 1 fi - if [ "${{ needs.docker-tests.result }}" != "success" ]; then - echo "Docker tests failed" - exit 1 - fi echo "All tests passed successfully!" diff --git a/.gitignore b/.gitignore index df19e7c..88f7c25 100755 --- a/.gitignore +++ b/.gitignore @@ -9,11 +9,6 @@ /vendor/ ###< symfony/framework-bundle ### -###> phpunit/phpunit ### -/phpunit.xml -.phpunit.result.cache -###< phpunit/phpunit ### - ###> symfony/asset-mapper ### /public/assets/ /public/uploads/maps/* diff --git a/AGENTS.md b/AGENTS.md index 371858f..607d589 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,9 @@ - JavaScript is written as ES6 modules and organized under `assets/controllers/` using Stimulus. Use 4 space indentation and define `targets`, `values`, and life‑cycle methods (e.g., `connect`). Webpack Encore bundles assets and the entry point is `assets/app.js`. ## Tests and checks -- Run unit tests with `vendor/bin/phpunit -c phpunit.xml.dist`. -- Run code style checks with `vendor/bin/ecs check`. -- Run static analysis with `vendor/bin/phpstan analyse -c phpstan.neon`. +- Prepare tests with `make test-build`. +- Run unit tests with `make test`. +- Run code style checks with `make ecs-fix`. +- Run static analysis with `make stan`. Make sure these checks pass before committing changes. diff --git a/CLAUDE.md b/CLAUDE.md index 3fa5152..781da08 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,9 +40,21 @@ make ecs-fix make stan # OR: docker compose exec -T php bash -lc "XDEBUG_MODE=off php -d memory_limit=-1 vendor/bin/phpstan analyse -c phpstan.neon" -# Run tests +# Run all tests (Codeception) make test -# OR: docker compose exec -T php bash -lc "APP_ENV=test php vendor/bin/phpunit -c phpunit.xml.dist --colors=always" +# OR: docker compose exec -T php vendor/bin/codecept run --colors + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest + +# Rebuild Codeception actors (after suite config changes) +make test-build +# OR: docker compose exec -T php vendor/bin/codecept build # Automated refactoring (PHP 8.2) make rector-fix @@ -377,7 +389,7 @@ $pagination = $this->getPagination($qb, $request); For sort controls in filter forms, use twig since they're UI controls, not filters: ```php -{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'common.name'|trans } %} +{% include 'includes/sort_th.html.twig' with { field: 'name', label: 'name'|trans } %} ``` **Examples**: See `FactionController::list()` for complete implementations. @@ -396,7 +408,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter
- {{ 'common.create'|trans }} + {{ 'create'|trans }}
@@ -414,10 +426,10 @@ Backoffice list pages follow a consistent template pattern for displaying filter {# Sortable column header #} {% include 'includes/sort_th.html.twig' with { field: 'name', - label: 'common.name'|trans + label: 'name'|trans } %} - {{ 'common.description'|trans }} - {{ 'common.actions'|trans }} + {{ 'description'|trans }} + {{ 'actions'|trans }} @@ -442,7 +454,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter data-delete-url="{{ path('backoffice_larp_story_tag_delete', { larp: larp.id, tag: tag.id }) }}"> - {{ 'common.delete'|trans }} + {{ 'delete'|trans }} @@ -451,7 +463,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter {% else %} -

{{ 'common.empty_list'|trans }}

+

{{ 'empty_list'|trans }}

{% endif %} @@ -470,7 +482,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter 3. **Conditional Data Display**: - Check `{% if items is not empty %}` before rendering table - - Show `{{ 'common.empty_list'|trans }}` message when no data + - Show `{{ 'empty_list'|trans }}` message when no data 4. **Table Styling**: - `table-responsive` wrapper for mobile scrolling @@ -488,7 +500,7 @@ Backoffice list pages follow a consistent template pattern for displaying filter - Pass item data via `data-*` attributes for JavaScript handling 7. **Empty State**: - - Simple text message: `

{{ 'common.empty_list'|trans }}

` + - Simple text message: `

{{ 'empty_list'|trans }}

` **Sorting Implementation**: @@ -528,13 +540,134 @@ Copy `.env` to `.env.local` and configure: ## Testing -Run tests with PHPUnit: +LARPilot uses **Codeception** for all testing (unit, functional, and acceptance tests). + +### Running Tests + ```bash -vendor/bin/phpunit -c phpunit.xml.dist +# Run all tests +make test + +# Run specific test suites +make test-unit # Unit tests only (fast, no database) +make test-functional # Functional tests (with database, no browser) +make test-acceptance # Acceptance tests (browser-based) + +# Run a specific test file or path +make test-filter FILTER=Functional/Authentication/UserSignupAndApprovalCest +make test-filter FILTER=Unit/Domain/Infrastructure + +# Rebuild actors after suite configuration changes +make test-build ``` +### Test Structure + +``` +tests/ +├── Unit/ # Unit tests (no dependencies) +├── Functional/ # Functional tests (with database) +├── Acceptance/ # Browser-based tests +└── Support/ + ├── Helper/ + │ └── Authentication.php # Custom Codeception helper + └── Factory/ # Foundry factories (organized by domain) + ├── Account/ + ├── Core/ + └── Survey/ +``` + +### Writing Tests + +**Functional Test Example** (Cest format): +```php +wantTo('verify PENDING users cannot access backoffice'); + + $pendingUser = $I->createPendingUser(); + $I->amLoggedInAs($pendingUser); + + $I->amOnRoute('backoffice_larp_create'); + $I->seeResponseCodeIs(302); + } +} +``` + +### Authentication Helper Methods + +The `Authentication` helper (`tests/Support/Helper/Authentication.php`) provides factory-based methods for test data: + +**User Creation:** +- `$I->createPendingUser()` - Create PENDING user +- `$I->createApprovedUser()` - Create APPROVED user +- `$I->createSuspendedUser()` - Create SUSPENDED user +- `$I->createBannedUser()` - Create BANNED user +- `$I->createSuperAdmin()` - Create SUPER_ADMIN user + +**Authentication:** +- `$I->amLoggedInAs($user)` - Log in as specific user + +**LARP Creation:** +- `$I->createLarp($organizer)` - Create LARP (default: DRAFT) +- `$I->createDraftLarp($organizer)` - Create DRAFT LARP +- `$I->createPublishedLarp($organizer)` - Create PUBLISHED LARP +- `$I->addParticipantToLarp($larp, $user, 'player')` - Add participant + +**Location Creation:** +- `$I->createPendingLocation($creator)` - Create PENDING location +- `$I->createApprovedLocation($creator)` - Create APPROVED location +- `$I->createRejectedLocation($creator, $reason)` - Create REJECTED location + +**Utilities:** +- `$I->getEntityManager()` - Get Doctrine EntityManager +- `$I->getUrl('route_name', $params)` - Generate URL from route + +### Foundry Factories + +Tests use **Zenstruck Foundry** factories for test data creation. Factories are organized by domain under `tests/Support/Factory/`: + +**Example Usage:** +```php +use Tests\Support\Factory\Account\UserFactory; +use Tests\Support\Factory\Core\LarpFactory; +use Tests\Support\Factory\Survey\SurveyFactory; + +// Create user with factory +$user = UserFactory::new()->approved()->create(); + +// Create LARP with factory +$larp = LarpFactory::new() + ->forOrganizer($user) + ->withStatus('published') + ->create(); + +// Create survey with questions +$survey = SurveyFactory::new() + ->forLarp($larp) + ->withQuestions(5) + ->create(); +``` + +See factory classes for available methods and options. + +### Test Database + Test database uses suffix `_test` (configured in `config/packages/doctrine.yaml`). +**Prepare test database:** +```bash +make prepare-test-db +``` + ## Code Quality Standards - **PHP Version**: 8.2+ diff --git a/assets/controllers/application-mode-toggle_controller.js b/assets/controllers/application-mode-toggle_controller.js new file mode 100644 index 0000000..04445ad --- /dev/null +++ b/assets/controllers/application-mode-toggle_controller.js @@ -0,0 +1,76 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Application Mode Toggle Controller + * + * Manages the visibility of the "Publish Characters Publicly" checkbox + * based on the selected application mode in LARP settings. + * + * - CHARACTER_SELECTION mode: Shows the checkbox + * - SURVEY mode: Hides the checkbox and unchecks it + */ +export default class extends Controller { + static targets = ['publishCheckbox']; + + connect() { + this.updateVisibility(); + } + + /** + * Called when application mode radio buttons change + */ + change(event) { + this.updateVisibility(); + } + + updateVisibility() { + const selectedMode = this.getSelectedMode(); + const publishContainer = this.findPublishCheckboxContainer(); + + if (!publishContainer) { + return; + } + + if (selectedMode === 'character_selection') { + // Show the checkbox for character selection mode + publishContainer.style.display = 'block'; + } else if (selectedMode === 'survey') { + // Hide the checkbox for survey mode and uncheck it + publishContainer.style.display = 'none'; + + // Uncheck the checkbox + const checkbox = publishContainer.querySelector('input[type="checkbox"]'); + if (checkbox) { + checkbox.checked = false; + } + } + } + + getSelectedMode() { + // Find the checked radio button + const checkedRadio = this.element.querySelector('input[type="radio"]:checked'); + return checkedRadio ? checkedRadio.value : null; + } + + findPublishCheckboxContainer() { + // The checkbox is in a sibling form group + // Look for the parent form and then find the publish checkbox container + const form = this.element.closest('form'); + if (!form) { + return null; + } + + // Find by data attribute if available + if (this.hasPublishCheckboxTarget) { + return this.publishCheckboxTarget.closest('.mb-3, .form-group'); + } + + // Fallback: Find by field name pattern + const publishField = form.querySelector('input[name*="publishCharactersPublicly"]'); + if (publishField) { + return publishField.closest('.mb-3, .form-group'); + } + + return null; + } +} diff --git a/assets/controllers/character-gallery-card_controller.js b/assets/controllers/character-gallery-card_controller.js new file mode 100644 index 0000000..e92b0f2 --- /dev/null +++ b/assets/controllers/character-gallery-card_controller.js @@ -0,0 +1,55 @@ +import { Controller } from '@hotwired/stimulus'; + +/** + * Character Gallery Card Controller + * + * Handles interactions for character cards in the public gallery. + * Currently provides hover effects and could be extended for lazy loading, + * favoriting, or other interactive features. + */ +export default class extends Controller { + static targets = ['image', 'card']; + + connect() { + this.addHoverEffects(); + } + + addHoverEffects() { + if (this.hasCardTarget) { + this.cardTarget.addEventListener('mouseenter', () => { + this.cardTarget.classList.add('shadow-lg'); + }); + + this.cardTarget.addEventListener('mouseleave', () => { + this.cardTarget.classList.remove('shadow-lg'); + }); + } + } + + /** + * Navigate to character detail page + * Can be used for card click handling if needed + */ + viewDetails(event) { + // Prevent navigation if clicking on a link inside the card + if (event.target.tagName === 'A' || event.target.closest('a')) { + return; + } + + const detailUrl = this.element.dataset.detailUrl; + if (detailUrl) { + window.location.href = detailUrl; + } + } + + /** + * Lazy load image when card enters viewport + * Usage: data-action="intersection@window->character-gallery-card#lazyLoadImage" + */ + lazyLoadImage() { + if (this.hasImageTarget && this.imageTarget.dataset.src) { + this.imageTarget.src = this.imageTarget.dataset.src; + delete this.imageTarget.dataset.src; + } + } +} diff --git a/assets/controllers/map-config-preview_controller.js b/assets/controllers/map-config-preview_controller.js new file mode 100644 index 0000000..0632463 --- /dev/null +++ b/assets/controllers/map-config-preview_controller.js @@ -0,0 +1,290 @@ +import { Controller } from '@hotwired/stimulus'; +import L from 'leaflet'; +import 'leaflet/dist/leaflet.min.css'; + +/** + * Map Configuration Preview Controller + * + * Provides real-time preview of map image with configurable grid overlay. + * Allows users to see changes before saving. + */ +export default class extends Controller { + static targets = [ + 'preview', // The map container div + 'fileInput', // File input for new image + 'gridRows', // Grid rows input + 'gridColumns', // Grid columns input + 'gridOpacity', // Grid opacity input + 'gridVisible', // Grid visibility checkbox + 'placeholder' // Placeholder shown when no image + ]; + + static values = { + existingImage: String, // URL of existing image (for edit mode) + gridRows: { type: Number, default: 10 }, + gridColumns: { type: Number, default: 10 }, + gridOpacity: { type: Number, default: 0.5 }, + gridVisible: { type: Boolean, default: true } + }; + + connect() { + this.map = null; + this.gridLayer = null; + this.imageOverlay = null; + this.blobUrl = null; + + // Initialize with existing image if available + if (this.existingImageValue) { + this.loadImage(this.existingImageValue); + } + + // Sync initial values from form inputs + this.syncFromInputs(); + } + + disconnect() { + this.cleanup(); + } + + cleanup() { + if (this.map) { + this.map.remove(); + this.map = null; + } + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + this.blobUrl = null; + } + } + + /** + * Sync grid values from form inputs + */ + syncFromInputs() { + if (this.hasGridRowsTarget) { + this.gridRowsValue = parseInt(this.gridRowsTarget.value) || 10; + } + if (this.hasGridColumnsTarget) { + this.gridColumnsValue = parseInt(this.gridColumnsTarget.value) || 10; + } + if (this.hasGridOpacityTarget) { + this.gridOpacityValue = parseFloat(this.gridOpacityTarget.value) || 0.5; + } + if (this.hasGridVisibleTarget) { + this.gridVisibleValue = this.gridVisibleTarget.checked; + } + } + + /** + * Handle file input change - create blob URL and load preview + */ + onFileChange(event) { + const file = event.target.files[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + console.warn('Selected file is not an image'); + return; + } + + // Revoke previous blob URL + if (this.blobUrl) { + URL.revokeObjectURL(this.blobUrl); + } + + // Create new blob URL for preview + this.blobUrl = URL.createObjectURL(file); + this.loadImage(this.blobUrl); + } + + /** + * Handle grid rows input change + */ + onGridRowsChange(event) { + const value = parseInt(event.target.value); + if (value > 0 && value <= 100) { + this.gridRowsValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid columns input change + */ + onGridColumnsChange(event) { + const value = parseInt(event.target.value); + if (value > 0 && value <= 100) { + this.gridColumnsValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid opacity input change + */ + onGridOpacityChange(event) { + const value = parseFloat(event.target.value); + if (value >= 0 && value <= 1) { + this.gridOpacityValue = value; + this.redrawGrid(); + } + } + + /** + * Handle grid visibility checkbox change + */ + onGridVisibleChange(event) { + this.gridVisibleValue = event.target.checked; + this.redrawGrid(); + } + + /** + * Load image and initialize map + */ + loadImage(imageUrl) { + // Clean up existing map + if (this.map) { + this.map.remove(); + this.map = null; + } + + // Show preview, hide placeholder + if (this.hasPlaceholderTarget) { + this.placeholderTarget.classList.add('d-none'); + } + if (this.hasPreviewTarget) { + this.previewTarget.classList.remove('d-none'); + } + + const img = new Image(); + img.src = imageUrl; + + img.onload = () => { + this.imageWidth = img.width; + this.imageHeight = img.height; + + const bounds = [[0, 0], [img.height, img.width]]; + + // Initialize Leaflet map with simple CRS for image + this.map = L.map(this.previewTarget, { + crs: L.CRS.Simple, + minZoom: -3, + maxZoom: 2, + center: [img.height / 2, img.width / 2], + zoom: -1, + attributionControl: false + }); + + // Add image overlay + this.imageOverlay = L.imageOverlay(imageUrl, bounds).addTo(this.map); + this.map.fitBounds(bounds); + + // Draw initial grid + this.redrawGrid(); + }; + + img.onerror = () => { + console.error('Failed to load image:', imageUrl); + // Show placeholder on error + if (this.hasPlaceholderTarget) { + this.placeholderTarget.classList.remove('d-none'); + } + if (this.hasPreviewTarget) { + this.previewTarget.classList.add('d-none'); + } + }; + } + + /** + * Redraw grid overlay with current settings + */ + redrawGrid() { + if (!this.map || !this.imageWidth || !this.imageHeight) { + return; + } + + // Remove existing grid layer + if (this.gridLayer) { + this.map.removeLayer(this.gridLayer); + this.gridLayer = null; + } + + // Don't draw if grid is not visible + if (!this.gridVisibleValue) { + return; + } + + const rows = this.gridRowsValue; + const cols = this.gridColumnsValue; + const opacity = this.gridOpacityValue; + const width = this.imageWidth; + const height = this.imageHeight; + + const cellWidth = width / cols; + const cellHeight = height / rows; + + this.gridLayer = L.layerGroup().addTo(this.map); + + // Draw horizontal lines + for (let i = 0; i <= rows; i++) { + const y = i * cellHeight; + L.polyline([[y, 0], [y, width]], { + color: '#000000', + weight: 1, + opacity: opacity + }).addTo(this.gridLayer); + } + + // Draw vertical lines + for (let i = 0; i <= cols; i++) { + const x = i * cellWidth; + L.polyline([[0, x], [height, x]], { + color: '#000000', + weight: 1, + opacity: opacity + }).addTo(this.gridLayer); + } + + // Add grid labels (only if grid is not too dense) + if (rows <= 26 && cols <= 26) { + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const x = col * cellWidth + cellWidth / 2; + const y = row * cellHeight + cellHeight / 2; + const label = this.getCellLabel(row, col); + + // Only show labels if cells are large enough + if (cellWidth > 30 && cellHeight > 30) { + L.marker([y, x], { + icon: L.divIcon({ + className: 'map-grid-label', + html: `
${label}
`, + iconSize: [30, 30], + iconAnchor: [15, 15] + }) + }).addTo(this.gridLayer); + } + } + } + } + } + + /** + * Get cell label (A1, B2, etc.) + */ + getCellLabel(row, col) { + const letter = String.fromCharCode(65 + col); // A, B, C, ... + return `${letter}${row + 1}`; + } + + /** + * Zoom to fit the entire image + */ + fitImage() { + if (this.map && this.imageWidth && this.imageHeight) { + const bounds = [[0, 0], [this.imageHeight, this.imageWidth]]; + this.map.fitBounds(bounds); + } + } +} diff --git a/assets/controllers/survey-builder_controller.js b/assets/controllers/survey-builder_controller.js new file mode 100644 index 0000000..1577d82 --- /dev/null +++ b/assets/controllers/survey-builder_controller.js @@ -0,0 +1,158 @@ +import { Controller } from '@hotwired/stimulus'; +import $ from 'jquery'; +import Sortable from 'sortablejs'; + +export default class extends Controller { + static targets = ['questionsContainer', 'addQuestionButton']; + + connect() { + this.initializeSortable(); + this.initializeQuestionTypeHandlers(); + } + + disconnect() { + if (this.sortable) { + this.sortable.destroy(); + } + } + + initializeSortable() { + const container = document.getElementById('survey-questions-container'); + if (container) { + this.sortable = Sortable.create(container, { + animation: 150, + handle: '.drag-handle', + ghostClass: 'sortable-ghost', + chosenClass: 'sortable-chosen', + dragClass: 'sortable-drag', + fallbackOnBody: false, + swapThreshold: 0.65, + invertSwap: false, + removeCloneOnHide: false, + onStart: () => { + this.disableStimulus(); + }, + onEnd: (evt) => { + this.enableStimulus(); + this.updateOrderPositions(); + } + }); + } + } + + initializeQuestionTypeHandlers() { + // Listen for question type changes to show/hide options + $(document).on('change', 'select[id$="_questionType"]', (e) => { + const select = $(e.target); + const questionCard = select.closest('.question-item'); + const optionsContainer = questionCard.find('.question-options-container'); + const questionType = select.val(); + + // Show options container only for single_choice and multiple_choice + if (questionType === 'single_choice' || questionType === 'multiple_choice') { + optionsContainer.show(); + } else { + optionsContainer.hide(); + } + }); + } + + disableStimulus() { + if (this.application && this.application.router) { + this.application.router.stop(); + } + } + + enableStimulus() { + if (this.application && this.application.router) { + setTimeout(() => { + this.application.router.start(); + }, 100); + } + } + + updateOrderPositions() { + $('#survey-questions-container .question-item').each(function(index) { + const position = index; + + // Update visual position badge + $(this).find('.position-badge').text('Question ' + (position + 1)); + + // Update hidden orderPosition field + $(this).find('input[name$="[orderPosition]"]').val(position); + + // Update data attribute + $(this).attr('data-position', position); + }); + + // Also update option positions within each question + $('.question-options-container').each(function() { + $(this).find('.option-item').each(function(index) { + $(this).find('input[name$="[orderPosition]"]').val(index); + }); + }); + } + + addQuestion(event) { + event.preventDefault(); + + const container = document.getElementById('survey-questions-container'); + const prototype = container.dataset.prototype; + const index = container.dataset.index || 0; + + // Replace __name__ placeholder with index + const newForm = prototype.replace(/__name__/g, index); + + // Increment index + container.dataset.index = parseInt(index) + 1; + + // Add new question + $(container).append(newForm); + + // Update order positions + this.updateOrderPositions(); + } + + removeQuestion(event) { + event.preventDefault(); + + const questionCard = $(event.target).closest('.question-item'); + + // Confirm deletion + if (confirm('Are you sure you want to remove this question?')) { + questionCard.remove(); + this.updateOrderPositions(); + } + } + + addOption(event) { + event.preventDefault(); + + const button = $(event.target); + const questionCard = button.closest('.question-item'); + const optionsContainer = questionCard.find('.question-options-list'); + const prototype = optionsContainer.data('prototype'); + const index = optionsContainer.data('index') || 0; + + // Replace __name__ placeholder + const newForm = prototype.replace(/__name__/g, index); + + // Increment index + optionsContainer.data('index', parseInt(index) + 1); + + // Add new option + optionsContainer.append(newForm); + + // Update order positions + this.updateOrderPositions(); + } + + removeOption(event) { + event.preventDefault(); + + const optionItem = $(event.target).closest('.option-item'); + optionItem.remove(); + + this.updateOrderPositions(); + } +} diff --git a/assets/styles/app.scss b/assets/styles/app.scss index a78e4a9..75345f2 100755 --- a/assets/styles/app.scss +++ b/assets/styles/app.scss @@ -1,4 +1,5 @@ //assets/styles/app.scss +@import "./components/colors"; @import "./components/dark_mode"; @import "./components/sidebar_menu"; @import "./components/backoffice_menu"; @@ -9,6 +10,7 @@ @import "./components/kanban"; @import "./components/feedback"; @import "./components/timeline"; +@import "./components/map_preview"; @import "./vendors/quill.snow"; @import "./vendors/vis-timeline-graph2d.min"; @@ -77,10 +79,6 @@ a.nav-link.active { transition: transform 0.3s ease; } -.badge { - color: #000 !important; -} - // Mobile-first responsiveness @media (max-width: 767.98px) { table { diff --git a/assets/styles/components/_backoffice_buttons.scss b/assets/styles/components/_backoffice_buttons.scss index af13aa1..ce066ae 100644 --- a/assets/styles/components/_backoffice_buttons.scss +++ b/assets/styles/components/_backoffice_buttons.scss @@ -1,86 +1,13 @@ -// ============================================ -// BACKOFFICE COLOR VARIABLES -// ============================================ - -// Base Colors -$bo-white: #ffffff; -$bo-black: #000000; -$bo-gray-50: #f8f9fa; -$bo-gray-100: #e9ecef; -$bo-gray-200: #dee2e6; -$bo-gray-300: #ced4da; -$bo-gray-400: #adb5bd; -$bo-gray-500: #6c757d; -$bo-gray-600: #495057; -$bo-gray-700: #343a40; -$bo-gray-800: #212529; - -// Primary Colors (Bootstrap Blue) -$bo-primary: #0d6efd; -$bo-primary-hover: #0b5ed7; -$bo-primary-active: #0a58ca; -$bo-primary-light: #e7f1ff; -$bo-primary-dark: #084298; - -// Success Colors (Green) -$bo-success: #198754; -$bo-success-hover: #157347; -$bo-success-active: #146c43; -$bo-success-light: #d1e7dd; -$bo-success-dark: #0f5132; - -// Danger Colors (Red) -$bo-danger: #dc3545; -$bo-danger-hover: #bb2d3b; -$bo-danger-active: #b02a37; -$bo-danger-light: #f8d7da; -$bo-danger-dark: #842029; - -// Warning Colors (Yellow) -$bo-warning: #ffc107; -$bo-warning-hover: #ffca2c; -$bo-warning-active: #ffcd39; -$bo-warning-light: #fff3cd; -$bo-warning-dark: #997404; - -// Info Colors (Cyan) -$bo-info: #0dcaf0; -$bo-info-hover: #31d2f2; -$bo-info-active: #3dd5f3; -$bo-info-light: #cff4fc; -$bo-info-dark: #055160; - -// Border Radius -$bo-border-radius: 8px; -$bo-border-radius-sm: 6px; -$bo-border-radius-lg: 10px; -$bo-border-radius-xl: 12px; - -// Shadows -$bo-shadow-sm: 0 2px 4px rgba($bo-black, 0.08); -$bo-shadow: 0 2px 8px rgba($bo-black, 0.08); -$bo-shadow-lg: 0 4px 12px rgba($bo-black, 0.12); -$bo-shadow-xl: 0 8px 24px rgba($bo-black, 0.15); - -$bo-shadow-success: 0 4px 12px rgba($bo-success, 0.3); -$bo-shadow-success-hover: 0 6px 16px rgba($bo-success, 0.4); -$bo-shadow-danger: 0 4px 12px rgba($bo-danger, 0.3); - -// Transitions -$bo-transition-default: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -$bo-transition-smooth: all 0.2s ease; -$bo-transition-fast: all 0.15s ease; - // Modern SUCCESS button styling (GREEN for create/save) .btn-success-modern { - background: $bo-success; + background: var(--bo-success); border: none; - color: $bo-white; - border-radius: $bo-border-radius-lg; + color: var(--bo-white); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - box-shadow: $bo-shadow-success; - transition: $bo-transition-default; + box-shadow: var(--bo-shadow-success); + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; @@ -90,10 +17,10 @@ $bo-transition-fast: all 0.15s ease; } &:hover { - background: $bo-success-hover; + background: var(--bo-success-hover); transform: translateY(-2px); - box-shadow: $bo-shadow-success-hover; - color: $bo-white; + box-shadow: var(--bo-shadow-success-hover); + color: var(--bo-white); i { transform: scale(1.1); @@ -102,41 +29,41 @@ $bo-transition-fast: all 0.15s ease; &:active { transform: translateY(0); - box-shadow: $bo-shadow-success; + box-shadow: var(--bo-shadow-success); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); - color: $bo-white; + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); + color: var(--bo-white); } } // Small button variant .btn-sm.btn-primary-modern { padding: 0.375rem 0.875rem; - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); font-size: 0.875rem; } // Secondary button with outline (GRAY for cancel/decline) .btn-secondary-modern { - background: $bo-white; - border: 2px solid $bo-gray-500; - color: $bo-gray-500; - border-radius: $bo-border-radius-lg; + background: var(--bo-white); + border: 2px solid var(--bo-gray-500); + color: var(--bo-gray-500); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - transition: $bo-transition-default; + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; &:hover { - background: $bo-gray-500; - border-color: $bo-gray-500; - color: $bo-white; + background: var(--bo-gray-500); + border-color: var(--bo-gray-500); + color: var(--bo-white); transform: translateY(-2px); - box-shadow: 0 4px 12px rgba($bo-gray-500, 0.3); + box-shadow: 0 4px 12px rgba(var(--bo-gray-500), 0.3); } &:active { @@ -144,18 +71,18 @@ $bo-transition-fast: all 0.15s ease; } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-gray-500, 0.25); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-gray-500), 0.25); } } // Danger button with transition .btn-danger { - border-radius: $bo-border-radius; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-default); &:hover { transform: translateY(-2px); - box-shadow: $bo-shadow-danger; + box-shadow: var(--bo-shadow-danger); } &:active { @@ -166,24 +93,24 @@ $bo-transition-fast: all 0.15s ease; // Button group with clean styling (NO GRADIENT) .btn-group { position: relative; - box-shadow: $bo-shadow; - border-radius: $bo-border-radius; + box-shadow: var(--bo-shadow); + border-radius: var(--bo-border-radius); overflow: hidden; - border: 1px solid $bo-gray-200; - transition: $bo-transition-default; + border: 1px solid var(--bo-gray-200); + transition: var(--bo-transition-default); &:hover { - box-shadow: $bo-shadow-lg; + box-shadow: var(--bo-shadow-lg); } .btn { border: none; - border-right: 1px solid $bo-gray-200; - background: $bo-white; - color: $bo-gray-600; + border-right: 1px solid var(--bo-gray-200); + background: var(--bo-white); + color: var(--bo-gray-600); margin: 0; border-radius: 0; - transition: $bo-transition-default; + transition: var(--bo-transition-default); font-weight: 500; &:last-child { @@ -191,35 +118,35 @@ $bo-transition-fast: all 0.15s ease; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); z-index: 1; } &.active { - background: $bo-primary; - color: $bo-white; + background: var(--bo-primary); + color: var(--bo-white); font-weight: 600; } &.btn-outline-success { - background: $bo-white; - color: $bo-gray-600; + background: var(--bo-white); + color: var(--bo-gray-600); border: none; - border-right: 1px solid $bo-gray-200; + border-right: 1px solid var(--bo-gray-200); &:last-child { border-right: none; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); } &.active { - background: $bo-primary; - color: $bo-white; + background: var(--bo-primary); + color: var(--bo-white); } } } @@ -228,72 +155,72 @@ $bo-transition-fast: all 0.15s ease; // Dropdown toggle with clean styling (NO GRADIENT - except for top menu which has its own styles) .dropdown:not(.user-menu) { .btn { - background: $bo-white; - border: 1px solid $bo-gray-200; - color: $bo-gray-600; - border-radius: $bo-border-radius; - box-shadow: $bo-shadow; - transition: $bo-transition-default; + background: var(--bo-white); + border: 1px solid var(--bo-gray-200); + color: var(--bo-gray-600); + border-radius: var(--bo-border-radius); + box-shadow: var(--bo-shadow); + transition: var(--bo-transition-default); font-weight: 500; &:hover { - background: $bo-gray-50; - color: $bo-primary; - border-color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); + border-color: var(--bo-primary); transform: translateY(-2px); - box-shadow: $bo-shadow-lg; + box-shadow: var(--bo-shadow-lg); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-primary, 0.15); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); } &.active { - background: $bo-primary; - color: $bo-white; - border-color: $bo-primary; + background: var(--bo-primary); + color: var(--bo-white); + border-color: var(--bo-primary); } } .dropdown-menu { - border-radius: $bo-border-radius-lg; - box-shadow: $bo-shadow-xl; - border: 1px solid rgba($bo-black, 0.08); + border-radius: var(--bo-border-radius-lg); + box-shadow: var(--bo-shadow-xl); + border: 1px solid rgba(var(--bo-black), 0.08); padding: 0.5rem; margin-top: 0.5rem; animation: dropdownSlide 0.2s ease; .dropdown-item { - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); padding: 0.625rem 1rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.75rem; i { - color: $bo-primary; + color: var(--bo-primary); width: 1.25rem; } &:hover { - background: $bo-gray-50; - color: $bo-primary; + background: var(--bo-gray-50); + color: var(--bo-primary); transform: translateX(4px); } &:active { - background: $bo-primary-light; + background: var(--bo-primary-light); } } .dropdown-divider { margin: 0.5rem 0; - border-color: rgba($bo-black, 0.08); + border-color: rgba(var(--bo-black), 0.08); } .dropdown-header { - color: $bo-gray-500; + color: var(--bo-gray-500); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; @@ -316,40 +243,40 @@ $bo-transition-fast: all 0.15s ease; // Card with modern styling .card { - border-radius: $bo-border-radius-xl; - border: 1px solid rgba($bo-black, 0.08); - box-shadow: $bo-shadow-sm; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius-xl); + border: 1px solid rgba(var(--bo-black), 0.08); + box-shadow: var(--bo-shadow-sm); + transition: var(--bo-transition-default); &:hover { - box-shadow: $bo-shadow; + box-shadow: var(--bo-shadow); } .card-header { - border-radius: $bo-border-radius-xl $bo-border-radius-xl 0 0; - border-bottom: 1px solid rgba($bo-black, 0.08); - background: linear-gradient(180deg, $bo-white 0%, $bo-gray-50 100%); + border-radius: var(--bo-border-radius-xl) var(--bo-border-radius-xl) 0 0; + border-bottom: 1px solid rgba(var(--bo-black), 0.08); + background: linear-gradient(180deg, var(--bo-white) 0%, var(--bo-gray-50) 100%); } } // Modern alert styling (NO GRADIENT) .alert { - border-radius: $bo-border-radius-lg; + border-radius: var(--bo-border-radius-lg); border: none; - box-shadow: $bo-shadow; + box-shadow: var(--bo-shadow); &.alert-info { - background: $bo-primary-light; - color: $bo-primary-dark; - border-left: 4px solid $bo-primary; + background: var(--bo-primary-light); + color: var(--bo-primary-dark); + border-left: 4px solid var(--bo-primary); } } // Table action buttons .table { .btn-sm { - border-radius: $bo-border-radius; - transition: $bo-transition-default; + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-default); &:hover { transform: translateY(-2px); @@ -363,7 +290,7 @@ $bo-transition-fast: all 0.15s ease; // Badge modern styling .badge { - border-radius: $bo-border-radius-sm; + border-radius: var(--bo-border-radius-sm); padding: 0.375rem 0.75rem; font-weight: 500; } @@ -371,19 +298,19 @@ $bo-transition-fast: all 0.15s ease; // Empty state with modern styling .empty-state { i { - color: $bo-primary; + color: var(--bo-primary); } } // Form controls modern styling .form-control, .form-select { - border-radius: $bo-border-radius; - border: 1px solid $bo-gray-200; - transition: $bo-transition-smooth; + border-radius: var(--bo-border-radius); + border: 1px solid var(--bo-gray-200); + transition: var(--bo-transition-smooth); &:focus { - border-color: $bo-success; - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); + border-color: var(--bo-success); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); } } @@ -399,52 +326,52 @@ $bo-transition-fast: all 0.15s ease; // Modern outline success button (green outline) .btn-outline-success:not(.btn-group .btn) { - background: $bo-white; - border: 2px solid $bo-success; - color: $bo-success; - border-radius: $bo-border-radius-lg; + background: var(--bo-white); + border: 2px solid var(--bo-success); + color: var(--bo-success); + border-radius: var(--bo-border-radius-lg); padding: 0.625rem 1.25rem; font-weight: 500; - transition: $bo-transition-default; + transition: var(--bo-transition-default); display: inline-flex; align-items: center; gap: 0.5rem; &:hover { - background: rgba($bo-success, 0.08); - border-color: $bo-success-hover; - color: $bo-success-hover; + background: rgba(var(--bo-success), 0.08); + border-color: var(--bo-success-hover); + color: var(--bo-success-hover); transform: translateY(-2px); - box-shadow: $bo-shadow-success; + box-shadow: var(--bo-shadow-success); } &:active { transform: translateY(0); - background: rgba($bo-success, 0.15); + background: rgba(var(--bo-success), 0.15); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-success, 0.25); - color: $bo-success; + box-shadow: 0 0 0 0.25rem rgba(var(--bo-success), 0.25); + color: var(--bo-success); } } // Custom text colors (clean Bootstrap primary) .text-primary-violet { - color: $bo-primary !important; + color: var(--bo-primary !important); } // Button group enhancements .btn-group { .btn-success, .btn-outline-success { &:first-child { - border-top-left-radius: $bo-border-radius-lg; - border-bottom-left-radius: $bo-border-radius-lg; + border-top-left-radius: var(--bo-border-radius-lg); + border-bottom-left-radius: var(--bo-border-radius-lg); } &:last-child { - border-top-right-radius: $bo-border-radius-lg; - border-bottom-right-radius: $bo-border-radius-lg; + border-top-right-radius: var(--bo-border-radius-lg); + border-bottom-right-radius: var(--bo-border-radius-lg); } &:not(:first-child):not(:last-child) { @@ -493,10 +420,10 @@ $bo-transition-fast: all 0.15s ease; // Table row hover effect (NO GRADIENT) .table-hover tbody tr { - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); &:hover { - background-color: $bo-gray-50; + background-color: var(--bo-gray-50); //transform: scale(1.001); } } diff --git a/assets/styles/components/_backoffice_menu.scss b/assets/styles/components/_backoffice_menu.scss index 1ff6322..7afd11b 100644 --- a/assets/styles/components/_backoffice_menu.scss +++ b/assets/styles/components/_backoffice_menu.scss @@ -1,76 +1,11 @@ // Clean Backoffice Menu Styling // Modern Bootstrap navbar with solid colors - NO GRADIENTS -// ============================================ -// BACKOFFICE COLOR VARIABLES -// ============================================ - -// Base Colors -$bo-white: #ffffff; -$bo-black: #000000; -$bo-gray-50: #f8f9fa; -$bo-gray-100: #e9ecef; -$bo-gray-200: #dee2e6; -$bo-gray-300: #ced4da; -$bo-gray-400: #adb5bd; -$bo-gray-500: #6c757d; -$bo-gray-600: #495057; -$bo-gray-700: #343a40; -$bo-gray-800: #212529; - -// Primary Colors (Bootstrap Blue) -$bo-primary: #0d6efd; -$bo-primary-hover: #0b5ed7; -$bo-primary-active: #0a58ca; -$bo-primary-light: #e7f1ff; -$bo-primary-dark: #084298; - -// Success Colors (Green) -$bo-success: #198754; -$bo-success-hover: #157347; -$bo-success-active: #146c43; -$bo-success-light: #d1e7dd; -$bo-success-dark: #0f5132; - -// Danger Colors (Red) -$bo-danger: #dc3545; -$bo-danger-hover: #bb2d3b; -$bo-danger-active: #b02a37; -$bo-danger-light: #f8d7da; -$bo-danger-dark: #842029; - -// Header Colors -$bo-header-bg: $bo-white; -$bo-header-border: $bo-gray-200; -$bo-header-shadow: 0 2px 4px rgba($bo-black, 0.08); - -// Navigation Colors -$bo-nav-text: $bo-gray-600; -$bo-nav-text-hover: $bo-primary; -$bo-nav-text-active: $bo-primary; -$bo-nav-bg-hover: $bo-gray-50; -$bo-nav-bg-active: $bo-primary-light; -$bo-nav-border-active: $bo-primary; - -// LARP Title Colors -$bo-title-color: $bo-gray-800; -$bo-title-icon-color: $bo-primary; - -// Border Radius -$bo-border-radius: 6px; -$bo-border-radius-sm: 4px; -$bo-border-radius-lg: 8px; -$bo-border-radius-xl: 10px; - -// Transitions -$bo-transition-smooth: all 0.2s ease; -$bo-transition-fast: all 0.15s ease; - // Main header container .larp-backoffice-header { - background: $bo-header-bg; - border-bottom: 2px solid $bo-header-border; - box-shadow: $bo-header-shadow; + background: var(--bo-header-bg); + border-bottom: 2px solid var(--bo-header-border); + box-shadow: var(--bo-header-shadow); margin-bottom: 1.5rem; padding: 1rem 0; @@ -81,23 +16,23 @@ $bo-transition-fast: all 0.15s ease; gap: 1rem; padding-bottom: 1rem; margin-bottom: 1rem; - border-bottom: 1px solid $bo-header-border; + border-bottom: 1px solid var(--bo-header-border); // Hamburger toggle button .navbar-toggler { padding: 0.5rem; - border: 1px solid $bo-header-border; - border-radius: $bo-border-radius; + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius); background: transparent; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); &:hover { - background: $bo-nav-bg-hover; - border-color: $bo-nav-text-hover; + background: var(--bo-nav-bg-hover); + border-color: var(--bo-nav-text-hover); } &:focus { - box-shadow: 0 0 0 0.25rem rgba($bo-primary, 0.15); + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); } // Hide on desktop @@ -107,7 +42,7 @@ $bo-transition-fast: all 0.15s ease; } .larp-title { - color: $bo-title-color; + color: var(--bo-title-color); font-size: 1.75rem; font-weight: 600; margin: 0; @@ -116,7 +51,7 @@ $bo-transition-fast: all 0.15s ease; gap: 0.75rem; i { - color: $bo-title-icon-color; + color: var(--bo-title-icon-color); font-size: 2rem; } @@ -148,12 +83,12 @@ $bo-transition-fast: all 0.15s ease; .nav-item { .nav-link { - color: $bo-nav-text; + color: var(--bo-nav-text); padding: 0.625rem 1rem; - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); font-weight: 500; font-size: 0.9375rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.5rem; @@ -161,13 +96,13 @@ $bo-transition-fast: all 0.15s ease; i { font-size: 1.125rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); } // Hover state &:hover { - color: $bo-nav-text-hover; - background: $bo-nav-bg-hover; + color: var(--bo-nav-text-hover); + background: var(--bo-nav-bg-hover); i { transform: scale(1.1); @@ -176,10 +111,10 @@ $bo-transition-fast: all 0.15s ease; // Active state &.active { - color: $bo-nav-text-active; - background: $bo-nav-bg-active; + color: var(--bo-nav-text-active); + background: var(--bo-nav-bg-active); font-weight: 600; - border-bottom: 2px solid $bo-nav-border-active; + border-bottom: 2px solid var(--bo-nav-border-active); } } @@ -197,48 +132,48 @@ $bo-transition-fast: all 0.15s ease; } .dropdown-menu { - border: 1px solid $bo-header-border; - border-radius: $bo-border-radius-lg; - box-shadow: 0 4px 12px rgba($bo-black, 0.1); + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius-lg); + box-shadow: 0 4px 12px rgba(var(--bo-black), 0.1); padding: 0.5rem; margin-top: 0.25rem; min-width: 240px; .dropdown-item { - border-radius: $bo-border-radius; + border-radius: var(--bo-border-radius); padding: 0.625rem 1rem; - transition: $bo-transition-smooth; + transition: var(--bo-transition-smooth); display: flex; align-items: center; gap: 0.75rem; - color: $bo-nav-text; + color: var(--bo-nav-text); font-size: 0.9375rem; i { - color: $bo-nav-text-hover; + color: var(--bo-nav-text-hover); width: 1.25rem; font-size: 1rem; text-align: center; } &:hover { - background: $bo-nav-bg-hover; - color: $bo-nav-text-hover; + background: var(--bo-nav-bg-hover); + color: var(--bo-nav-text-hover); transform: translateX(4px); } &:active { - background: $bo-nav-bg-active; + background: var(--bo-nav-bg-active); } } .dropdown-divider { margin: 0.5rem 0; - border-color: $bo-header-border; + border-color: var(--bo-header-border); } .dropdown-header { - color: $bo-gray-500; + color: var(--bo-gray-500); font-weight: 600; font-size: 0.75rem; text-transform: uppercase; @@ -274,7 +209,7 @@ $bo-transition-fast: all 0.15s ease; .navbar-collapse { margin-top: 1rem; padding-top: 1rem; - border-top: 1px solid $bo-header-border; + border-top: 1px solid var(--bo-header-border); } .navbar-nav { diff --git a/assets/styles/components/_character_choice_styles.scss b/assets/styles/components/_character_choice_styles.scss index 69901f6..96625b2 100755 --- a/assets/styles/components/_character_choice_styles.scss +++ b/assets/styles/components/_character_choice_styles.scss @@ -4,18 +4,18 @@ border: 2px solid transparent; } .character-choice-item:hover { - background-color: #f8f9fa; - border-color: #dee2e6; + background-color: var(--bg-secondary); + border-color: var(--border-color); } .character-choice-item.sortable-chosen { - background-color: #fff; + background-color: var(--card-bg); box-shadow: 0 8px 25px rgba(0,0,0,0.3); - border-color: #0d6efd; + border-color: var(--bo-primary); transform: rotate(3deg); } .character-choice-item.sortable-ghost { - background-color: #e7f3ff; - border: 2px dashed #0d6efd; + background-color: var(--bo-primary-light); + border: 2px dashed var(--bo-primary); opacity: 0.5; } .priority-badge { @@ -29,18 +29,18 @@ } .drag-handle { cursor: move; - color: #6c757d; + color: var(--text-secondary); font-size: 1.2em; padding: 5px; border-radius: 4px; transition: all 0.2s ease; } .drag-handle:hover { - color: #495057; - background-color: #f8f9fa; + color: var(--text-primary); + background-color: var(--bg-secondary); } .choice-content { - border-left: 3px solid #e9ecef; + border-left: 3px solid var(--border-color-light); padding-left: 1rem; } .choice-header { diff --git a/assets/styles/components/_colors.scss b/assets/styles/components/_colors.scss new file mode 100644 index 0000000..710642c --- /dev/null +++ b/assets/styles/components/_colors.scss @@ -0,0 +1,212 @@ +:root { + + // ============================================ + // BACKOFFICE COLOR VARIABLES + // ============================================ + + // Base Colors + --bo-white: #ffffff; + --bo-black: #000000; + --bo-gray-50: #f8f9fa; + --bo-gray-100: #e9ecef; + --bo-gray-200: #dee2e6; + --bo-gray-300: #ced4da; + --bo-gray-400: #adb5bd; + --bo-gray-500: #6c757d; + --bo-gray-600: #495057; + --bo-gray-700: #343a40; + --bo-gray-800: #212529; + + // Primary Colors (Bootstrap Blue) + --bo-primary: #0d6efd; + --bo-primary-hover: #0b5ed7; + --bo-primary-active: #0a58ca; + --bo-primary-light: #e7f1ff; + --bo-primary-dark: #084298; + + // Success Colors (Green) + --bo-success: #198754; + --bo-success-hover: #157347; + --bo-success-active: #146c43; + --bo-success-light: #d1e7dd; + --bo-success-dark: #0f5132; + + // Danger Colors (Red) + --bo-danger: #dc3545; + --bo-danger-hover: #bb2d3b; + --bo-danger-active: #b02a37; + --bo-danger-light: #f8d7da; + --bo-danger-dark: #842029; + + // Warning Colors (Yellow) + --bo-warning: #ffc107; + --bo-warning-hover: #ffca2c; + --bo-warning-active: #ffcd39; + --bo-warning-light: #fff3cd; + --bo-warning-dark: #997404; + + // Info Colors (Cyan) + --bo-info: #0dcaf0; + --bo-info-hover: #31d2f2; + --bo-info-active: #3dd5f3; + --bo-info-light: #cff4fc; + --bo-info-dark: #055160; + + // Header Colors + --bo-header-bg: var(--bo-white); + --bo-header-border: var(--bo-gray-200); + --bo-header-shadow: 0 2px 4px rgba(--bo-black, 0.08); + + // Navigation Colors + --bo-nav-text: var(--bo-gray-600); + --bo-nav-text-hover: var(--bo-primary); + --bo-nav-text-active: var(--bo-primary); + --bo-nav-bg-hover: var(--bo-gray-50); + --bo-nav-bg-active: var(--bo-primary-light); + --bo-nav-border-active: var(--bo-primary); + + // LARP Title Colors + --bo-title-color: var(--bo-gray-800); + --bo-title-icon-color: var(--bo-primary); + + // Border Radius + --bo-border-radius: 6px; + --bo-border-radius-sm: 4px; + --bo-border-radius-lg: 8px; + --bo-border-radius-xl: 10px; + + //--bo-border-radius: 8px; + //--bo-border-radius-sm: 6px; + //--bo-border-radius-lg: 10px; + //--bo-border-radius-xl: 12px; + + // Transitions + --bo-transition-smooth: all 0.2s ease; + --bo-transition-fast: all 0.15s ease; + --bo-transition-default: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + // Shadows + --bo-shadow-sm: 0 2px 4px rgba(var(--bo-black), 0.08); + --bo-shadow: 0 2px 8px rgba(var(--bo-black), 0.08); + --bo-shadow-lg: 0 4px 12px rgba(var(--bo-black), 0.12); + --bo-shadow-xl: 0 8px 24px rgba(var(--bo-black), 0.15); + + --bo-shadow-success: 0 4px 12px rgba(var(--bo-success), 0.3); + --bo-shadow-success-hover: 0 6px 16px rgba(var(--bo-success), 0.4); + --bo-shadow-danger: 0 4px 12px rgba(var(--bo-danger), 0.3); + + //////////////////////// + // Background colors + --bg-primary: #2d2d2d; + --bg-secondary: #f8f9fa; + --bg-tertiary: #f0f0f0; + --bg-card: #ffffff; + --bg-sidebar: #ffffff; + --bg-sidebar-header: #0d6efd; + + // Text colors + --text-primary: #212529; + --text-secondary: #6c757d; + --text-muted: #8e8e93; + --text-inverse: #ffffff; + + // Border colors + --border-color: #dee2e6; + --border-color-light: #e0e0e0; + + // Component colors + --card-bg: #ffffff; + --modal-bg: #ffffff; + --table-bg: #ffffff; + --table-hover-bg: rgba(0, 0, 0, 0.05); + --table-striped-bg: rgba(0, 0, 0, 0.025); + + // Input colors + --input-bg: #ffffff; + --input-border: #ced4da; + --input-text: #212529; + --input-placeholder: #6c757d; + + // Shadow colors + --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + + // Sidebar specific (using primary blue) + --sidebar-text: #2d3748; + --sidebar-link-hover-bg: rgba(13, 110, 253, 0.08); + --sidebar-link-active-bg: rgba(13, 110, 253, 0.15); + --sidebar-scrollbar-track: rgba(0, 0, 0, 0.05); + --sidebar-scrollbar-thumb: rgba(13, 110, 253, 0.3); + --sidebar-scrollbar-thumb-hover: rgba(13, 110, 253, 0.5); +} + +// Dark mode variables +html.dark-mode { + // Header Colors (dark mode) + --bo-header-bg: #2d2d2d; + --bo-header-border: #404040; + --bo-header-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + + // Navigation Colors (dark mode) + --bo-nav-text: #b8b8b8; + --bo-nav-text-hover: #6ea8fe; + --bo-nav-text-active: #ffffff; + --bo-nav-bg-hover: rgba(13, 110, 253, 0.15); + --bo-nav-bg-active: rgba(13, 110, 253, 0.25); + --bo-nav-border-active: #0d6efd; + + // LARP Title Colors (dark mode) + --bo-title-color: #e8e8e8; + --bo-title-icon-color: #6ea8fe; + + // Gray scale (dark mode) + --bo-gray-50: #3a3a3a; + --bo-gray-100: #4a4a4a; + --bo-gray-200: #5a5a5a; + --bo-gray-500: #999999; + + // Background colors + --bg-primary: #1a1a1a; + --bg-secondary: #2f2f2f; + --bg-tertiary: #4a4a4a; + --bg-card: #2d2d2d; + --bg-sidebar: #4a4a4a; + --bg-sidebar-header: var(--bo-primary); + + // Text colors + --text-primary: #e8e8e8; + --text-secondary: #b8b8b8; + --text-muted: #8e8e93; + --text-inverse: #1a1a1a; + + // Border colors + --border-color: #404040; + --border-color-light: #4a4a4a; + + // Component colors + --card-bg: #2d2d2d; + --modal-bg: #2d2d2d; + --table-bg: #2d2d2d; + --table-hover-bg: rgba(255, 255, 255, 0.05); + --table-striped-bg: rgba(255, 255, 255, 0.025); + + // Input colors + --input-bg: #3a3a3a; + --input-border: #4a4a4a; + --input-text: #e8e8e8; + --input-placeholder: #8e8e93; + + // Shadow colors + --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); + --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); + --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); + + // Sidebar specific (using primary blue) + --sidebar-text: #e8e8e8; + --sidebar-link-hover-bg: rgba(13, 110, 253, 0.15); + --sidebar-link-active-bg: rgba(13, 110, 253, 0.25); + --sidebar-scrollbar-track: rgba(255, 255, 255, 0.05); + --sidebar-scrollbar-thumb: rgba(13, 110, 253, 0.4); + --sidebar-scrollbar-thumb-hover: rgba(13, 110, 253, 0.6); +} \ No newline at end of file diff --git a/assets/styles/components/_dark_mode.scss b/assets/styles/components/_dark_mode.scss index 942411e..373d3c9 100644 --- a/assets/styles/components/_dark_mode.scss +++ b/assets/styles/components/_dark_mode.scss @@ -1,101 +1,126 @@ -// Dark Mode Variables and Styles - -// Light mode variables (default) -:root { - // Background colors - --bg-primary: #ffffff; - --bg-secondary: #f8f9fa; - --bg-tertiary: #f0f0f0; - --bg-card: #ffffff; - --bg-sidebar: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%); - --bg-sidebar-header: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +// Apply dark mode styles to various components +html.dark-mode { + .larp-backoffice-header{ + background: var(--bg-tertiary); + border-bottom: 2px solid #0d6efd; + } - // Text colors - --text-primary: #212529; - --text-secondary: #6c757d; - --text-muted: #8e8e93; - --text-inverse: #ffffff; - - // Border colors - --border-color: #dee2e6; - --border-color-light: #e0e0e0; - - // Component colors - --card-bg: #ffffff; - --modal-bg: #ffffff; - --table-bg: #ffffff; - --table-hover-bg: rgba(0, 0, 0, 0.05); - --table-striped-bg: rgba(0, 0, 0, 0.025); - - // Input colors - --input-bg: #ffffff; - --input-border: #ced4da; - --input-text: #212529; - --input-placeholder: #6c757d; - - // Shadow colors - --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); - --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); - --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); - - // Sidebar specific - --sidebar-text: #2d3748; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%); - --sidebar-scrollbar-track: rgba(0, 0, 0, 0.05); - --sidebar-scrollbar-thumb: rgba(102, 126, 234, 0.3); - --sidebar-scrollbar-thumb-hover: rgba(102, 126, 234, 0.5); -} + .larp-header-title { + border-bottom: 1px solid var(--bo-header-border); -// Dark mode variables -html.dark-mode { - // Background colors - --bg-primary: #1a1a1a; - --bg-secondary: #2d2d2d; - --bg-tertiary: #3a3a3a; - --bg-card: #2d2d2d; - --bg-sidebar: linear-gradient(180deg, #2d2d2d 0%, #1a1a1a 100%); - --bg-sidebar-header: linear-gradient(135deg, #5568d3 0%, #6a4c93 100%); + // Hamburger toggle button + .navbar-toggler { + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius); + background: transparent; + transition: var(--bo-transition-smooth); - // Text colors - --text-primary: #e8e8e8; - --text-secondary: #b8b8b8; - --text-muted: #8e8e93; - --text-inverse: #1a1a1a; - - // Border colors - --border-color: #404040; - --border-color-light: #4a4a4a; - - // Component colors - --card-bg: #2d2d2d; - --modal-bg: #2d2d2d; - --table-bg: #2d2d2d; - --table-hover-bg: rgba(255, 255, 255, 0.05); - --table-striped-bg: rgba(255, 255, 255, 0.025); - - // Input colors - --input-bg: #3a3a3a; - --input-border: #4a4a4a; - --input-text: #e8e8e8; - --input-placeholder: #8e8e93; - - // Shadow colors - --shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3); - --shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4); - --shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5); - - // Sidebar specific - --sidebar-text: #e8e8e8; - --sidebar-link-hover-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.15) 0%, rgba(106, 76, 147, 0.15) 100%); - --sidebar-link-active-bg: linear-gradient(135deg, rgba(85, 104, 211, 0.25) 0%, rgba(106, 76, 147, 0.25) 100%); - --sidebar-scrollbar-track: rgba(255, 255, 255, 0.05); - --sidebar-scrollbar-thumb: rgba(85, 104, 211, 0.4); - --sidebar-scrollbar-thumb-hover: rgba(85, 104, 211, 0.6); -} + &:hover { + background: var(--bo-nav-bg-hover); + border-color: var(--bo-nav-text-hover); + } + + &:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bo-primary), 0.15); + } + } + + .larp-title { + color: var(--bo-title-color); + font-size: 1.75rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.75rem; + + i { + color: var(--bo-title-icon-color); + font-size: 2rem; + } + + span { + line-height: 1.2; + } + } + + // Preview button + .btn-outline-primary { + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.5rem; + } + } + + // Navigation Bar + .larp-navbar { + + .navbar-nav { + + .nav-item { + .nav-link { + color: var(--bo-nav-text); + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-smooth); + i { + transition: var(--bo-transition-smooth); + } + // Hover state + &:hover { + color: var(--bo-nav-text-hover); + background: var(--bo-nav-bg-hover); + } + + // Active state + &.active { + color: var(--bo-nav-text-active); + background: var(--bo-nav-bg-active); + border-bottom: 2px solid var(--bo-nav-border-active); + } + } + + // Dropdown styling + &.dropdown { + + .dropdown-menu { + background-color: var(--bg-card); + border: 1px solid var(--bo-header-border); + border-radius: var(--bo-border-radius-lg); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + + .dropdown-item { + border-radius: var(--bo-border-radius); + transition: var(--bo-transition-smooth); + color: var(--bo-nav-text); + + i { + color: var(--bo-nav-text-hover); + } + + &:hover { + background: var(--bo-nav-bg-hover); + color: var(--bo-nav-text-hover); + } + + &:active { + background: var(--bo-nav-bg-active); + } + } + + .dropdown-divider { + border-color: var(--bo-header-border); + } + + .dropdown-header { + color: var(--bo-gray-500); + } + } + } + } + } + } -// Apply dark mode styles to various components -html.dark-mode { body { background-color: var(--bg-primary); color: var(--text-primary); @@ -117,6 +142,7 @@ html.dark-mode { background-color: var(--bg-secondary); border-color: var(--border-color); color: var(--text-primary); + background: var(--bs-primary-rgb); } .card-body { @@ -170,7 +196,7 @@ html.dark-mode { &:focus { background-color: var(--input-bg); color: var(--input-text); - border-color: #667eea; + border-color: var(--bo-primary); } } @@ -306,3 +332,57 @@ html.dark-mode { outline-offset: 2px; } } + +.card { + + + .card-header { + border-bottom: 1px solid rgba(var(--bo-black), 0.08); + background: linear-gradient(180deg, #2d2d2d 0%, var(--bo-gray-50) 100%); + } +} + +// Google Maps dark mode styling +html.dark-mode { + // Invert map colors for dark mode effect + #map, + .google-map, + [data-google-map] { + filter: invert(90%) hue-rotate(180deg); + } + + // Preserve marker and image colors by double-inverting + #map .gm-style img, + #map .gm-style canvas, + .google-map .gm-style img, + .google-map .gm-style canvas { + filter: invert(100%) hue-rotate(180deg); + } + + // InfoWindow styling + .gm-style .gm-style-iw-c, + .gm-style .gm-style-iw-d { + background-color: var(--card-bg) !important; + } + + .gm-style .gm-style-iw-tc::after { + background: var(--card-bg) !important; + } + + .gm-style .gm-style-iw { + color: var(--text-primary) !important; + + h6, .h6 { + color: var(--text-primary) !important; + } + + p, .text-muted { + color: var(--text-secondary) !important; + } + } + + // Close button in InfoWindow + .gm-style .gm-ui-hover-effect { + filter: invert(100%); + } +} \ No newline at end of file diff --git a/assets/styles/components/_folder_browser.scss b/assets/styles/components/_folder_browser.scss index 342fb78..1a474d4 100755 --- a/assets/styles/components/_folder_browser.scss +++ b/assets/styles/components/_folder_browser.scss @@ -14,7 +14,7 @@ } .tree-row:hover { - background-color: #f0f0f0; + background-color: var(--bg-tertiary); } .checkbox-label { @@ -29,17 +29,18 @@ .tree-label { cursor: pointer; transition: color 0.2s; + color: var(--text-primary); } .tree-label:hover { - color: #007bff; + color: var(--bo-primary); } .subfolders { margin-left: 20px; } -/* ✅ Loader styling */ +/* Loader styling */ .loader { text-align: center; } @@ -47,4 +48,4 @@ .loading-icon { width: 18px; height: 18px; -} \ No newline at end of file +} diff --git a/assets/styles/components/_kanban.scss b/assets/styles/components/_kanban.scss index e0c8fbb..c315413 100755 --- a/assets/styles/components/_kanban.scss +++ b/assets/styles/components/_kanban.scss @@ -1,22 +1,22 @@ //assets/styles/components/_kanban.scss .kanban-board { - background: #f8f9fa; + background: var(--bg-secondary); padding: 20px; border-radius: 8px; } .kanban-column { - background: #ffffff; + background: var(--card-bg); border-radius: 8px; padding: 15px; margin: 0 10px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: var(--shadow-sm); min-height: 600px; } .kanban-column h5 { - background: #6c757d; - color: white; + background: var(--bo-gray-500); + color: var(--text-inverse); padding: 10px; border-radius: 5px; margin-bottom: 15px; @@ -24,30 +24,30 @@ } .kanban-column[data-status="TODO"] h5 { - background: #6c757d; + background: var(--bo-gray-500); } .kanban-column[data-status="IN_PROGRESS"] h5 { - background: #ffc107; + background: var(--bo-warning); } .kanban-column[data-status="DONE"] h5 { - background: #28a745; + background: var(--bo-success); } .kanban-task { - background: #fff; - border: 1px solid #dee2e6; + background: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 5px; padding: 10px; margin-bottom: 10px; cursor: move; - box-shadow: 0 1px 3px rgba(0,0,0,0.1); + box-shadow: var(--shadow-sm); transition: all 0.2s; } .kanban-task:hover { - box-shadow: 0 3px 6px rgba(0,0,0,0.15); + box-shadow: var(--shadow); transform: translateY(-1px); } @@ -56,7 +56,7 @@ } .kanban-task.sortable-chosen { - background: #e3f2fd; + background: var(--bo-primary-light); } .task-header { @@ -68,11 +68,11 @@ .task-title { font-weight: bold; - color: #495057; + color: var(--text-primary); cursor: pointer; - + &:hover { - color: #007bff; + color: var(--bo-primary); } } @@ -80,24 +80,24 @@ font-size: 0.8em; padding: 2px 6px; border-radius: 3px; - color: white; + color: var(--text-inverse); } .task-priority.high { - background: #dc3545; + background: var(--bo-danger); } .task-priority.medium { - background: #ffc107; + background: var(--bo-warning); } .task-priority.low { - background: #28a745; + background: var(--bo-success); } .task-assignee { font-size: 0.9em; - color: #6c757d; + color: var(--text-secondary); margin-top: 5px; } @@ -107,12 +107,12 @@ .task-due-date { font-size: 0.8em; - color: #dc3545; + color: var(--bo-danger); margin-top: 5px; } .task-form { - background: #f8f9fa; + background: var(--bg-secondary); padding: 20px; border-radius: 8px; margin-top: 20px; @@ -120,17 +120,17 @@ .sortable-ghost { opacity: 0.4; - background: #c8ebfb; + background: var(--bo-info-light); } .assignment-dropdown { position: absolute; top: 100%; right: 0; - background: white; - border: 1px solid #dee2e6; + background: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 5px; - box-shadow: 0 2px 10px rgba(0,0,0,0.1); + box-shadow: var(--shadow); min-width: 200px; z-index: 1000; display: none; @@ -147,10 +147,11 @@ background: none; width: 100%; text-align: left; + color: var(--text-primary); } .assignment-dropdown .dropdown-item:hover { - background: #f8f9fa; + background: var(--bg-secondary); } // Floating Add Button @@ -160,8 +161,8 @@ right: 30px; width: 60px; height: 60px; - background: #007bff; - color: white; + background: var(--bo-primary); + color: var(--text-inverse); border: none; border-radius: 50%; font-size: 24px; @@ -169,16 +170,16 @@ align-items: center; justify-content: center; cursor: pointer; - box-shadow: 0 4px 12px rgba(0,123,255,0.4); + box-shadow: 0 4px 12px rgba(13, 110, 253, 0.4); transition: all 0.3s ease; z-index: 1000; - + &:hover { - background: #0056b3; + background: var(--bo-primary-hover); transform: scale(1.1); - box-shadow: 0 6px 16px rgba(0,123,255,0.5); + box-shadow: 0 6px 16px rgba(13, 110, 253, 0.5); } - + &:active { transform: scale(0.95); } @@ -199,7 +200,7 @@ opacity: 0; visibility: hidden; transition: all 0.3s ease; - + &.show { opacity: 1; visibility: visible; @@ -208,7 +209,7 @@ .modal-content { padding: 10px; - background: white !important; + background: var(--modal-bg) !important; border-radius: 8px; max-width: 600px; width: 90%; @@ -216,7 +217,7 @@ overflow-y: auto; transform: scale(0.9); transition: transform 0.3s ease; - + .modal-overlay.show & { transform: scale(1); } @@ -224,14 +225,14 @@ .modal-header { padding: 20px; - border-bottom: 1px solid #dee2e6; + border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center; - + h5 { margin: 0; - color: #495057; + color: var(--text-primary); } } @@ -240,10 +241,10 @@ border: none; font-size: 24px; cursor: pointer; - color: #6c757d; - + color: var(--text-secondary); + &:hover { - color: #495057; + color: var(--text-primary); } } @@ -253,7 +254,7 @@ .modal-footer { padding: 20px; - border-top: 1px solid #dee2e6; + border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 10px; @@ -266,60 +267,60 @@ flex-wrap: wrap; gap: 15px; margin-bottom: 20px; - + .meta-item { display: flex; align-items: center; gap: 5px; - + .meta-label { font-weight: bold; - color: #6c757d; + color: var(--text-secondary); } - + .meta-value { - color: #495057; + color: var(--text-primary); } } } - + .task-description { margin-bottom: 20px; - + h6 { - color: #6c757d; + color: var(--text-secondary); margin-bottom: 10px; } - + .description-content { - background: #f8f9fa; + background: var(--bg-secondary); padding: 15px; border-radius: 5px; - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); } } - + .task-activity { h6 { - color: #6c757d; + color: var(--text-secondary); margin-bottom: 15px; } - + .activity-item { padding: 10px; - border-left: 3px solid #007bff; + border-left: 3px solid var(--bo-primary); margin-bottom: 10px; - background: #f8f9fa; + background: var(--bg-secondary); border-radius: 0 5px 5px 0; - + .activity-type { font-weight: bold; - color: #007bff; + color: var(--bo-primary); } - + .activity-time { font-size: 0.9em; - color: #6c757d; + color: var(--text-secondary); } } } @@ -331,12 +332,12 @@ justify-content: center; align-items: center; padding: 40px; - + .spinner { width: 40px; height: 40px; - border: 4px solid #f3f3f3; - border-top: 4px solid #007bff; + border: 4px solid var(--border-color-light); + border-top: 4px solid var(--bo-primary); border-radius: 50%; animation: spin 1s linear infinite; } diff --git a/assets/styles/components/_map_preview.scss b/assets/styles/components/_map_preview.scss new file mode 100644 index 0000000..d052440 --- /dev/null +++ b/assets/styles/components/_map_preview.scss @@ -0,0 +1,95 @@ +// ============================================ +// MAP PREVIEW COMPONENT STYLES +// For map configuration with grid preview +// ============================================ + +// Map preview container +.map-preview-container { + background-color: var(--bg-secondary); + overflow: hidden; +} + +// Placeholder when no image +.map-preview-placeholder { + min-height: 400px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-secondary); + + .bi-image { + color: var(--text-muted); + } + + p { + color: var(--text-secondary); + } +} + +// Map preview (Leaflet container) +.map-preview { + height: 500px; + width: 100%; + background: var(--bg-secondary); + + // Leaflet container styling + .leaflet-container { + background: var(--bg-secondary); + } +} + +// Grid label styling +.map-grid-label { + background: transparent; + border: none; + + .grid-label-text { + font-size: 12px; + font-weight: bold; + color: var(--text-primary); + text-align: center; + text-shadow: + 1px 1px 0 var(--bg-secondary), + -1px -1px 0 var(--bg-secondary), + 1px -1px 0 var(--bg-secondary), + -1px 1px 0 var(--bg-secondary); + } +} + +// Responsive adjustments +@media (max-width: 767.98px) { + .map-preview-placeholder { + min-height: 250px; + } + + .map-preview { + height: 350px; + } +} + +// Dark mode adjustments +html.dark-mode { + .map-preview-container { + border-color: var(--border-color) !important; + } + + .map-grid-label .grid-label-text { + color: var(--text-primary); + text-shadow: + 1px 1px 0 var(--bg-primary), + -1px -1px 0 var(--bg-primary), + 1px -1px 0 var(--bg-primary), + -1px 1px 0 var(--bg-primary); + } + + .leaflet-container { + background: var(--bg-secondary); + } +} + +// Grid configuration section +.grid-config-section { + .form-range { + accent-color: var(--bo-primary); + } +} diff --git a/assets/styles/components/_sidebar_menu.scss b/assets/styles/components/_sidebar_menu.scss index 76cc3c2..96249ab 100755 --- a/assets/styles/components/_sidebar_menu.scss +++ b/assets/styles/components/_sidebar_menu.scss @@ -2,12 +2,7 @@ $sidebar-width-mobile: 300px; $sidebar-width-tablet: 340px; $sidebar-width-desktop: 360px; -$sidebar-bg: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%); -$sidebar-border-color: #e0e0e0; -$sidebar-shadow: -4px 0 20px rgba(0, 0, 0, 0.08); -$sidebar-shadow-open: -8px 0 30px rgba(0, 0, 0, 0.12); $sidebar-transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); -$overlay-bg: rgba(0, 0, 0, 0.6); $overlay-transition: opacity 0.3s ease, visibility 0.3s ease; // Breakpoints @@ -25,9 +20,9 @@ $desktop-min: 769px; width: 48px; height: 48px; border-radius: 12px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--bg-sidebar-header); border: none; - box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); + box-shadow: 0 4px 12px rgba(13, 110, 253, 0.3); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; @@ -35,14 +30,14 @@ $desktop-min: 769px; i { font-size: 24px; - color: white; + color: var(--text-inverse); transition: transform 0.2s ease; } &:hover { transform: translateY(-2px); - box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4); - background: linear-gradient(135deg, #764ba2 0%, #667eea 100%); + box-shadow: 0 6px 16px rgba(13, 110, 253, 0.4); + filter: brightness(1.1); i { transform: scale(1.1); @@ -51,7 +46,7 @@ $desktop-min: 769px; &:active { transform: translateY(0); - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3); + box-shadow: 0 2px 8px rgba(13, 110, 253, 0.3); } // Hide button when sidebar is open @@ -75,14 +70,14 @@ $desktop-min: 769px; right: 0; width: $sidebar-width-mobile; height: 100%; - background: var(--bg-sidebar, $sidebar-bg); - border-left: 1px solid var(--border-color-light, $sidebar-border-color); + background: var(--bg-sidebar); + border-left: 1px solid var(--border-color-light); overflow-y: auto; overflow-x: hidden; z-index: 1050; transform: translateX(100%); transition: $sidebar-transition; - box-shadow: $sidebar-shadow; + box-shadow: -4px 0 20px rgba(0, 0, 0, 0.08); // Custom scrollbar &::-webkit-scrollbar { @@ -90,15 +85,15 @@ $desktop-min: 769px; } &::-webkit-scrollbar-track { - background: var(--sidebar-scrollbar-track, rgba(0, 0, 0, 0.05)); + background: var(--sidebar-scrollbar-track); } &::-webkit-scrollbar-thumb { - background: var(--sidebar-scrollbar-thumb, rgba(102, 126, 234, 0.3)); + background: var(--sidebar-scrollbar-thumb); border-radius: 4px; &:hover { - background: var(--sidebar-scrollbar-thumb-hover, rgba(102, 126, 234, 0.5)); + background: var(--sidebar-scrollbar-thumb-hover); } } @@ -111,7 +106,7 @@ $desktop-min: 769px; // Sidebar header .sidebar-header { padding: 1.5rem 1.25rem; - background: var(--bg-sidebar-header, linear-gradient(135deg, #667eea 0%, #764ba2 100%)); + background: var(--bg-sidebar-header); display: flex; justify-content: space-between; align-items: center; @@ -124,7 +119,7 @@ $desktop-min: 769px; margin: 0; font-size: 1.375rem; font-weight: 700; - color: white; + color: var(--text-inverse); letter-spacing: -0.02em; } @@ -144,7 +139,7 @@ $desktop-min: 769px; } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; } } @@ -161,7 +156,7 @@ $desktop-min: 769px; left: 0; width: 100%; height: 100%; - background: $overlay-bg; + background: rgba(0, 0, 0, 0.6); z-index: 1040; opacity: 0; visibility: hidden; @@ -207,7 +202,7 @@ $desktop-min: 769px; width: $sidebar-width-desktop; &.open { - box-shadow: $sidebar-shadow-open; + box-shadow: -8px 0 30px rgba(0, 0, 0, 0.12); } } @@ -220,7 +215,7 @@ $desktop-min: 769px; // Enhanced menu items styling .sidebar-body { .nav-link { - color: var(--sidebar-text, #2d3748); + color: var(--sidebar-text); padding: 0.875rem 1rem; margin-bottom: 0.375rem; border-radius: 10px; @@ -241,14 +236,14 @@ $desktop-min: 769px; top: 0; height: 100%; width: 3px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: var(--bg-sidebar-header); transform: scaleY(0); transition: transform 0.25s ease; } &:hover { - background: var(--sidebar-link-hover-bg, linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.08) 100%)); - color: #667eea; + background: var(--sidebar-link-hover-bg); + color: var(--bo-primary); transform: translateX(4px); padding-left: 1.125rem; @@ -258,10 +253,10 @@ $desktop-min: 769px; } &.active { - background: var(--sidebar-link-active-bg, linear-gradient(135deg, rgba(102, 126, 234, 0.15) 0%, rgba(118, 75, 162, 0.15) 100%)); - color: #667eea; + background: var(--sidebar-link-active-bg); + color: var(--bo-primary); font-weight: 600; - box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15); + box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15); &::before { transform: scaleY(1); @@ -300,7 +295,7 @@ $desktop-min: 769px; .nav { margin-top: 0.375rem; padding-left: 1rem; - border-left: 2px solid rgba(102, 126, 234, 0.1); + border-left: 2px solid rgba(13, 110, 253, 0.1); margin-left: 0.5rem; } @@ -314,7 +309,7 @@ $desktop-min: 769px; // Accessibility improvements .sidebar-menu { &:focus-within { - outline: 2px solid #667eea; + outline: 2px solid var(--bo-primary); outline-offset: -2px; } -} \ No newline at end of file +} diff --git a/assets/styles/components/_wysiwyg.scss b/assets/styles/components/_wysiwyg.scss index e81c93b..83a936c 100755 --- a/assets/styles/components/_wysiwyg.scss +++ b/assets/styles/components/_wysiwyg.scss @@ -3,17 +3,17 @@ .wysiwyg-editor { min-height: 300px; padding: 0.75rem; - border: 1px solid #ced4da; + border: 1px solid var(--input-border); border-radius: 0.25rem; - background-color: #fff; - color: #212529; + background-color: var(--input-bg); + color: var(--input-text); transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; overflow: auto; line-height: 1.5; resize: vertical; &:focus { - border-color: #86b7fe; + border-color: var(--bo-primary); outline: 0; box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } @@ -23,9 +23,9 @@ display: inline-block; padding: 0 0.25rem; margin: 0 0.125rem; - background-color: rgba(13, 110, 253, 0.1); + background-color: var(--bo-primary-light); border-radius: 0.25rem; - color: #0d6efd; + color: var(--bo-primary); font-weight: 500; white-space: nowrap; cursor: default; @@ -41,21 +41,22 @@ max-width: 350px; max-height: 250px; overflow-y: auto; - background-color: #fff; - border: 1px solid rgba(0, 0, 0, 0.125); + background-color: var(--card-bg); + border: 1px solid var(--border-color); border-radius: 0.25rem; - box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow); .mention-item { padding: 0.5rem 1rem; cursor: pointer; + color: var(--text-primary); &:hover, &.selected { - background-color: #f8f9fa; + background-color: var(--bg-secondary); } &.selected { - background-color: rgba(13, 110, 253, 0.1); + background-color: var(--bo-primary-light); } } } diff --git a/assets/styles/components/feedback.scss b/assets/styles/components/feedback.scss index 4fb7b2b..d21c6a5 100644 --- a/assets/styles/components/feedback.scss +++ b/assets/styles/components/feedback.scss @@ -3,13 +3,9 @@ // Unified with Sidebar Menu Gradient Design // ============================================ -// Gradient Variables (matching sidebar menu) -$feedback-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); -$feedback-gradient-hover: linear-gradient(135deg, #764ba2 0%, #667eea 100%); -$feedback-shadow: rgba(102, 126, 234, 0.3); -$feedback-shadow-hover: rgba(102, 126, 234, 0.4); -$feedback-light-bg: rgba(102, 126, 234, 0.08); -$feedback-light-bg-hover: rgba(102, 126, 234, 0.15); +// Shadow Variables (using primary blue) +$feedback-shadow: rgba(13, 110, 253, 0.3); +$feedback-shadow-hover: rgba(13, 110, 253, 0.4); // Floating feedback button (matches sidebar menu button) .feedback-float-button { @@ -19,14 +15,14 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); width: 56px; height: 56px; border-radius: 50%; - background: $feedback-gradient; + background: var(--bg-sidebar-header); border: none; box-shadow: 0 4px 12px $feedback-shadow; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; - color: white; + color: var(--text-inverse); z-index: 1040; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); cursor: pointer; @@ -38,7 +34,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); &:hover { transform: translateY(-3px) scale(1.05); box-shadow: 0 6px 16px $feedback-shadow-hover; - background: $feedback-gradient-hover; + filter: brightness(1.1); i { transform: scale(1.1); @@ -51,7 +47,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; } @@ -81,7 +77,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #feedbackModal { // Modal header with gradient (matching sidebar header) .modal-header { - background: $feedback-gradient; + background: var(--bg-sidebar-header); border-bottom: none; padding: 1.5rem 1.25rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); @@ -92,7 +88,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); align-items: center; font-weight: 600; font-size: 1.375rem; - color: white; + color: var(--text-inverse); letter-spacing: -0.02em; i { @@ -116,7 +112,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - outline: 2px solid white; + outline: 2px solid var(--text-inverse); outline-offset: 2px; box-shadow: none; } @@ -126,24 +122,27 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); // Modal body .modal-body { padding: 1.5rem 1.25rem; + background-color: var(--modal-bg); } // Form elements .form-label { font-weight: 500; margin-bottom: 0.5rem; - color: #2d3748; + color: var(--sidebar-text); } .form-select, .form-control { border-radius: 8px; - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); + background-color: var(--input-bg); + color: var(--input-text); transition: all 0.2s ease; &:focus { - border-color: #667eea; - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + border-color: var(--bo-primary); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } } @@ -154,9 +153,9 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); // Modal footer with gradient buttons .modal-footer { - border-top: 1px solid #dee2e6; + border-top: 1px solid var(--border-color); padding: 1rem 1.25rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); .btn-secondary { border-radius: 8px; @@ -168,7 +167,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } .btn-primary { - background: $feedback-gradient; + background: var(--bg-sidebar-header); border: none; border-radius: 8px; box-shadow: 0 2px 8px $feedback-shadow; @@ -176,7 +175,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); font-weight: 500; &:hover { - background: $feedback-gradient-hover; + filter: brightness(1.1); transform: translateY(-2px); box-shadow: 0 4px 12px $feedback-shadow-hover; } @@ -187,11 +186,11 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); } &:focus { - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } &:disabled { - background: linear-gradient(135deg, rgba(102, 126, 234, 0.6) 0%, rgba(118, 75, 162, 0.6) 100%); + filter: brightness(0.7); cursor: not-allowed; } } @@ -207,7 +206,7 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #screenshotPreview { max-height: 300px; overflow-y: auto; - background-color: #f8f9fa; + background-color: var(--bg-secondary); border-radius: 8px; img { @@ -222,35 +221,37 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); .accordion-button { font-size: 0.875rem; padding: 0.75rem 1rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); border-radius: 8px; transition: all 0.2s ease; + color: var(--text-primary); &:not(.collapsed) { - background: $feedback-light-bg; - color: #667eea; + background: var(--sidebar-link-hover-bg); + color: var(--bo-primary); font-weight: 500; - box-shadow: 0 2px 4px rgba(102, 126, 234, 0.15); + box-shadow: 0 2px 4px rgba(13, 110, 253, 0.15); &::after { - filter: brightness(0) saturate(100%) invert(42%) sepia(93%) saturate(2384%) hue-rotate(226deg); + filter: brightness(0) saturate(100%) invert(37%) sepia(98%) saturate(1561%) hue-rotate(207deg) brightness(101%) contrast(101%); } } &:hover { - background: $feedback-light-bg-hover; + background: var(--sidebar-link-active-bg); } &:focus { - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); - border-color: #667eea; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); + border-color: var(--bo-primary); } } .accordion-body { padding: 1rem; font-size: 0.875rem; - background-color: #f8f9fa; + background-color: var(--bg-secondary); + color: var(--text-primary); } } @@ -266,9 +267,9 @@ $feedback-light-bg-hover: rgba(102, 126, 234, 0.15); #captureScreenshot { &:hover { - background: $feedback-light-bg; - border-color: #667eea; - color: #667eea; + background: var(--sidebar-link-hover-bg); + border-color: var(--bo-primary); + color: var(--bo-primary); } } diff --git a/assets/styles/components/timeline.scss b/assets/styles/components/timeline.scss index fbb0196..406ec95 100644 --- a/assets/styles/components/timeline.scss +++ b/assets/styles/components/timeline.scss @@ -6,9 +6,9 @@ // vis-timeline container #timeline-container { - border: 1px solid #dee2e6; + border: 1px solid var(--border-color); border-radius: 0.375rem; - background: #fff; + background: var(--card-bg); } // vis-timeline event category styling @@ -18,35 +18,35 @@ font-size: 14px; &.timeline-event-historical { - background-color: #6c757d; - border-color: #5a6268; - color: white; + background-color: var(--bo-gray-500); + border-color: var(--bo-gray-600, #5a6268); + color: var(--text-inverse); &.vis-selected { - background-color: #5a6268; - border-color: #4e555b; + background-color: var(--bo-gray-600, #5a6268); + border-color: var(--bo-gray-700, #4e555b); } } &.timeline-event-current { - background-color: #0d6efd; - border-color: #0a58ca; - color: white; + background-color: var(--bo-primary); + border-color: var(--bo-primary-active); + color: var(--text-inverse); &.vis-selected { - background-color: #0a58ca; - border-color: #084298; + background-color: var(--bo-primary-active); + border-color: var(--bo-primary-dark); } } &.timeline-event-future { - background-color: #0dcaf0; - border-color: #0aa2c0; - color: #000; + background-color: var(--bo-info); + border-color: var(--bo-info-hover, #0aa2c0); + color: var(--text-inverse); &.vis-selected { - background-color: #0aa2c0; - border-color: #088799; + background-color: var(--bo-info-hover, #0aa2c0); + border-color: var(--bo-info-dark); } } } @@ -89,16 +89,17 @@ // Current time marker .vis-time-axis .vis-text.vis-major { font-weight: 600; + color: var(--text-primary); } .vis-current-time { - background-color: #dc3545; + background-color: var(--bo-danger); width: 2px; } // Timeline background .vis-panel.vis-background { - background-color: #f8f9fa; + background-color: var(--bg-secondary); } // Hover effects @@ -124,8 +125,8 @@ transform: translate(-50%, -50%); width: 3rem; height: 3rem; - border: 0.25rem solid rgba(0, 0, 0, 0.1); - border-top-color: #0d6efd; + border: 0.25rem solid var(--border-color-light); + border-top-color: var(--bo-primary); border-radius: 50%; animation: spinner-border 0.75s linear infinite; } @@ -160,7 +161,7 @@ width: 16px; height: 16px; border-radius: 50%; - border: 3px solid white; + border: 3px solid var(--card-bg); box-shadow: 0 0 0 2px currentColor; z-index: 2; } @@ -168,7 +169,7 @@ .timeline-line { flex: 1; width: 2px; - background: #dee2e6; + background: var(--border-color); margin-top: 4px; } @@ -181,15 +182,15 @@ // Category-specific styles for fallback timeline .lore-timeline { .timeline-event-historical .timeline-dot { - box-shadow: 0 0 0 2px #6c757d; + box-shadow: 0 0 0 2px var(--bo-gray-500); } .timeline-event-current .timeline-dot { - box-shadow: 0 0 0 2px #0d6efd; + box-shadow: 0 0 0 2px var(--bo-primary); } .timeline-event-future .timeline-dot { - box-shadow: 0 0 0 2px #0dcaf0; + box-shadow: 0 0 0 2px var(--bo-info); } } diff --git a/composer.json b/composer.json index 64c875d..7d7c99f 100755 --- a/composer.json +++ b/composer.json @@ -85,7 +85,7 @@ }, "autoload-dev": { "psr-4": { - "App\\Tests\\": "tests/" + "Tests\\": "tests/" } }, "replace": { @@ -131,6 +131,8 @@ "require-dev": { "codeception/codeception": "^5.3.2", "codeception/module-asserts": "^3.2.1", + "codeception/module-doctrine": "^3.3", + "codeception/module-phpbrowser": ">=3.0.2", "codeception/module-symfony": "^3.7.1", "codeception/module-webdriver": "^4.0.3", "doctrine/doctrine-fixtures-bundle": "^4.3", @@ -143,7 +145,6 @@ "symfony/stopwatch": "7.2.*", "symfony/web-profiler-bundle": "7.2.*", "symplify/easy-coding-standard": "^12.6.2", - "zenstruck/foundry": "^2.8", - "codeception/module-phpbrowser": ">=3.0.2" + "zenstruck/foundry": "^2.8" } } diff --git a/composer.lock b/composer.lock index b346f56..848384e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bc19d47b0ddc39d480540ad7fda11a1f", + "content-hash": "400f03c25f07ae07d462bff66b7f0094", "packages": [ { "name": "composer/semver", @@ -11015,6 +11015,66 @@ }, "time": "2025-10-26T13:12:55+00:00" }, + { + "name": "codeception/module-doctrine", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/Codeception/module-doctrine.git", + "reference": "26bd3fc8da9e89230b964d128c567b305a91e7c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Codeception/module-doctrine/zipball/26bd3fc8da9e89230b964d128c567b305a91e7c6", + "reference": "26bd3fc8da9e89230b964d128c567b305a91e7c6", + "shasum": "" + }, + "require": { + "codeception/codeception": "^5.1", + "ext-json": "*", + "ext-pdo": "*", + "php": "^8.2" + }, + "require-dev": { + "codeception/stub": "^4.1.3", + "doctrine/annotations": "^2.0.1", + "doctrine/data-fixtures": "^1.6", + "doctrine/orm": "^2.14 || ^3.0", + "phpstan/phpstan": "^1.10.58", + "symfony/cache": "^5.4.35 || ^6.4.3 || ^7.0", + "symfony/doctrine-bridge": "^5.4.35 || ^6.4.3 || ^7.0", + "symfony/uid": "^5.4.35 || ^6.4.3 || ^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Bodnarchuk" + }, + { + "name": "Alex Kunin" + } + ], + "description": "Doctrine module for Codeception", + "homepage": "https://codeception.com/", + "keywords": [ + "codeception", + "doctrine" + ], + "support": { + "issues": "https://github.com/Codeception/module-doctrine/issues", + "source": "https://github.com/Codeception/module-doctrine/tree/3.3.0" + }, + "time": "2025-11-13T08:06:48+00:00" + }, { "name": "codeception/module-phpbrowser", "version": "3.0.2", diff --git a/docker-compose.yaml b/docker-compose.yaml index e50dd40..e478e15 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,6 +21,9 @@ services: - .:/var/www/html - ./docker/dev/php/php.ini:/usr/local/etc/php/conf.d/dev.ini - /var/www/html/var/cache + depends_on: + postgres: + condition: service_started networks: - larpilot-network diff --git a/phpstan.neon b/phpstan.neon index 78e39df..6ff1aeb 100755 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,9 +5,6 @@ parameters: # Domain Boundary Enforcement Rules # Prevents violations of domain architecture principles - ignoreErrors: - # Allow legacy code during migration - remove these as domains are migrated - - '#Access to an undefined property App\\Controller\\.*#' services: - diff --git a/phpunit.xml.dist b/phpunit.xml.dist deleted file mode 100755 index caf62a6..0000000 --- a/phpunit.xml.dist +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - tests - - - tests/Functional - - - tests/Integration - - - tests/Domain - - - - - - src - - - - - - - - - - - diff --git a/public/index_test.php b/public/index_test.php new file mode 100644 index 0000000..15f67ff --- /dev/null +++ b/public/index_test.php @@ -0,0 +1,9 @@ + $userRepo * @return User[] */ - private function ensureUsers(ObjectRepository $userRepo, string $larpId, int $count): array + private function ensureUsers(EntityRepository $userRepo, string $larpId, int $count): array { $users = []; for ($i = 0; $i < $count; $i++) { @@ -108,9 +109,11 @@ private function ensureUsers(ObjectRepository $userRepo, string $larpId, int $co /** * Ensure exactly one participant per organizer role for the given LARP. * - * @return \App\Domain\Core\Controller\Backoffice\LarpParticipant[] keyed by role value + * @param EntityRepository $participantRepo + * @param User[] $users + * @return LarpParticipant[] keyed by role value */ - private function ensureOrganizerParticipants(ObjectRepository $participantRepo, Larp $larp, array $users): array + private function ensureOrganizerParticipants(EntityRepository $participantRepo, Larp $larp, array $users): array { $byRole = $this->getExistingRolesMap($participantRepo, $larp); @@ -141,9 +144,10 @@ private function ensureOrganizerParticipants(ObjectRepository $participantRepo, /** * Get all characters for the LARP. * + * @param EntityRepository $characterRepo * @return Character[] */ - private function getLarpCharacters(ObjectRepository $characterRepo, Larp $larp): array + private function getLarpCharacters(EntityRepository $characterRepo, Larp $larp): array { return $characterRepo->createQueryBuilder('c') ->andWhere('c.larp = :larp') @@ -156,9 +160,11 @@ private function getLarpCharacters(ObjectRepository $characterRepo, Larp $larp): * Ensure we have at least N player participants for this LARP. * Returns the full current list of player participants (existing + newly created). * - * @return \App\Domain\Core\Controller\Backoffice\LarpParticipant[] + * @param EntityRepository $participantRepo + * @param User[] $users + * @return LarpParticipant[] */ - private function ensurePlayerParticipantsForCharactersCount(ObjectRepository $participantRepo, Larp $larp, array $users, int $requiredCount): array + private function ensurePlayerParticipantsForCharactersCount(EntityRepository $participantRepo, Larp $larp, array $users, int $requiredCount): array { $existingPlayers = $participantRepo->createQueryBuilder('p') ->andWhere('p.larp = :larp') @@ -266,11 +272,12 @@ private function ensureApplications(array $characters, Larp $larp): int /** * Build a map of role => existing participant, for the given LARP (first participant found with that role). * - * @return array + * @param EntityRepository $participantRepo + * @return array */ - private function getExistingRolesMap(ObjectRepository $participantRepo, Larp $larp): array + private function getExistingRolesMap(EntityRepository $participantRepo, Larp $larp): array { - /** @var \App\Domain\Core\Controller\Backoffice\LarpParticipant[] $all */ + /** @var LarpParticipant[] $all */ $all = $participantRepo->createQueryBuilder('p') ->andWhere('p.larp = :larp') ->setParameter('larp', $larp) diff --git a/src/DataFixtures/Dev/DevSampleFixtures.php b/src/DataFixtures/Dev/DevSampleFixtures.php index ee29abf..b05ca4e 100755 --- a/src/DataFixtures/Dev/DevSampleFixtures.php +++ b/src/DataFixtures/Dev/DevSampleFixtures.php @@ -142,7 +142,7 @@ private function createLarp( $larp->setCreatedBy($creator); // slug is generated by Gedmo, but keep a fallback if needed - if (method_exists($larp, 'setSlug') && $larp->getSlug() === null) { + if ($larp->getSlug() === null) { $slugger = new AsciiSlugger(); $larp->setSlug((string)$slugger->slug($title)->lower()); } diff --git a/src/Domain/Account/Entity/User.php b/src/Domain/Account/Entity/User.php index 0345e4d..9d86c7c 100755 --- a/src/Domain/Account/Entity/User.php +++ b/src/Domain/Account/Entity/User.php @@ -245,6 +245,22 @@ public function getLarpParticipants(): Collection return $this->larpParticipants; } + /** + * @return Collection + */ + public function getApplications(): Collection + { + return $this->applications; + } + + /** + * @return Collection + */ + public function getSocialAccounts(): Collection + { + return $this->socialAccounts; + } + /** * Get count of LARPs where user is an organizer. */ diff --git a/src/Domain/Application/Controller/Backoffice/CharacterApplicationsController.php b/src/Domain/Application/Controller/Backoffice/CharacterApplicationsController.php index 8a19804..12164e3 100755 --- a/src/Domain/Application/Controller/Backoffice/CharacterApplicationsController.php +++ b/src/Domain/Application/Controller/Backoffice/CharacterApplicationsController.php @@ -2,6 +2,7 @@ namespace App\Domain\Application\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Application\Entity\Enum\SubmissionStatus; use App\Domain\Application\Entity\LarpApplication; use App\Domain\Application\Entity\LarpApplicationChoice; @@ -24,7 +25,6 @@ use Symfony\Component\Mime\Email; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Core\User\UserInterface; #[Route('/larp/{larp}/applications', name: 'backoffice_larp_applications_')] class CharacterApplicationsController extends BaseController @@ -152,8 +152,9 @@ public function vote( return $this->redirectToRoute('backoffice_larp_applications_match', ['larp' => $larp->getId()]); } + /** @var User|null $user */ $user = $this->getUser(); - if (!$user instanceof UserInterface) { + if (!$user instanceof User) { $this->addFlash('error', 'larp.applications.login_required'); return $this->redirectToRoute('backoffice_larp_applications_match', ['larp' => $larp->getId()]); } @@ -227,7 +228,7 @@ public function view( $userVotes = []; $currentUser = $this->getUser(); - if ($currentUser instanceof UserInterface) { + if ($currentUser instanceof User) { $votes = $voteRepository->findBy(['user' => $currentUser]); foreach ($votes as $vote) { $userVotes[$vote->getChoice()->getId()->toRfc4122()] = $vote; diff --git a/src/Domain/Application/Controller/Public/CharacterApplicationController.php b/src/Domain/Application/Controller/Public/CharacterApplicationController.php index e3d49ec..1ee9b05 100755 --- a/src/Domain/Application/Controller/Public/CharacterApplicationController.php +++ b/src/Domain/Application/Controller/Public/CharacterApplicationController.php @@ -2,6 +2,7 @@ namespace App\Domain\Application\Controller\Public; +use App\Domain\Account\Entity\User; use App\Domain\Application\Entity\Enum\SubmissionStatus; use App\Domain\Application\Entity\LarpApplication; use App\Domain\Application\Entity\LarpApplicationChoice; @@ -36,7 +37,9 @@ public function create( $application = new LarpApplication(); $application->setLarp($larp); - $application->setUser($this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + $application->setUser($currentUser); for ($i = 1; $i <= $larp->getMaxCharacterChoices(); ++$i) { $choice = new LarpApplicationChoice(); diff --git a/src/Domain/Application/Entity/LarpApplication.php b/src/Domain/Application/Entity/LarpApplication.php index 40f85e5..8ae44ec 100755 --- a/src/Domain/Application/Entity/LarpApplication.php +++ b/src/Domain/Application/Entity/LarpApplication.php @@ -46,7 +46,7 @@ public function __construct() } #[ORM\Column(length: 50)] - private ?SubmissionStatus $status = SubmissionStatus::NEW; + private SubmissionStatus $status = SubmissionStatus::NEW; #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $notes = null; @@ -111,7 +111,7 @@ public function removeUnwantedTag(Tag $tag): static return $this; } - public function getStatus(): ?SubmissionStatus + public function getStatus(): SubmissionStatus { return $this->status; } diff --git a/src/Domain/Application/Entity/LarpApplicationChoice.php b/src/Domain/Application/Entity/LarpApplicationChoice.php index db84d89..a6bdc0e 100755 --- a/src/Domain/Application/Entity/LarpApplicationChoice.php +++ b/src/Domain/Application/Entity/LarpApplicationChoice.php @@ -19,8 +19,8 @@ class LarpApplicationChoice private LarpApplication $application; #[ORM\ManyToOne(targetEntity: Character::class)] - #[ORM\JoinColumn(nullable: false)] - private Character $character; + #[ORM\JoinColumn(nullable: true)] + private ?Character $character = null; #[ORM\Column(type: 'integer')] private int $priority = 1; @@ -44,12 +44,12 @@ public function setApplication(LarpApplication $application): void $this->application = $application; } - public function getCharacter(): Character + public function getCharacter(): ?Character { return $this->character; } - public function setCharacter(Character $character): void + public function setCharacter(?Character $character): void { $this->character = $character; } diff --git a/src/Domain/Application/Entity/LarpApplicationVote.php b/src/Domain/Application/Entity/LarpApplicationVote.php index fd06171..3fc49c9 100755 --- a/src/Domain/Application/Entity/LarpApplicationVote.php +++ b/src/Domain/Application/Entity/LarpApplicationVote.php @@ -7,7 +7,6 @@ use App\Domain\Core\Entity\Trait\UuidTraitEntity; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Security\Core\User\UserInterface; #[ORM\Entity(repositoryClass: LarpApplicationVoteRepository::class)] #[ORM\Table(name: 'larp_application_vote')] @@ -55,7 +54,7 @@ public function getUser(): User return $this->user; } - public function setUser(User|UserInterface $user): void + public function setUser(User $user): void { $this->user = $user; } diff --git a/src/Domain/Application/Service/ApplicationMatchService.php b/src/Domain/Application/Service/ApplicationMatchService.php index f83c3d1..b67a9ae 100755 --- a/src/Domain/Application/Service/ApplicationMatchService.php +++ b/src/Domain/Application/Service/ApplicationMatchService.php @@ -112,7 +112,7 @@ public function getUserVotes(?UserInterface $user): array } /** - * @param LarpApplicationVote $votesGrouped + * @param array> $votesGrouped * @return array */ private function buildVoteStatsMap(array $votesGrouped): array diff --git a/src/Domain/Application/Service/CharacterAllocationService.php b/src/Domain/Application/Service/CharacterAllocationService.php index add88e9..4fa1cb8 100755 --- a/src/Domain/Application/Service/CharacterAllocationService.php +++ b/src/Domain/Application/Service/CharacterAllocationService.php @@ -82,7 +82,7 @@ private function buildCharacterApplicantsMap(array $applications): array * - Tag match bonus (if implemented) * * @param array $characterApplicants - * @return array + * @return array */ private function calculateScores(array $characterApplicants): array { diff --git a/src/Domain/Core/Controller/Backoffice/LocationController.php b/src/Domain/Core/Controller/Backoffice/LocationController.php index 5192260..4b1d70d 100755 --- a/src/Domain/Core/Controller/Backoffice/LocationController.php +++ b/src/Domain/Core/Controller/Backoffice/LocationController.php @@ -152,7 +152,7 @@ public function modifyForLarp( ]); } - #[Route('/{id}', name: 'delete', methods: ['POST'])] + #[Route('/{id}/delete', name: 'delete', methods: ['POST', 'DELETE'])] public function delete(Request $request, Location $location): Response { // Use the voter for permission check @@ -175,7 +175,7 @@ public function approve(Request $request, Location $location): Response { if (!$this->isCsrfTokenValid('approve' . $location->getId(), $request->request->get('_token'))) { $this->addFlash('error', 'Invalid CSRF token.'); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } /** @var User $user */ @@ -184,7 +184,7 @@ public function approve(Request $request, Location $location): Response $this->approvalService->approve($location, $user); $this->addFlash('success', sprintf('Location "%s" has been approved.', $location->getTitle())); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } #[Route('/{id}/reject', name: 'reject', methods: ['GET', 'POST'])] @@ -202,7 +202,7 @@ public function reject(Request $request, Location $location): Response $this->approvalService->reject($location, $user, $reason); $this->addFlash('success', sprintf('Location "%s" has been rejected.', $location->getTitle())); - return $this->redirectToRoute('backoffice_location_pending_list'); + return $this->redirectToRoute('backoffice_location_list'); } return $this->render('backoffice/location/reject.html.twig', [ diff --git a/src/Domain/Core/Controller/Backoffice/ParticipantController.php b/src/Domain/Core/Controller/Backoffice/ParticipantController.php index 04a87d8..80d86a6 100755 --- a/src/Domain/Core/Controller/Backoffice/ParticipantController.php +++ b/src/Domain/Core/Controller/Backoffice/ParticipantController.php @@ -2,6 +2,7 @@ namespace App\Domain\Core\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Entity\Larp; use App\Domain\Core\Entity\LarpParticipant; @@ -72,6 +73,30 @@ public function delete( LarpParticipantRepository $participantRepository, LarpParticipant $participant, ): Response { + // Check authorization - only organizers can delete participants + $this->denyAccessUnlessGranted('DELETE_LARP_PARTICIPANT', $participant); + + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + + // Check if the user is trying to delete themselves + if ($currentUser instanceof User && $participant->getUser()->getId() === $currentUser->getId()) { + // Check if this participant is an organizer + if ($participant->isAdmin()) { + // Count total organizers for this LARP + $organizerCount = $participantRepository->countMainOrganizersForLarp($larp); + + // Prevent deletion if this is the last organizer + if ($organizerCount <= 1) { + $this->addFlash('danger', $this->translator->trans('larp.participant.error.cannot_delete_last_organizer')); + + return $this->redirectToRoute('backoffice_larp_participant_list', [ + 'larp' => $larp->getId(), + ]); + } + } + } + $participantRepository->remove($participant); $this->addFlash('success', $this->translator->trans('success_delete')); diff --git a/src/Domain/Core/Controller/BaseController.php b/src/Domain/Core/Controller/BaseController.php index 3323b3b..3898509 100755 --- a/src/Domain/Core/Controller/BaseController.php +++ b/src/Domain/Core/Controller/BaseController.php @@ -16,7 +16,6 @@ use ShipMonk\DoctrineEntityPreloader\EntityPreloader; use Spiriit\Bundle\FormFilterBundle\Filter\FilterBuilderUpdaterInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormErrorIterator; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -38,7 +37,6 @@ public function __construct( protected function showErrorsAsFlash(FormErrorIterator $errors): void { - /** @var FormError $error */ foreach ($errors as $error) { $fieldName = $error->getOrigin()?->getName(); diff --git a/src/Domain/Core/Controller/Public/LarpController.php b/src/Domain/Core/Controller/Public/LarpController.php index 416d917..8e100d6 100755 --- a/src/Domain/Core/Controller/Public/LarpController.php +++ b/src/Domain/Core/Controller/Public/LarpController.php @@ -2,6 +2,7 @@ namespace App\Domain\Core\Controller\Public; +use App\Domain\Account\Entity\User; use App\Domain\Application\Repository\LarpApplicationRepository; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Form\Filter\LarpPublicFilterType; @@ -12,7 +13,6 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Http\Attribute\IsGranted; #[Route('/', name: 'public_larp_')] @@ -43,9 +43,10 @@ public function list(Request $request, LarpRepository $larpRepository): Response #[IsGranted('ROLE_USER')] public function myLarps(LarpParticipantRepository $participantRepository): Response { + /** @var User|null $user */ $user = $this->getUser(); - if (!$user instanceof UserInterface) { + if (!$user instanceof User) { throw $this->createAccessDeniedException(); } @@ -72,10 +73,10 @@ public function details( $userIsParticipant = false; $userHasApplication = false; - if ($user instanceof UserInterface) { + if ($user instanceof User) { // Check if user is already a participant $userIsParticipant = $larp->getParticipants()->exists(fn ($key, $participant): bool => $participant->getUser() === $user); - + // Check if user already has an application $userHasApplication = $applicationRepository->findOneBy(['larp' => $larp, 'user' => $user]) !== null; } @@ -131,7 +132,9 @@ public function acceptInvitation( } try { - $larpManager->acceptInvitation($invitation, $this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + $larpManager->acceptInvitation($invitation, $currentUser); } catch (\DomainException $e) { $this->addFlash('error', $e->getMessage()); return $this->redirectToRoute('public_larp_list'); diff --git a/src/Domain/Core/Controller/Public/LocationController.php b/src/Domain/Core/Controller/Public/LocationController.php index b59939d..5899cf7 100755 --- a/src/Domain/Core/Controller/Public/LocationController.php +++ b/src/Domain/Core/Controller/Public/LocationController.php @@ -16,48 +16,8 @@ class LocationController extends BaseController #[Route('/locations', name: 'list', methods: ['GET'])] public function list(Request $request, LocationRepository $locationRepository): Response { - // Get distinct cities and countries for filter dropdowns (only approved locations) - $cities = $locationRepository->createQueryBuilder('l') - ->select('DISTINCT l.city') - ->where('l.isPublic = :isPublic') - ->andWhere('l.isActive = :isActive') - ->andWhere('l.approvalStatus = :approved') - ->andWhere('l.city IS NOT NULL') - ->setParameter('isPublic', true) - ->setParameter('isActive', true) - ->setParameter('approved', LocationApprovalStatus::APPROVED) - ->orderBy('l.city', 'ASC') - ->getQuery() - ->getResult(); - - $countries = $locationRepository->createQueryBuilder('l') - ->select('DISTINCT l.country') - ->where('l.isPublic = :isPublic') - ->andWhere('l.isActive = :isActive') - ->andWhere('l.approvalStatus = :approved') - ->andWhere('l.country IS NOT NULL') - ->setParameter('isPublic', true) - ->setParameter('isActive', true) - ->setParameter('approved', LocationApprovalStatus::APPROVED) - ->orderBy('l.country', 'ASC') - ->getQuery() - ->getResult(); - - // Transform to choice array format - $cityChoices = array_combine( - array_column($cities, 'city'), - array_column($cities, 'city') - ); - $countryChoices = array_combine( - array_column($countries, 'country'), - array_column($countries, 'country') - ); - // Create and handle filter form - $filterForm = $this->createForm(LocationPublicFilterType::class, null, [ - 'cities' => $cityChoices, - 'countries' => $countryChoices, - ]); + $filterForm = $this->createForm(LocationPublicFilterType::class); $filterForm->handleRequest($request); // Build query (only show approved locations) diff --git a/src/Domain/Core/Entity/Larp.php b/src/Domain/Core/Entity/Larp.php index 4736347..deede33 100755 --- a/src/Domain/Core/Entity/Larp.php +++ b/src/Domain/Core/Entity/Larp.php @@ -405,7 +405,7 @@ public function setIntegrations(Collection $integrations): void public function getMarking(): string { - return $this->status?->value ?? LarpStageStatus::DRAFT->value; + return $this->status !== null ? $this->status->value : LarpStageStatus::DRAFT->value; } public function setMarking(string $marking): void diff --git a/src/Domain/Core/Entity/LarpInvitation.php b/src/Domain/Core/Entity/LarpInvitation.php index e2f3231..40c1ed6 100755 --- a/src/Domain/Core/Entity/LarpInvitation.php +++ b/src/Domain/Core/Entity/LarpInvitation.php @@ -41,7 +41,7 @@ class LarpInvitation public function __construct() { - $this->createdAt = new \DateTimeImmutable(); + $this->createdAt = new \DateTime(); $this->code = bin2hex(random_bytes(16)); } diff --git a/src/Domain/Core/Entity/LarpParticipant.php b/src/Domain/Core/Entity/LarpParticipant.php index cc50e95..a45193d 100755 --- a/src/Domain/Core/Entity/LarpParticipant.php +++ b/src/Domain/Core/Entity/LarpParticipant.php @@ -49,7 +49,7 @@ class LarpParticipant public function __construct() { - $this->createdAt = new \DateTimeImmutable(); + $this->createdAt = new \DateTime(); $this->larpCharacters = new ArrayCollection(); } @@ -91,13 +91,13 @@ public function getRoles(): array } /** - * Accepts an array of UserRole enum instances or valid role strings. + * Accepts an array of ParticipantRole enum instances or valid role strings. * - * @param ParticipantRole[] $roles + * @param array $roles */ public function setRoles(array $roles): self { - $this->roles = array_map(fn ($role) => $role instanceof ParticipantRole ? $role->value : $role, $roles); + $this->roles = array_map(fn (ParticipantRole|string $role) => $role instanceof ParticipantRole ? $role->value : $role, $roles); return $this; } diff --git a/src/Domain/Core/EventSubscriber/LocaleListener.php b/src/Domain/Core/EventSubscriber/LocaleListener.php index 5b08eec..7e5d8c0 100755 --- a/src/Domain/Core/EventSubscriber/LocaleListener.php +++ b/src/Domain/Core/EventSubscriber/LocaleListener.php @@ -8,14 +8,14 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\LocaleAwareInterface; #[AsEventListener(event: KernelEvents::REQUEST, priority: 0)] readonly class LocaleListener { public function __construct( private Security $security, - private TranslatorInterface $translator, + private LocaleAwareInterface $translator, private string $defaultLocale = 'en', ) { } diff --git a/src/Domain/Core/Form/Filter/LarpPublicFilterType.php b/src/Domain/Core/Form/Filter/LarpPublicFilterType.php index 2d3a0b4..70b77aa 100755 --- a/src/Domain/Core/Form/Filter/LarpPublicFilterType.php +++ b/src/Domain/Core/Form/Filter/LarpPublicFilterType.php @@ -6,7 +6,10 @@ use App\Domain\Core\Entity\Enum\LarpSetting; use App\Domain\Core\Entity\Enum\LarpStageStatus; use App\Domain\Core\Entity\Enum\LarpType; +use App\Domain\Core\Entity\Enum\LocationApprovalStatus; use App\Domain\Core\Entity\Location; +use App\Domain\Core\Repository\LocationRepository; +use Doctrine\ORM\QueryBuilder; use Spiriit\Bundle\FormFilterBundle\Filter\Form\Type as Filters; use Spiriit\Bundle\FormFilterBundle\Filter\Query\QueryInterface; use Symfony\Bridge\Doctrine\Form\Type\EntityType; @@ -65,7 +68,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'placeholder' => 'choose', 'required' => false, 'autocomplete' => true, - 'attr' => ['class' => 'form-select'] + 'attr' => ['class' => 'form-select'], + 'query_builder' => fn (LocationRepository $repo): QueryBuilder => $repo->createQueryBuilder('l') + ->where('l.isActive = true') + ->andWhere('l.isPublic = true') + ->andWhere('l.approvalStatus = :status') + ->setParameter('status', LocationApprovalStatus::APPROVED->value) ]) ->add('startDate', Filters\DateFilterType::class, [ 'required' => false, @@ -82,8 +90,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 30, 'placeholder' => 'Min days' ], + 'html5' => true, 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { if (empty($values['value'])) { return null; @@ -100,8 +110,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 31, 'placeholder' => 'Max days' ], + 'html5' => true, 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { if (empty($values['value'])) { return null; diff --git a/src/Domain/Core/Form/Filter/LocationPublicFilterType.php b/src/Domain/Core/Form/Filter/LocationPublicFilterType.php index f297570..e6c5e47 100644 --- a/src/Domain/Core/Form/Filter/LocationPublicFilterType.php +++ b/src/Domain/Core/Form/Filter/LocationPublicFilterType.php @@ -2,7 +2,10 @@ namespace App\Domain\Core\Form\Filter; +use App\Domain\Core\Entity\Enum\LocationApprovalStatus; +use App\Domain\Core\Repository\LocationRepository; use Spiriit\Bundle\FormFilterBundle\Filter\Form\Type as Filters; +use Spiriit\Bundle\FormFilterBundle\Filter\Query\QueryInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; @@ -10,29 +13,87 @@ class LocationPublicFilterType extends AbstractType { + public function __construct(private LocationRepository $locationRepository) + { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { + // Get distinct cities and countries for filter dropdowns (only approved locations) + $cities = $this->locationRepository->createQueryBuilder('l') + ->select('DISTINCT l.city') + ->where('l.isPublic = :isPublic') + ->andWhere('l.isActive = :isActive') + ->andWhere('l.approvalStatus = :approved') + ->andWhere('l.city IS NOT NULL') + ->setParameter('isPublic', true) + ->setParameter('isActive', true) + ->setParameter('approved', LocationApprovalStatus::APPROVED) + ->orderBy('l.city', 'ASC') + ->getQuery() + ->getResult(); + + $countries = $this->locationRepository->createQueryBuilder('l') + ->select('DISTINCT l.country') + ->where('l.isPublic = :isPublic') + ->andWhere('l.isActive = :isActive') + ->andWhere('l.approvalStatus = :approved') + ->andWhere('l.country IS NOT NULL') + ->setParameter('isPublic', true) + ->setParameter('isActive', true) + ->setParameter('approved', LocationApprovalStatus::APPROVED) + ->orderBy('l.country', 'ASC') + ->getQuery() + ->getResult(); + + // Transform to choice array format + $cityChoices = array_combine( + array_column($cities, 'city'), + array_column($cities, 'city') + ); + $countryChoices = array_combine( + array_column($countries, 'country'), + array_column($countries, 'country') + ); + + $builder - ->add('search', Filters\TextFilterType::class, [ + ->add('title', Filters\TextFilterType::class, [ 'required' => false, 'label' => 'Search', 'attr' => [ 'class' => 'form-control', - 'placeholder' => 'Search by name...', + 'placeholder' => 'Search by name, city or country...', ], -// 'condition_pattern' => Filters\TextFilterType::PATTERN_CONTAINS, + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + $qb = $filterQuery->getQueryBuilder(); + $searchTerm = strtolower($values['value']); + + $qb->andWhere( + $qb->expr()->orX( + $qb->expr()->like('LOWER(l.title)', ':searchTerm'), + $qb->expr()->like('LOWER(l.city)', ':searchTerm'), + $qb->expr()->like('LOWER(l.country)', ':searchTerm') + ) + )->setParameter('searchTerm', '%' . $searchTerm . '%'); + + return null; + }, ]) ->add('city', ChoiceType::class, [ 'required' => false, 'placeholder' => 'All Cities', 'attr' => ['class' => 'form-select'], - 'choices' => $options['cities'] ?? [], + 'choices' => $cityChoices, ]) ->add('country', ChoiceType::class, [ 'required' => false, 'placeholder' => 'All Countries', 'attr' => ['class' => 'form-select'], - 'choices' => $options['countries'] ?? [], + 'choices' => $countryChoices, ]) ->add('minCapacity', Filters\NumberFilterType::class, [ 'required' => false, @@ -40,8 +101,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 9000, 'placeholder' => 'Min', ], + 'html5' => true ]) ->add('maxCapacity', Filters\NumberFilterType::class, [ 'required' => false, @@ -49,8 +112,10 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'attr' => [ 'class' => 'form-control', 'min' => 1, + 'max' => 9000, 'placeholder' => 'Max', ], + 'html5' => true ]); } @@ -61,8 +126,6 @@ public function configureOptions(OptionsResolver $resolver): void 'validation_groups' => false, 'method' => 'GET', 'translation_domain' => 'forms', - 'cities' => [], - 'countries' => [], ]); } } diff --git a/src/Domain/Core/Form/Filter/ParticipantFilterType.php b/src/Domain/Core/Form/Filter/ParticipantFilterType.php index 5407b2f..6581ce4 100755 --- a/src/Domain/Core/Form/Filter/ParticipantFilterType.php +++ b/src/Domain/Core/Form/Filter/ParticipantFilterType.php @@ -30,14 +30,16 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_value' => fn (?ParticipantRole $role) => $role?->value, 'autocomplete' => true, 'multiple' => true, + 'required' => false, 'apply_filter' => static function (QueryInterface $filterQuery, $field, $values) { $roles = $values['value'] ?? []; if (!is_array($roles)) { return null; } + $qb = $filterQuery->getQueryBuilder(); $parameters = []; - $expression = $filterQuery->getExpr()->andX(); - /** @var \App\Domain\Account\Entity\Enum\\App\Domain\Core\Entity\Enum\ParticipantRole $role */ + $expression = $qb->expr()->andX(); + /** @var ParticipantRole $role */ foreach ($roles as $i => $role) { $expression->add("JSONB_EXISTS($field, :role_$i) = true"); $parameters["role_$i"] = $role->value; diff --git a/src/Domain/Core/Form/LarpPropertiesType.php b/src/Domain/Core/Form/LarpPropertiesType.php index bae3913..4e9b800 100755 --- a/src/Domain/Core/Form/LarpPropertiesType.php +++ b/src/Domain/Core/Form/LarpPropertiesType.php @@ -68,7 +68,8 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'help' => 'How players apply for this LARP', 'attr' => [ 'class' => 'form-check', - 'data-controller' => 'application-mode-toggle' + 'data-controller' => 'application-mode-toggle', + 'data-action' => 'change->application-mode-toggle#change' ] ]) ->add('publishCharactersPublicly', CheckboxType::class, [ diff --git a/src/Domain/Core/Form/LocationType.php b/src/Domain/Core/Form/LocationType.php index 2c6bb17..09f96dd 100755 --- a/src/Domain/Core/Form/LocationType.php +++ b/src/Domain/Core/Form/LocationType.php @@ -51,11 +51,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('latitude', NumberType::class, [ 'required' => false, 'scale' => 8, + 'html5' => true, 'attr' => ['class' => 'form-control', 'step' => 0.00000001] ]) ->add('longitude', NumberType::class, [ 'required' => false, 'scale' => 8, + 'html5' => true, 'attr' => ['class' => 'form-control', 'step' => 0.00000001] ]) ->add('website', UrlType::class, [ @@ -100,7 +102,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('capacity', IntegerType::class, [ 'required' => false, - 'attr' => ['class' => 'form-control'] + 'attr' => [ + 'class' => 'form-control', + 'min' => 1, + 'max' => 9000, + ], +// 'html5' => true, ]) ->add('isPublic', CheckboxType::class, [ 'required' => false, diff --git a/src/Domain/Core/Repository/LarpParticipantRepository.php b/src/Domain/Core/Repository/LarpParticipantRepository.php index 74db618..97ef34c 100755 --- a/src/Domain/Core/Repository/LarpParticipantRepository.php +++ b/src/Domain/Core/Repository/LarpParticipantRepository.php @@ -3,6 +3,8 @@ namespace App\Domain\Core\Repository; use App\Domain\Account\Entity\User; +use App\Domain\Core\Entity\Enum\ParticipantRole; +use App\Domain\Core\Entity\Larp; use App\Domain\Core\Entity\LarpParticipant; use Doctrine\Persistence\ManagerRegistry; @@ -38,4 +40,20 @@ public function findForUserWithCharacters(User $user): array ->getQuery() ->getResult(); } + + /** + * Count the number of organizers for a given LARP + */ + public function countMainOrganizersForLarp(Larp $larp): int + { + $qb = $this->createQueryBuilder('p') + ->select('COUNT(DISTINCT p.id)') + ->where('p.larp = :larp') + ->setParameter('larp', $larp); + + $qb->andWhere("JSONB_EXISTS(p.roles, :role) = true"); + $qb->setParameter("role", ParticipantRole::ORGANIZER->value); + + return (int) $qb->getQuery()->getSingleScalarResult(); + } } diff --git a/src/Domain/Core/Repository/LarpRepository.php b/src/Domain/Core/Repository/LarpRepository.php index b53d29c..d26063b 100755 --- a/src/Domain/Core/Repository/LarpRepository.php +++ b/src/Domain/Core/Repository/LarpRepository.php @@ -78,52 +78,4 @@ public function findAllWhereParticipating(User $user): array return $qb->getQuery()->getResult(); } - - private function applyFilters(QueryBuilder $qb, array $filters): void - { - if (!empty($filters['status'])) { - $qb->andWhere('l.status = :status') - ->setParameter('status', $filters['status']); - } - - if (!empty($filters['setting'])) { - $qb->andWhere('l.setting = :setting') - ->setParameter('setting', $filters['setting']); - } - - if (!empty($filters['type'])) { - $qb->andWhere('l.type = :type') - ->setParameter('type', $filters['type']); - } - - if (!empty($filters['characterSystem'])) { - $qb->andWhere('l.characterSystem = :characterSystem') - ->setParameter('characterSystem', $filters['characterSystem']); - } - - if (!empty($filters['location'])) { - $qb->andWhere('loc.city LIKE :location OR loc.country LIKE :location OR loc.title LIKE :location OR loc.address LIKE :location') - ->setParameter('location', '%' . $filters['location'] . '%'); - } - - if (!empty($filters['dateFrom'])) { - $qb->andWhere('l.startDate >= :dateFrom') - ->setParameter('dateFrom', $filters['dateFrom']); - } - - if (!empty($filters['dateTo'])) { - $qb->andWhere('l.endDate <= :dateTo') - ->setParameter('dateTo', $filters['dateTo']); - } - - if (!empty($filters['minDuration'])) { - $qb->andWhere('DATEDIFF(l.endDate, l.startDate) + 1 >= :minDuration') - ->setParameter('minDuration', $filters['minDuration']); - } - - if (!empty($filters['maxDuration'])) { - $qb->andWhere('DATEDIFF(l.endDate, l.startDate) + 1 <= :maxDuration') - ->setParameter('maxDuration', $filters['maxDuration']); - } - } } diff --git a/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php b/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php index 4bf179e..26ea823 100644 --- a/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php +++ b/src/Domain/Core/Security/Voter/LarpParticipantsVoter.php @@ -11,14 +11,23 @@ class LarpParticipantsVoter extends Voter { public const VIEW = 'VIEW_BO_LARP_PARTICIPANTS'; + public const DELETE = 'DELETE_LARP_PARTICIPANT'; protected function supports(string $attribute, $subject): bool { - return $attribute === self::VIEW && $subject instanceof Larp; + if ($attribute === self::VIEW) { + return $subject instanceof Larp; + } + + if ($attribute === self::DELETE) { + return $subject instanceof LarpParticipant; + } + + return false; } /** - * @param Larp $subject + * @param Larp|LarpParticipant $subject */ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool { @@ -27,9 +36,42 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return false; } - $participants = $subject->getParticipants(); - /** @var LarpParticipant|null $userOrganizer */ - $userOrganizer = $participants->filter(fn (LarpParticipant $participant): bool => $participant->getUser()->getId() === $user->getId() && $participant->isAdmin())->first(); - return $userOrganizer !== null; + return match ($attribute) { + self::VIEW => $this->canView($subject, $user), + self::DELETE => $this->canDelete($subject, $user), + default => false, + }; + } + + private function canView(Larp $larp, User $user): bool + { + $participants = $larp->getParticipants(); + /** @var LarpParticipant|null $userParticipant */ + $userParticipant = $participants->filter( + fn (LarpParticipant $participant): bool => + $participant->getUser()->getId() === $user->getId() && $participant->isOrganizer() + )->first(); + + return $userParticipant !== null; + } + + private function canDelete(LarpParticipant $participant, User $user): bool + { + $larp = $participant->getLarp(); + $participants = $larp->getParticipants(); + + // Find the current user's participant record + /** @var LarpParticipant|null $userParticipant */ + $userParticipant = $participants->filter( + fn (LarpParticipant $p): bool => $p->getUser()->getId() === $user->getId() + )->first(); + + // User must be a participant + if ($userParticipant === null) { + return false; + } + + // Only organizers can delete participants + return $userParticipant->isAdmin(); } } diff --git a/src/Domain/Core/Service/LarpDashboardService.php b/src/Domain/Core/Service/LarpDashboardService.php index b38b0a5..6309b5c 100755 --- a/src/Domain/Core/Service/LarpDashboardService.php +++ b/src/Domain/Core/Service/LarpDashboardService.php @@ -120,7 +120,7 @@ private function getFactionsData(Larp $larp): array foreach ($factions as $faction) { $factionParticipants = $participants->filter(function (LarpParticipant $participant) use ($faction) { foreach ($participant->getLarpCharacters() as $larpCharacter) { - return $larpCharacter?->belongsToFaction($faction); + return $larpCharacter->belongsToFaction($faction); } })->count(); diff --git a/src/Domain/Core/Service/Pagination/DTOPaginationAdapter.php b/src/Domain/Core/Service/Pagination/DTOPaginationAdapter.php index b098ea9..b49c90f 100755 --- a/src/Domain/Core/Service/Pagination/DTOPaginationAdapter.php +++ b/src/Domain/Core/Service/Pagination/DTOPaginationAdapter.php @@ -16,15 +16,23 @@ * }); * ``` */ -readonly class DTOPaginationAdapter implements \IteratorAggregate, \Countable, \ArrayAccess +class DTOPaginationAdapter implements \IteratorAggregate, \Countable, \ArrayAccess { + private PaginationInterface $originalPagination; + + /** @var array */ + private array $items; + /** + * @param PaginationInterface $originalPagination * @param array $items Transformed DTO items */ private function __construct( - private PaginationInterface $originalPagination, - private array $items + PaginationInterface $originalPagination, + array $items ) { + $this->originalPagination = $originalPagination; + $this->items = $items; } /** @@ -45,7 +53,7 @@ public static function wrap(PaginationInterface $pagination, callable $transform /** * Get the transformed DTO items * - * @return array + * @return array */ public function getItems(): array { @@ -68,14 +76,18 @@ public function getItemNumberPerPage(): int return $this->originalPagination->getItemNumberPerPage(); } + /** + * @return array + */ public function getPaginationData(): array { - return $this->originalPagination->getPaginationData(); - } + if (method_exists($this->originalPagination, 'getPaginationData')) { + /** @var array $result */ + $result = $this->originalPagination->getPaginationData(); + return $result; + } - public function getCustomParameters(): array - { - return $this->originalPagination->getCustomParameters(); + return []; } public function setCustomParameters(array $parameters): void @@ -83,16 +95,6 @@ public function setCustomParameters(array $parameters): void $this->originalPagination->setCustomParameters($parameters); } - public function getRoute(): ?string - { - return $this->originalPagination->getRoute(); - } - - public function getParams(): array - { - return $this->originalPagination->getParams(); - } - // IteratorAggregate implementation public function getIterator(): \Traversable { @@ -116,15 +118,6 @@ public function offsetGet(mixed $offset): mixed return $this->items[$offset]; } - public function offsetSet(mixed $offset, mixed $value): void - { - if ($offset === null) { - $this->items[] = $value; - } else { - $this->items[$offset] = $value; - } - } - public function offsetUnset(mixed $offset): void { unset($this->items[$offset]); @@ -142,4 +135,9 @@ public function __call(string $name, array $arguments): mixed throw new \BadMethodCallException(sprintf('Method "%s" does not exist', $name)); } + + public function offsetSet(mixed $offset, mixed $value): void + { + $this->originalPagination->offsetSet($offset, $value); + } } diff --git a/src/Domain/Core/Service/SuggestionService.php b/src/Domain/Core/Service/SuggestionService.php index 2e5f814..ca1956f 100755 --- a/src/Domain/Core/Service/SuggestionService.php +++ b/src/Domain/Core/Service/SuggestionService.php @@ -16,8 +16,8 @@ public function __construct( private CharacterRepository $characterRepository, private QuestRepository $questRepository, - private EventRepository $eventRepository, - private ThreadRepository $threadRepository, + // private EventRepository $eventRepository, + // private ThreadRepository $threadRepository, ) { } diff --git a/src/Domain/Core/UseCase/ImportCharacters/ImportCharactersHandler.php b/src/Domain/Core/UseCase/ImportCharacters/ImportCharactersHandler.php index 10f4fda..9b38c60 100755 --- a/src/Domain/Core/UseCase/ImportCharacters/ImportCharactersHandler.php +++ b/src/Domain/Core/UseCase/ImportCharacters/ImportCharactersHandler.php @@ -140,10 +140,6 @@ private function createReference(Character $character, int|string $rowNo, Shared $this->entityManager->persist($reference); } - /** - * @param Larp|null $larp - * @return array - */ private function getExistingCharactersMap(?string $larpId): array { $existingCharacters = $this->characterRepository->findBy(['larp' => Uuid::fromString($larpId)]); diff --git a/src/Domain/Core/UseCase/ImportTags/ImportTagsHandler.php b/src/Domain/Core/UseCase/ImportTags/ImportTagsHandler.php index 4382342..973481f 100644 --- a/src/Domain/Core/UseCase/ImportTags/ImportTagsHandler.php +++ b/src/Domain/Core/UseCase/ImportTags/ImportTagsHandler.php @@ -7,8 +7,8 @@ use App\Domain\Core\Repository\LarpRepository; use App\Domain\Core\Repository\TagRepository; use App\Domain\Integrations\Repository\SharedFileRepository; -use App\Domain\Integrations\Service\IntegrationManager; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Exception\ORMException; use Exception; use Symfony\Component\Uid\Uuid; use Webmozart\Assert\Assert; @@ -21,13 +21,12 @@ public function __construct( private readonly LarpRepository $larpRepository, private readonly TagRepository $tagRepository, private readonly SharedFileRepository $sharedFileRepository, - private readonly IntegrationManager $integrationManager, private readonly EntityManagerInterface $entityManager ) { } /** - * @throws Exception + * @throws Exception|ORMException */ public function handle(ImportTagsCommand $command): array { diff --git a/src/Domain/EventPlanning/Controller/Backoffice/ResourceController.php b/src/Domain/EventPlanning/Controller/Backoffice/ResourceController.php index a02fc94..bacbacb 100755 --- a/src/Domain/EventPlanning/Controller/Backoffice/ResourceController.php +++ b/src/Domain/EventPlanning/Controller/Backoffice/ResourceController.php @@ -2,6 +2,7 @@ namespace App\Domain\EventPlanning\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Entity\Larp; use App\Domain\EventPlanning\Entity\PlanningResource; @@ -54,7 +55,11 @@ public function modify( if ($isNew) { $resource = new PlanningResource(); $resource->setLarp($larp); - $resource->setCreatedBy($this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + if ($currentUser instanceof User) { + $resource->setCreatedBy($currentUser); + } } $form = $this->createForm(PlanningResourceType::class, $resource, ['larp' => $larp]); diff --git a/src/Domain/EventPlanning/Controller/Backoffice/ScheduledEventController.php b/src/Domain/EventPlanning/Controller/Backoffice/ScheduledEventController.php index f7c5045..dd25234 100755 --- a/src/Domain/EventPlanning/Controller/Backoffice/ScheduledEventController.php +++ b/src/Domain/EventPlanning/Controller/Backoffice/ScheduledEventController.php @@ -2,6 +2,7 @@ namespace App\Domain\EventPlanning\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Entity\Larp; use App\Domain\EventPlanning\Entity\ScheduledEvent; @@ -56,12 +57,20 @@ public function modify( if ($isNew) { $event = new ScheduledEvent(); $event->setLarp($larp); - $event->setCreatedBy($this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + if ($currentUser instanceof User) { + $event->setCreatedBy($currentUser); + } // Set default times to LARP start date if available - if ($larp->getStartDate()) { - $event->setStartTime(clone $larp->getEndDate()); - $event->setEndTime((clone $larp->getEndDate())->modify('+1 hour')); + $endDate = $larp->getEndDate(); + if ($larp->getStartDate() && $endDate) { + $startTime = \DateTime::createFromInterface($endDate); + $endTime = \DateTime::createFromInterface($endDate); + $endTime->modify('+1 hour'); + $event->setStartTime($startTime); + $event->setEndTime($endTime); } } diff --git a/src/Domain/EventPlanning/Entity/PlanningResource.php b/src/Domain/EventPlanning/Entity/PlanningResource.php index 58b128d..ed54cf6 100755 --- a/src/Domain/EventPlanning/Entity/PlanningResource.php +++ b/src/Domain/EventPlanning/Entity/PlanningResource.php @@ -239,7 +239,7 @@ public function getLinkedEntityTitle(): ?string return $this->item->getTitle(); } if ($this->participant) { - return $this->participant->getUser()->getFullName(); + return $this->participant->getUser()?->getUsername(); } return null; } diff --git a/src/Domain/EventPlanning/Entity/ScheduledEvent.php b/src/Domain/EventPlanning/Entity/ScheduledEvent.php index 67246a0..82b9089 100755 --- a/src/Domain/EventPlanning/Entity/ScheduledEvent.php +++ b/src/Domain/EventPlanning/Entity/ScheduledEvent.php @@ -307,7 +307,7 @@ public function __toString(): string */ public function getEffectiveStartTime(): \DateTimeInterface { - $effective = clone $this->startTime; + $effective = \DateTime::createFromInterface($this->startTime); if ($this->setupMinutes > 0) { $effective->modify('-' . $this->setupMinutes . ' minutes'); } @@ -319,7 +319,7 @@ public function getEffectiveStartTime(): \DateTimeInterface */ public function getEffectiveEndTime(): \DateTimeInterface { - $effective = clone $this->endTime; + $effective = \DateTime::createFromInterface($this->endTime); if ($this->cleanupMinutes > 0) { $effective->modify('+' . $this->cleanupMinutes . ' minutes'); } diff --git a/src/Domain/EventPlanning/Form/PlanningResourceType.php b/src/Domain/EventPlanning/Form/PlanningResourceType.php index 434c152..41f906b 100755 --- a/src/Domain/EventPlanning/Form/PlanningResourceType.php +++ b/src/Domain/EventPlanning/Form/PlanningResourceType.php @@ -12,7 +12,7 @@ use Symfony\Component\Form\Extension\Core\Type\CheckboxType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\EnumType; -use Symfony\Component\Form\Extension\Core\Type\IntegerType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; use Symfony\Component\Form\Extension\Core\Type\TextareaType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; @@ -40,7 +40,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'required' => false, 'attr' => ['rows' => 3], ]) - ->add('quantity', IntegerType::class, [ + ->add('quantity', NumberType::class, [ 'label' => 'planning_resource.quantity', 'attr' => ['min' => 1, 'max' => 99999], ]) diff --git a/src/Domain/EventPlanning/Service/ConflictDetectionService.php b/src/Domain/EventPlanning/Service/ConflictDetectionService.php index e1657dd..24810c8 100755 --- a/src/Domain/EventPlanning/Service/ConflictDetectionService.php +++ b/src/Domain/EventPlanning/Service/ConflictDetectionService.php @@ -14,7 +14,7 @@ { public function __construct( private ResourceBookingRepository $resourceBookingRepository, - private ScheduledEventRepository $scheduledEventRepository + // private ScheduledEventRepository $scheduledEventRepository ) { } diff --git a/src/Domain/Feedback/Service/GitHubFeedbackService.php b/src/Domain/Feedback/Service/GitHubFeedbackService.php index 1fb9490..64bc087 100644 --- a/src/Domain/Feedback/Service/GitHubFeedbackService.php +++ b/src/Domain/Feedback/Service/GitHubFeedbackService.php @@ -77,14 +77,8 @@ public function submitFeedback(array $feedbackData): array */ private function createIssue(string $title, string $body, array $labels, ?string $screenshot): array { - // Upload screenshot first if provided - $screenshotUrl = null; - if ($screenshot) { - $screenshotUrl = $this->uploadScreenshot($screenshot); - if ($screenshotUrl) { - $body .= "\n\n## Screenshot\n\n![Screenshot]({$screenshotUrl})"; - } - } + // Note: Screenshot upload is currently disabled (GitHub API limitation) + // @see uploadScreenshot() method for details try { $response = $this->httpClient->request('POST', "https://api.github.com/repos/{$this->githubRepo}/issues", [ @@ -138,14 +132,8 @@ private function createIssue(string $title, string $body, array $labels, ?string */ private function createDiscussion(string $title, string $body, ?string $screenshot): array { - // Upload screenshot first if provided - $screenshotUrl = null; - if ($screenshot) { - $screenshotUrl = $this->uploadScreenshot($screenshot); - if ($screenshotUrl) { - $body .= "\n\n## Screenshot\n\n![Screenshot]({$screenshotUrl})"; - } - } + // Note: Screenshot upload is currently disabled (GitHub API limitation) + // @see uploadScreenshot() method for details // Get repository ID [$owner, $repo] = explode('/', $this->githubRepo); @@ -207,51 +195,6 @@ private function createDiscussion(string $title, string $body, ?string $screensh } } - /** - * Upload screenshot to GitHub as an asset - * - * Uses GitHub's issue attachment API - * - * @param string $screenshotData Base64 encoded image (with data URI prefix) - * @return string|null URL of uploaded screenshot - */ - private function uploadScreenshot(string $screenshotData): ?string - { - try { - // Extract base64 data from data URI - if (!preg_match('/^data:image\/(\w+);base64,(.+)$/', $screenshotData, $matches)) { - $this->logger->warning('Screenshot data format not recognized'); - return null; - } - - $imageType = $matches[1]; - $base64Data = $matches[2]; - $binaryData = base64_decode($base64Data); - - // Create a unique filename - $filename = 'feedback_screenshot_' . date('Y-m-d_His') . '.' . $imageType; - - // Upload to GitHub as release asset (alternative: use external service like imgur) - // For now, we'll return null and let GitHub handle it via markdown - // In production, you might want to upload to a CDN or use GitHub's asset upload - - // Since GitHub doesn't have a direct screenshot upload API for issues, - // we'll need to use an alternative approach: - // 1. Create a temporary gist with the image - // 2. Or use an external image hosting service - // For simplicity, we'll skip this and rely on users uploading manually - - $this->logger->info('Screenshot prepared but not uploaded (GitHub API limitation)'); - return null; - } catch (\Exception $e) { - $this->logger->error('Failed to process screenshot', [ - 'error' => $e->getMessage(), - ]); - - return null; - } - } - /** * Get GitHub repository ID using GraphQL * diff --git a/src/Domain/Gallery/Form/Filter/GalleryFilterType.php b/src/Domain/Gallery/Form/Filter/GalleryFilterType.php index 5b562bb..7b065a5 100644 --- a/src/Domain/Gallery/Form/Filter/GalleryFilterType.php +++ b/src/Domain/Gallery/Form/Filter/GalleryFilterType.php @@ -31,7 +31,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'choice_label' => fn (LarpParticipant $participant): string => $participant->getUser()->getUsername(), 'required' => false, - 'placeholder' => 'common.all', + 'placeholder' => 'all', 'autocomplete' => true, 'query_builder' => function (LarpParticipantRepository $repo) use ($larp) { $qb = $repo->createQueryBuilder('p') @@ -49,7 +49,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'class' => GalleryVisibility::class, 'required' => false, 'multiple' => false, - 'placeholder' => 'common.all', + 'placeholder' => 'all', ]); } diff --git a/src/Domain/Incidents/Form/LarpIncidentType.php b/src/Domain/Incidents/Form/LarpIncidentType.php index 07267e3..fb736ac 100755 --- a/src/Domain/Incidents/Form/LarpIncidentType.php +++ b/src/Domain/Incidents/Form/LarpIncidentType.php @@ -14,9 +14,9 @@ class LarpIncidentType extends AbstractType { - public function __construct(private readonly ParticipantCodeValidator $validator) - { - } + // public function __construct(private readonly ParticipantCodeValidator $validator) + // { + // } public function buildForm(FormBuilderInterface $builder, array $options): void { diff --git a/src/Domain/Integrations/Entity/Enum/LarpIntegrationProvider.php b/src/Domain/Integrations/Entity/Enum/LarpIntegrationProvider.php index 198156f..e0b4a1d 100755 --- a/src/Domain/Integrations/Entity/Enum/LarpIntegrationProvider.php +++ b/src/Domain/Integrations/Entity/Enum/LarpIntegrationProvider.php @@ -6,7 +6,7 @@ enum LarpIntegrationProvider: string implements LabelableEnumInterface { - // case Facebook = 'facebook'; + case Facebook = 'facebook'; case Google = 'integration_google_drive'; // case Discord = 'discord'; // case Asana = 'asana'; @@ -18,23 +18,15 @@ public function getLabel(): string { return match ($this) { self::Google => 'Google Drive', - // self::Trello => 'Trello', - // self::Miro => 'Miro', - // self::Asana => 'Asana', - // self::Facebook => 'Facebook', - // self::Discord => 'Discord', + self::Facebook => 'Facebook', }; } - public function descriptionKey(): ?string + public function descriptionKey(): string { return match ($this) { self::Google => 'larp.integration.googleDriveDescription', - // self::Trello => 'larp.integration.trelloDescription', - // self::Miro => 'larp.integration.miroDescription', - // self::Asana => 'larp.integration.asanaDescription', - // self::Facebook => 'larp.integration.facebookDescription', - // self::Discord => 'larp.integration.discordDescription', + self::Facebook => 'larp.integration.facebookDescription', }; } diff --git a/src/Domain/Integrations/Entity/Enum/ResourceType.php b/src/Domain/Integrations/Entity/Enum/ResourceType.php index 6a71dc1..cf11ab7 100755 --- a/src/Domain/Integrations/Entity/Enum/ResourceType.php +++ b/src/Domain/Integrations/Entity/Enum/ResourceType.php @@ -24,9 +24,9 @@ enum ResourceType: string case TAG_LIST = 'tag_list'; /** - * @return class-string|null + * @return class-string */ - public function getSubForm(): ?string + public function getSubForm(): string { return match ($this) { self::CHARACTER_LIST => CharacterListColumnMappingType::class, @@ -47,7 +47,6 @@ public function getMetaForm(): ?string self::CHARACTER_LIST, self::EVENT_LIST, self::TAG_LIST => SpreadsheetMetaFormType::class, self::CHARACTER_DOC, self::CHARACTER_DOC_TEMPLATE, self::EVENT_DOC => DocumentMetaFormType::class, default => null, - // etc. }; } @@ -57,7 +56,6 @@ public function matchesTargetType(TargetType $targetType): bool self::CHARACTER_LIST, self::CHARACTER_DOC, self::CHARACTER_DOC_TEMPLATE, self::CHARACTER_DOC_DIRECTORY => $targetType === TargetType::Character, self::EVENT_LIST, self::EVENT_DOC => $targetType === TargetType::Faction, self::TAG_LIST => $targetType === TargetType::Tag, - default => false, }; } diff --git a/src/Domain/Integrations/Form/Integrations/FileMappingType.php b/src/Domain/Integrations/Form/Integrations/FileMappingType.php index dce68b3..9a3fc44 100755 --- a/src/Domain/Integrations/Form/Integrations/FileMappingType.php +++ b/src/Domain/Integrations/Form/Integrations/FileMappingType.php @@ -46,9 +46,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $type = $this->getType($mimeType, $type); $form = $type->getSubForm(); - if ($form !== null) { - $field->add($form); - } + $field->add($form); }) ->add('submit', SubmitType::class); } diff --git a/src/Domain/Integrations/Security/Voter/IntegrationSettingsVoter.php b/src/Domain/Integrations/Security/Voter/IntegrationSettingsVoter.php index 8873dc7..a67e1c2 100755 --- a/src/Domain/Integrations/Security/Voter/IntegrationSettingsVoter.php +++ b/src/Domain/Integrations/Security/Voter/IntegrationSettingsVoter.php @@ -28,7 +28,7 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ } $participants = $subject->getParticipants(); - /** @var \App\Domain\Core\Controller\Backoffice\LarpParticipant|null $userOrganizer */ + /** @var LarpParticipant|null $userOrganizer */ $userOrganizer = $participants->filter(fn (LarpParticipant $participant): bool => $participant->getUser()->getId() === $user->getId() && $participant->isAdmin())->first(); return $userOrganizer !== null; } diff --git a/src/Domain/Integrations/Service/Google/GoogleIntegrationService.php b/src/Domain/Integrations/Service/Google/GoogleIntegrationService.php index 8753319..6cf317e 100755 --- a/src/Domain/Integrations/Service/Google/GoogleIntegrationService.php +++ b/src/Domain/Integrations/Service/Google/GoogleIntegrationService.php @@ -23,7 +23,6 @@ use KnpU\OAuth2ClientBundle\Client\Provider\GoogleClient; use League\OAuth2\Client\Provider\GoogleUser; use League\OAuth2\Client\Provider\ResourceOwnerInterface; -use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Token\AccessTokenInterface; use RuntimeException; use Symfony\Component\HttpFoundation\Response; @@ -121,7 +120,7 @@ public function getOwnerNameFromOwner(ResourceOwnerInterface $owner): ?string } public function createGoogleDriveIntegration( - AccessToken $accessToken, + AccessTokenInterface $accessToken, ResourceOwnerInterface $owner, string $larpId ): LarpIntegration { diff --git a/src/Domain/Integrations/Service/Google/GoogleOAuthTokenProvider.php b/src/Domain/Integrations/Service/Google/GoogleOAuthTokenProvider.php index a38f564..3acfdfc 100755 --- a/src/Domain/Integrations/Service/Google/GoogleOAuthTokenProvider.php +++ b/src/Domain/Integrations/Service/Google/GoogleOAuthTokenProvider.php @@ -2,7 +2,6 @@ namespace App\Domain\Integrations\Service\Google; -use App\Domain\Integrations\Entity\LarpIntegration; use App\Domain\Integrations\Repository\LarpIntegrationRepository; use App\Domain\Integrations\Service\Exceptions\ReAuthenticationNeededException; use App\Domain\Integrations\Service\OAuthTokenProviderInterface; @@ -17,7 +16,6 @@ public function __construct( public function getTokenForIntegration(string $integrationId): ?string { - /** @var LarpIntegration $integration */ $integration = $this->integrationRepository->find($integrationId); if (!$integration) { diff --git a/src/Domain/Integrations/Service/Google/GoogleSpreadsheetIntegrationHelper.php b/src/Domain/Integrations/Service/Google/GoogleSpreadsheetIntegrationHelper.php index 7ffdcdf..83022bf 100755 --- a/src/Domain/Integrations/Service/Google/GoogleSpreadsheetIntegrationHelper.php +++ b/src/Domain/Integrations/Service/Google/GoogleSpreadsheetIntegrationHelper.php @@ -233,7 +233,7 @@ private function findNextAvailableRowFromMetadata(Sheets\Spreadsheet $spreadshee $columnCount = count($columnRange); foreach ($rows as $index => $row) { - $cells = $row->getValues() ?? []; + $cells = $row->getValues(); $cellsInRange = array_slice($cells, 0, $columnCount); // Only slice needed columns $hasContent = false; diff --git a/src/Domain/Integrations/Service/IntegrationManager.php b/src/Domain/Integrations/Service/IntegrationManager.php index a2b3ba8..0e30a22 100755 --- a/src/Domain/Integrations/Service/IntegrationManager.php +++ b/src/Domain/Integrations/Service/IntegrationManager.php @@ -41,7 +41,6 @@ public function getService(LarpIntegration|string|Uuid|LarpIntegrationProvider $ is_string($input), $input instanceof Uuid => $this->larpIntegrationRepository->find($input)?->getProvider(), $input instanceof LarpIntegrationProvider => $input, $input instanceof LarpIntegration => $input->getProvider(), - default => throw new \InvalidArgumentException("Invalid type for integrationOrId: " . get_debug_type($input)), }; if (!$provider) { diff --git a/src/Domain/Integrations/Service/OAuthTokenProviderFactory.php b/src/Domain/Integrations/Service/OAuthTokenProviderFactory.php index bfcbe20..b50d7c9 100755 --- a/src/Domain/Integrations/Service/OAuthTokenProviderFactory.php +++ b/src/Domain/Integrations/Service/OAuthTokenProviderFactory.php @@ -19,6 +19,6 @@ public function getProviderForIntegration(LarpIntegration $integration): OAuthTo return $this->googleOAuthTokenProvider; } - throw new \InvalidArgumentException("No OAuth provider found for integration:" . $integration->getId()->toRfc4122()); + throw new \LogicException("No OAuth provider found for integration:" . $integration->getId()->toRfc4122()); } } diff --git a/src/Domain/Kanban/Entity/KanbanTask.php b/src/Domain/Kanban/Entity/KanbanTask.php index c1d8acd..0074c8b 100755 --- a/src/Domain/Kanban/Entity/KanbanTask.php +++ b/src/Domain/Kanban/Entity/KanbanTask.php @@ -49,7 +49,7 @@ class KanbanTask private ?\DateTimeInterface $dueDate = null; #[ORM\Column(type: 'json', nullable: true)] - private ?array $activityLog = []; + private array $activityLog = []; public function getLarp(): ?Larp { @@ -169,10 +169,6 @@ public function getActivityLog(): array private function logActivity(string $type, array $data): void { - if ($this->activityLog === null) { - $this->activityLog = []; - } - $this->activityLog[] = [ 'type' => $type, 'data' => $data, diff --git a/src/Domain/Mailing/Form/MailTemplateFilterType.php b/src/Domain/Mailing/Form/MailTemplateFilterType.php index 4d1e616..5cdcc8a 100644 --- a/src/Domain/Mailing/Form/MailTemplateFilterType.php +++ b/src/Domain/Mailing/Form/MailTemplateFilterType.php @@ -17,6 +17,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ->add('name', Filters\TextFilterType::class, [ 'condition_pattern' => FilterOperands::STRING_CONTAINS, 'label' => 'name', + 'required' => false, ]) ->add('type', Filters\EnumFilterType::class, [ 'class' => MailTemplateType::class, @@ -27,6 +28,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]) ->add('enabled', Filters\BooleanFilterType::class, [ 'label' => 'mail_template.enabled', + 'translation_domain' => 'forms', 'required' => false, ]); } diff --git a/src/Domain/Mailing/Repository/MailTemplateRepository.php b/src/Domain/Mailing/Repository/MailTemplateRepository.php index 7254c4c..5d5dd2d 100644 --- a/src/Domain/Mailing/Repository/MailTemplateRepository.php +++ b/src/Domain/Mailing/Repository/MailTemplateRepository.php @@ -3,44 +3,21 @@ namespace App\Domain\Mailing\Repository; use App\Domain\Core\Entity\Larp; +use App\Domain\Core\Repository\BaseRepository; use App\Domain\Mailing\Entity\Enum\MailTemplateType; use App\Domain\Mailing\Entity\MailTemplate; -use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; /** - * @extends ServiceEntityRepository + * @extends BaseRepository */ -class MailTemplateRepository extends ServiceEntityRepository +class MailTemplateRepository extends BaseRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, MailTemplate::class); } - public function save(MailTemplate $template, bool $flush = false): void - { - $this->_em->persist($template); - - if ($flush) { - $this->_em->flush(); - } - } - - public function remove(MailTemplate $template, bool $flush = false): void - { - $this->_em->remove($template); - - if ($flush) { - $this->_em->flush(); - } - } - - public function flush(): void - { - $this->_em->flush(); - } - public function findOneByLarpAndType(Larp $larp, MailTemplateType $type): ?MailTemplate { return $this->findOneBy([ diff --git a/src/Domain/Map/Controller/Backoffice/GameMapController.php b/src/Domain/Map/Controller/Backoffice/GameMapController.php index 3f2ab69..6fa3cb7 100755 --- a/src/Domain/Map/Controller/Backoffice/GameMapController.php +++ b/src/Domain/Map/Controller/Backoffice/GameMapController.php @@ -59,7 +59,6 @@ public function modify( if ($isNew) { $map = new GameMap(); $map->setLarp($larp); - $map->setCreatedBy($this->getUser()); } $form = $this->createForm(GameMapType::class, $map); @@ -150,7 +149,6 @@ public function locationModify( if ($isNew) { $location = new MapLocation(); $location->setMap($map); - $location->setCreatedBy($this->getUser()); } $form = $this->createForm(MapLocationType::class, $location, ['larp' => $larp]); diff --git a/src/Domain/Public/Controller/CharacterGalleryController.php b/src/Domain/Public/Controller/CharacterGalleryController.php new file mode 100644 index 0000000..2018d90 --- /dev/null +++ b/src/Domain/Public/Controller/CharacterGalleryController.php @@ -0,0 +1,94 @@ +getPublishCharactersPublicly()) { + throw $this->createAccessDeniedException('Characters are not publicly available for this LARP.'); + } + + // Check LARP is visible + if (!$larp->getStatus()?->isVisibleForEveryone()) { + throw $this->createAccessDeniedException('This LARP is not publicly visible.'); + } + + // Build base query - only show Player characters or those available for recruitment + $qb = $characterRepository->createQueryBuilder('c') + ->where('c.larp = :larp') + ->andWhere('(c.characterType = :playerType OR c.availableForRecruitment = true)') + ->setParameter('larp', $larp) + ->setParameter('playerType', CharacterType::Player); + + // Apply filters + $filterForm = $this->createForm(CharacterGalleryFilterType::class, null, ['larp' => $larp]); + $filterForm->handleRequest($request); + $this->filterBuilderUpdater->addFilterConditions($filterForm, $qb); + + // Get characters + $characters = $qb->getQuery()->getResult(); + + return $this->render('public/character/gallery.html.twig', [ + 'larp' => $larp, + 'characters' => $characters, + 'filterForm' => $filterForm->createView(), + ]); + } + + #[Route('/{id}', name: 'show', methods: ['GET'])] + public function show( + Larp $larp, + Character $character + ): Response { + // Check if characters are published publicly + if (!$larp->getPublishCharactersPublicly()) { + throw $this->createAccessDeniedException('Characters are not publicly available for this LARP.'); + } + + // Check LARP is visible + if (!$larp->getStatus()?->isVisibleForEveryone()) { + throw $this->createAccessDeniedException('This LARP is not publicly visible.'); + } + + // Verify character belongs to this LARP + if ($character->getLarp() !== $larp) { + throw $this->createNotFoundException('Character not found for this LARP.'); + } + + // Verify character is eligible for public viewing + if ($character->getCharacterType() !== CharacterType::Player) { + throw $this->createAccessDeniedException('This character is not publicly available.'); + } + + return $this->render('public/character/show.html.twig', [ + 'larp' => $larp, + 'character' => $character, + ]); + } +} diff --git a/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php b/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php new file mode 100644 index 0000000..9f41185 --- /dev/null +++ b/src/Domain/Public/Form/Filter/CharacterGalleryFilterType.php @@ -0,0 +1,89 @@ +add('search', TextType::class, [ + 'label' => 'Search', + 'required' => false, + 'attr' => [ + 'placeholder' => 'Search by name or description...', + 'class' => 'form-control', + ], + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + + $qb = $filterQuery->getQueryBuilder(); + $qb->andWhere('(c.title LIKE :search OR c.description LIKE :search)') + ->setParameter('search', '%' . $values['value'] . '%'); + + return null; + }, + ]) + ->add('tags', EntityType::class, [ + 'class' => Tag::class, + 'label' => 'Tags', + 'required' => false, + 'multiple' => true, + 'autocomplete' => true, + 'query_builder' => fn ($repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') + ->setParameter('larp', $larp) + ->orderBy('t.title', 'ASC'), + 'attr' => ['class' => 'form-select'], + 'apply_filter' => function (QueryInterface $filterQuery, $field, $values) { + if (empty($values['value'])) { + return null; + } + + $qb = $filterQuery->getQueryBuilder(); + $qb->join('c.tags', 't') + ->andWhere('t IN (:tags)') + ->setParameter('tags', $values['value']); + + return null; + }, + ]) + ->add('gender', EnumType::class, [ + 'class' => Gender::class, + 'label' => 'Gender', + 'required' => false, + 'placeholder' => 'Any gender', + 'choice_label' => fn (Gender $gender): string => $gender->name, + 'attr' => ['class' => 'form-select'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_protection' => false, + 'validation_groups' => ['filtering'], + 'method' => 'GET', + 'translation_domain' => 'forms', + 'larp' => null, + ]); + $resolver->setRequired('larp'); + } +} diff --git a/src/Domain/StoryMarketplace/Entity/StoryRecruitment.php b/src/Domain/StoryMarketplace/Entity/StoryRecruitment.php index abf37c2..de4834a 100755 --- a/src/Domain/StoryMarketplace/Entity/StoryRecruitment.php +++ b/src/Domain/StoryMarketplace/Entity/StoryRecruitment.php @@ -2,6 +2,7 @@ namespace App\Domain\StoryMarketplace\Entity; +use App\Domain\Core\Entity\Trait\CreatorAwareInterface; use App\Domain\Core\Entity\Trait\CreatorAwareTrait; use App\Domain\Core\Entity\Trait\UuidTraitEntity; use App\Domain\StoryMarketplace\Repository\StoryRecruitmentRepository; @@ -12,7 +13,7 @@ use Gedmo\Timestampable\Traits\TimestampableEntity; #[ORM\Entity(repositoryClass: StoryRecruitmentRepository::class)] -class StoryRecruitment +class StoryRecruitment implements CreatorAwareInterface { use UuidTraitEntity; use TimestampableEntity; diff --git a/src/Domain/StoryMarketplace/Service/MarketplaceService.php b/src/Domain/StoryMarketplace/Service/MarketplaceService.php index a1942ff..5b0900b 100755 --- a/src/Domain/StoryMarketplace/Service/MarketplaceService.php +++ b/src/Domain/StoryMarketplace/Service/MarketplaceService.php @@ -3,12 +3,10 @@ namespace App\Domain\StoryMarketplace\Service; use App\Domain\Application\Entity\LarpApplication; -use App\Domain\Application\Repository\LarpApplicationRepository; use App\Domain\Core\Entity\Tag; use App\Domain\StoryObject\Entity\Event; use App\Domain\StoryObject\Entity\Quest; use App\Domain\StoryObject\Entity\Thread; -use App\Domain\StoryObject\Repository\CharacterRepository; use App\Domain\StoryObject\Repository\EventRepository; use App\Domain\StoryObject\Repository\QuestRepository; use App\Domain\StoryObject\Repository\ThreadRepository; @@ -19,8 +17,6 @@ public function __construct( private ThreadRepository $threadRepository, private QuestRepository $questRepository, private EventRepository $eventRepository, - private CharacterRepository $characterRepository, - private LarpApplicationRepository $applicationRepository ) { } diff --git a/src/Domain/StoryObject/Controller/Backoffice/CharacterController.php b/src/Domain/StoryObject/Controller/Backoffice/CharacterController.php index 6e10d30..5f8d9db 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/CharacterController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/CharacterController.php @@ -105,7 +105,7 @@ public function modify( $applicantsCount = 0; $commentsCount = 0; $unresolvedCommentsCount = 0; - if ($character->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($character); $applicantsCount = $choiceRepository->getApplicationsCountForCharacter($character); $commentsCount = $commentRepository->countByStoryObject($character); @@ -229,7 +229,6 @@ public function selectIntegrationFile( $integration = $larpManager->getIntegrationTypeForLarp($larp, $provider); Assert::notNull($integration, sprintf('Integration %s not found for LARP %s', $provider->value, $larp->getId()->toRfc4122())); - /** @var SharedFile[] $files */ $files = $integration->getSharedFiles(); return $this->render('backoffice/larp/characters/fileSelect.html.twig', [ 'larp' => $larp, diff --git a/src/Domain/StoryObject/Controller/Backoffice/EventController.php b/src/Domain/StoryObject/Controller/Backoffice/EventController.php index 5469e57..b3f7762 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/EventController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/EventController.php @@ -235,7 +235,7 @@ public function modify( // Get mentions only for existing events (not new ones) $mentions = []; - if ($event->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($event); } @@ -365,7 +365,6 @@ public function recruitment( if (!$recruitment instanceof StoryRecruitment) { $recruitment = new StoryRecruitment(); $recruitment->setStoryObject($event); - $recruitment->setCreatedBy($this->getUser()); } $form = $this->createForm(StoryRecruitmentType::class, $recruitment); diff --git a/src/Domain/StoryObject/Controller/Backoffice/FactionController.php b/src/Domain/StoryObject/Controller/Backoffice/FactionController.php index fb34068..868a946 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/FactionController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/FactionController.php @@ -85,7 +85,7 @@ public function modify( // Get mentions only for existing factions (not new ones) $mentions = []; - if ($faction->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($faction); } diff --git a/src/Domain/StoryObject/Controller/Backoffice/ItemController.php b/src/Domain/StoryObject/Controller/Backoffice/ItemController.php index aebad7d..47964ac 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/ItemController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/ItemController.php @@ -69,7 +69,7 @@ public function modify( // Get mentions only for existing items (not new ones) $mentions = []; - if ($item->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($item); } diff --git a/src/Domain/StoryObject/Controller/Backoffice/PlaceController.php b/src/Domain/StoryObject/Controller/Backoffice/PlaceController.php index ea8e822..a66e971 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/PlaceController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/PlaceController.php @@ -66,7 +66,7 @@ public function modify( // Get mentions only for existing places (not new ones) $mentions = []; - if ($place->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($place); } diff --git a/src/Domain/StoryObject/Controller/Backoffice/QuestController.php b/src/Domain/StoryObject/Controller/Backoffice/QuestController.php index 107d528..0718a07 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/QuestController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/QuestController.php @@ -2,6 +2,7 @@ namespace App\Domain\StoryObject\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Entity\Larp; use App\Domain\Core\Service\LarpManager; @@ -86,7 +87,7 @@ public function modify( // Get mentions only for existing quests (not new ones) $mentions = []; - if ($quest->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($quest); } @@ -240,7 +241,11 @@ public function recruitment( if (!$recruitment instanceof StoryRecruitment) { $recruitment = new StoryRecruitment(); $recruitment->setStoryObject($quest); - $recruitment->setCreatedBy($this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + if ($currentUser instanceof User) { + $recruitment->setCreatedBy($currentUser); + } } $form = $this->createForm(StoryRecruitmentType::class, $recruitment); diff --git a/src/Domain/StoryObject/Controller/Backoffice/TagController.php b/src/Domain/StoryObject/Controller/Backoffice/TagController.php index fdcf972..a08905f 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/TagController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/TagController.php @@ -15,6 +15,7 @@ use App\Domain\Integrations\Entity\ObjectFieldMapping; use App\Domain\Integrations\Entity\SharedFile; use App\Domain\Integrations\Service\IntegrationManager; +use Doctrine\Common\Collections\Collection; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -87,7 +88,7 @@ public function selectIntegrationFile( $integration = $larpManager->getIntegrationTypeForLarp($larp, $provider); Assert::notNull($integration, sprintf('Integration %s not found for LARP %s', $provider->value, $larp->getId()->toRfc4122())); - /** @var SharedFile[] $files */ + /** @var Collection $files */ $files = $integration->getSharedFiles(); return $this->render('backoffice/larp/tag/fileSelect.html.twig', [ 'larp' => $larp, diff --git a/src/Domain/StoryObject/Controller/Backoffice/ThreadController.php b/src/Domain/StoryObject/Controller/Backoffice/ThreadController.php index 6636ed2..d1b9f78 100755 --- a/src/Domain/StoryObject/Controller/Backoffice/ThreadController.php +++ b/src/Domain/StoryObject/Controller/Backoffice/ThreadController.php @@ -2,6 +2,7 @@ namespace App\Domain\StoryObject\Controller\Backoffice; +use App\Domain\Account\Entity\User; use App\Domain\Core\Controller\BaseController; use App\Domain\Core\Entity\Larp; use App\Domain\Core\Service\LarpManager; @@ -19,6 +20,7 @@ use App\Domain\StoryObject\Repository\CharacterRepository; use App\Domain\StoryObject\Repository\ThreadRepository; use App\Domain\StoryObject\Service\StoryObjectMentionService; +use App\Domain\StoryObject\Service\StoryObjectTextLinker; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -56,7 +58,7 @@ public function modify( Larp $larp, ThreadRepository $threadRepository, CharacterRepository $characterRepository, - \App\Domain\StoryObject\Service\StoryObjectTextLinker $textLinker, + StoryObjectTextLinker $textLinker, ?Thread $thread = null, ): Response { $new = false; @@ -92,7 +94,7 @@ public function modify( // Get mentions only for existing threads (not new ones) $mentions = []; - if ($thread->getId() !== null) { + if (!$new) { $mentions = $mentionService->findMentions($thread); } @@ -246,7 +248,11 @@ public function recruitment( if (!$recruitment instanceof StoryRecruitment) { $recruitment = new StoryRecruitment(); $recruitment->setStoryObject($thread); - $recruitment->setCreatedBy($this->getUser()); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + if ($currentUser instanceof User) { + $recruitment->setCreatedBy($currentUser); + } } $form = $this->createForm(StoryRecruitmentType::class, $recruitment); diff --git a/src/Domain/StoryObject/Entity/CharacterSkill.php b/src/Domain/StoryObject/Entity/CharacterSkill.php index fbf4e46..fa788b4 100755 --- a/src/Domain/StoryObject/Entity/CharacterSkill.php +++ b/src/Domain/StoryObject/Entity/CharacterSkill.php @@ -23,4 +23,44 @@ class CharacterSkill #[ORM\Column(type: 'string', length: 255)] private ?string $description = null; + + public function getCharacter(): Character + { + return $this->character; + } + + public function setCharacter(Character $character): void + { + $this->character = $character; + } + + public function getSkill(): Skill + { + return $this->skill; + } + + public function setSkill(Skill $skill): void + { + $this->skill = $skill; + } + + public function getLevel(): int + { + return $this->level; + } + + public function setLevel(int $level): void + { + $this->level = $level; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } } diff --git a/src/Domain/StoryObject/Entity/Comment.php b/src/Domain/StoryObject/Entity/Comment.php index ff166aa..48ce928 100644 --- a/src/Domain/StoryObject/Entity/Comment.php +++ b/src/Domain/StoryObject/Entity/Comment.php @@ -5,11 +5,11 @@ namespace App\Domain\StoryObject\Entity; use App\Domain\Account\Entity\User; +use App\Domain\Core\Entity\Trait\CreatorAwareTrait; +use App\Domain\Core\Entity\Trait\UuidTraitEntity; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; -use Gedmo\Mapping\Annotation as Gedmo; -use Symfony\Bridge\Doctrine\Types\UuidType; -use Symfony\Component\Uid\Uuid; +use Gedmo\Timestampable\Traits\TimestampableEntity; #[ORM\Entity(repositoryClass: 'App\Domain\StoryObject\Repository\CommentRepository')] #[ORM\Table(name: 'comment')] @@ -17,11 +17,9 @@ #[ORM\Index(name: 'idx_comment_parent', columns: ['parent_id'])] class Comment { - #[ORM\Id] - #[ORM\Column(type: UuidType::NAME)] - #[ORM\GeneratedValue(strategy: 'CUSTOM')] - #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] - private ?Uuid $id = null; + use UuidTraitEntity; + use TimestampableEntity; + use CreatorAwareTrait; #[ORM\ManyToOne(targetEntity: StoryObject::class)] #[ORM\JoinColumn(name: 'story_object_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] @@ -44,18 +42,6 @@ class Comment #[ORM\Column(type: Types::BOOLEAN)] private bool $isResolved = false; - #[ORM\Column(type: Types::DATETIME_MUTABLE)] - #[Gedmo\Timestampable(on: 'create')] - private ?\DateTimeInterface $createdAt = null; - - #[ORM\Column(type: Types::DATETIME_MUTABLE)] - #[Gedmo\Timestampable(on: 'update')] - private ?\DateTimeInterface $updatedAt = null; - - public function getId(): ?Uuid - { - return $this->id; - } public function getStoryObject(): StoryObject { @@ -117,30 +103,6 @@ public function setIsResolved(bool $isResolved): self return $this; } - public function getCreatedAt(): ?\DateTimeInterface - { - return $this->createdAt; - } - - public function setCreatedAt(\DateTimeInterface $createdAt): self - { - $this->createdAt = $createdAt; - - return $this; - } - - public function getUpdatedAt(): ?\DateTimeInterface - { - return $this->updatedAt; - } - - public function setUpdatedAt(\DateTimeInterface $updatedAt): self - { - $this->updatedAt = $updatedAt; - - return $this; - } - /** * Check if this comment is a top-level comment (no parent) */ diff --git a/src/Domain/StoryObject/Entity/Skill.php b/src/Domain/StoryObject/Entity/Skill.php index ebc731b..0473c0b 100755 --- a/src/Domain/StoryObject/Entity/Skill.php +++ b/src/Domain/StoryObject/Entity/Skill.php @@ -26,4 +26,34 @@ class Skill implements CreatorAwareInterface, Timestampable #[ORM\Column(type: 'text', nullable: true)] private ?string $description = null; + + public function getLarp(): Larp + { + return $this->larp; + } + + public function setLarp(Larp $larp): void + { + $this->larp = $larp; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): void + { + $this->description = $description; + } } diff --git a/src/Domain/StoryObject/Form/RelationType.php b/src/Domain/StoryObject/Form/RelationType.php index 0a6be52..615b880 100755 --- a/src/Domain/StoryObject/Form/RelationType.php +++ b/src/Domain/StoryObject/Form/RelationType.php @@ -132,7 +132,7 @@ private function getDisabledFieldList(FormBuilderInterface $builder, ?StoryObjec /** @var Relation|null $relation */ $relation = $builder->getData(); - $isEditing = $relation && null !== $relation->getId(); + $isEditing = $relation && null !== $relation->getCreatedAt(); $disableFrom = false; $disableTo = false; diff --git a/src/Domain/StoryObject/Repository/StoryObjectRepository.php b/src/Domain/StoryObject/Repository/StoryObjectRepository.php index 5bde874..f005fcd 100755 --- a/src/Domain/StoryObject/Repository/StoryObjectRepository.php +++ b/src/Domain/StoryObject/Repository/StoryObjectRepository.php @@ -147,24 +147,6 @@ private function fetchIds(string $dql, array $parameters): array return array_map(static fn ($id): string => $id instanceof Uuid ? $id->toRfc4122() : (string) $id, $rows); } - /** - * @param array> $sets - * @return string[] - */ - private function intersectSets(array $sets): array - { - if ($sets === []) { - return []; - } - - $base = array_shift($sets); - foreach ($sets as $set) { - $base = array_intersect($base, $set); - } - - return array_values(array_unique($base)); - } - private function getConnectedToThread(Larp $larp, string $threadId): array { $ids = []; diff --git a/src/Domain/StoryObject/Service/StoryObjectMentionService.php b/src/Domain/StoryObject/Service/StoryObjectMentionService.php index 44f2535..b998aa4 100644 --- a/src/Domain/StoryObject/Service/StoryObjectMentionService.php +++ b/src/Domain/StoryObject/Service/StoryObjectMentionService.php @@ -61,7 +61,7 @@ private function findRelationMentions(StoryObject $storyObject): array $mentions[] = new MentionDTO( sourceObject: $sourceObject, mentionType: 'story_object.mention.type.relation', - context: $relationType ? sprintf('relation.%s', $relationType->value) : 'relation', + context: sprintf('relation.%s', $relationType->value), fieldName: 'relation', ); } diff --git a/src/Domain/StoryObject/Service/StoryObjectTextLinker.php b/src/Domain/StoryObject/Service/StoryObjectTextLinker.php index 5f7cb23..94f2da1 100755 --- a/src/Domain/StoryObject/Service/StoryObjectTextLinker.php +++ b/src/Domain/StoryObject/Service/StoryObjectTextLinker.php @@ -24,6 +24,9 @@ public function finalizeMentions(string $html, Larp $larp): string $xpath = new \DOMXPath($dom); foreach ($xpath->query('//*[@data-story-object-id]') as $node) { + if (!$node instanceof \DOMElement) { + continue; + } $id = $node->getAttribute('data-story-object-id'); $object = $this->storyObjectRepository->find(Uuid::fromString($id)); if (!$object) { @@ -39,7 +42,7 @@ public function finalizeMentions(string $html, Larp $larp): string $link = $dom->createElement('a'); $link->setAttribute('href', $href); $link->nodeValue = $node->textContent; - $node->parentNode->replaceChild($link, $node); + $node->parentNode?->replaceChild($link, $node); } return $dom->saveHTML(); } diff --git a/src/Domain/StoryObject/Service/StoryObjectVersionService.php b/src/Domain/StoryObject/Service/StoryObjectVersionService.php index 813ddb6..8663e90 100755 --- a/src/Domain/StoryObject/Service/StoryObjectVersionService.php +++ b/src/Domain/StoryObject/Service/StoryObjectVersionService.php @@ -5,6 +5,7 @@ use App\Domain\StoryObject\Entity\StoryObject; use App\Domain\StoryObject\Entity\StoryObjectLogEntry; use Doctrine\ORM\EntityManagerInterface; +use Gedmo\Loggable\Entity\Repository\LogEntryRepository; final readonly class StoryObjectVersionService { @@ -17,9 +18,10 @@ public function __construct(private EntityManagerInterface $em) */ public function getVersionHistory(StoryObject $object): array { - /** @var \Gedmo\Loggable\Entity\Repository\LogEntryRepository $repo */ + /** @var LogEntryRepository $repo */ $repo = $this->em->getRepository(StoryObjectLogEntry::class); - $entries = $repo->getLogEntries($object); + /** @var array $entries */ + $entries = $repo->getLogEntries($object); // @phpstan-ignore argument.type (Gedmo generics issue) $history = []; $previousData = null; diff --git a/src/Domain/StoryObject/Validator/UniqueCharacterNameValidator.php b/src/Domain/StoryObject/Validator/UniqueCharacterNameValidator.php index ae0d805..8340e21 100755 --- a/src/Domain/StoryObject/Validator/UniqueCharacterNameValidator.php +++ b/src/Domain/StoryObject/Validator/UniqueCharacterNameValidator.php @@ -12,9 +12,11 @@ public function __construct(private readonly CharacterRepository $repository) { } + /** + * @param UniqueCharacterName $constraint + */ public function validate(mixed $value, Constraint $constraint): void { - /* @var $constraint UniqueCharacterName */ if (null === $value || '' === $value) { return; } diff --git a/src/Domain/Survey/Controller/Backoffice/SurveyController.php b/src/Domain/Survey/Controller/Backoffice/SurveyController.php new file mode 100644 index 0000000..1b31289 --- /dev/null +++ b/src/Domain/Survey/Controller/Backoffice/SurveyController.php @@ -0,0 +1,87 @@ +getSurvey(); + + if (!$survey instanceof Survey) { + // Create from template if doesn't exist + $survey = $this->templateService->createSurveyFromTemplate($larp); + } + + $form = $this->createForm(SurveyType::class, $survey); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var Survey $survey */ + $survey = $form->getData(); + + // Update question order positions + $position = 1; + foreach ($survey->getQuestions() as $question) { + $question->setOrderPosition($position++); + + // Update option order positions + $optionPosition = 1; + foreach ($question->getOptions() as $option) { + $option->setOrderPosition($optionPosition++); + } + } + + $surveyRepository->save($survey); + $this->addFlash('success', $this->translator->trans('survey.saved')); + + return $this->redirectToRoute('backoffice_larp_survey_edit', ['larp' => $larp->getId()]); + } + + return $this->render('backoffice/survey/edit.html.twig', [ + 'form' => $form->createView(), + 'larp' => $larp, + 'survey' => $survey, + ]); + } + + #[Route('/preview', name: 'preview', methods: ['GET'])] + public function preview(Larp $larp): Response + { + $survey = $larp->getSurvey(); + + if (!$survey instanceof Survey) { + $this->addFlash('error', $this->translator->trans('survey.not_found')); + + return $this->redirectToRoute('backoffice_larp_survey_edit', ['larp' => $larp->getId()]); + } + + return $this->render('backoffice/survey/preview.html.twig', [ + 'larp' => $larp, + 'survey' => $survey, + ]); + } +} diff --git a/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php b/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php new file mode 100644 index 0000000..a95a7f0 --- /dev/null +++ b/src/Domain/Survey/Controller/Backoffice/SurveyResponseController.php @@ -0,0 +1,136 @@ +findByLarpWithRelations($larp); + + return $this->render('backoffice/survey/responses/list.html.twig', [ + 'larp' => $larp, + 'responses' => $responses, + ]); + } + + #[Route('/{response}', name: 'show', methods: ['GET'])] + public function show( + Larp $larp, + SurveyResponse $response + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + // Get or generate match suggestions + $matchSuggestions = $response->getMatchSuggestions(); + if ($matchSuggestions === null || $matchSuggestions === []) { + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + } + + return $this->render('backoffice/survey/responses/show.html.twig', [ + 'larp' => $larp, + 'response' => $response, + 'matchSuggestions' => $matchSuggestions, + ]); + } + + #[Route('/{response}/assign/{character}', name: 'assign_character', methods: ['POST'])] + public function assignCharacter( + Larp $larp, + SurveyResponse $response, + Character $character, + SurveyResponseRepository $responseRepository + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp || $character->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + $response->setAssignedCharacter($character); + $response->setStatus(SubmissionStatus::OFFERED); + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.response.character_assigned', [ + '%character%' => $character->getTitle(), + '%user%' => $response->getUser()->getContactEmail(), + ])); + + return $this->redirectToRoute('backoffice_larp_survey_responses_show', [ + 'larp' => $larp->getId(), + 'response' => $response->getId(), + ]); + } + + #[Route('/{response}/regenerate-matches', name: 'regenerate_matches', methods: ['POST'])] + public function regenerateMatches( + Larp $larp, + SurveyResponse $response, + SurveyResponseRepository $responseRepository + ): Response { + // Verify response belongs to this LARP + if ($response->getLarp() !== $larp) { + throw $this->createAccessDeniedException(); + } + + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.response.matches_regenerated')); + + return $this->redirectToRoute('backoffice_larp_survey_responses_show', [ + 'larp' => $larp->getId(), + 'response' => $response->getId(), + ]); + } + + #[Route('/regenerate-all', name: 'regenerate_all', methods: ['POST'])] + public function regenerateAllMatches( + Larp $larp, + SurveyResponseRepository $responseRepository + ): Response { + $responses = $responseRepository->findBy(['larp' => $larp]); + $count = 0; + + foreach ($responses as $response) { + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + $count++; + } + + $responseRepository->flush(); + + $this->addFlash('success', $this->translator->trans('survey.response.all_matches_regenerated', [ + '%count%' => $count, + ])); + + return $this->redirectToRoute('backoffice_larp_survey_responses_list', [ + 'larp' => $larp->getId(), + ]); + } +} diff --git a/src/Domain/Survey/Controller/Public/SurveySubmissionController.php b/src/Domain/Survey/Controller/Public/SurveySubmissionController.php new file mode 100644 index 0000000..2636aa7 --- /dev/null +++ b/src/Domain/Survey/Controller/Public/SurveySubmissionController.php @@ -0,0 +1,221 @@ +getStatus()?->value !== 'INQUIRIES') { + $this->addFlash('error', $this->translator->trans('survey.not_accepting_responses')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Check application mode is SURVEY + if ($larp->getApplicationMode()->value !== 'survey') { + $this->addFlash('error', $this->translator->trans('survey.not_in_survey_mode')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Get survey + $survey = $larp->getSurvey(); + if (!$survey instanceof Survey || !$survey->isActive()) { + $this->addFlash('error', $this->translator->trans('survey.not_available')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Check for existing response + $existingResponse = $responseRepository->findOneBy([ + 'larp' => $larp, + 'user' => $this->getUser(), + ]); + + if ($existingResponse instanceof SurveyResponse) { + $this->addFlash('info', $this->translator->trans('survey.already_submitted')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + // Build dynamic form from survey + $form = $this->buildSurveyForm($formFactory, $survey); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $formData = $form->getData(); + + // Create survey response + $response = new SurveyResponse(); + $response->setSurvey($survey); + $response->setLarp($larp); + /** @var User|null $currentUser */ + $currentUser = $this->getUser(); + $response->setUser($currentUser); + $response->setStatus(SubmissionStatus::NEW); + + // Process answers + foreach ($survey->getQuestions() as $question) { + $fieldName = 'question_' . $question->getId()->toRfc4122(); + $answerValue = $formData[$fieldName] ?? null; + + if ($answerValue !== null && $answerValue !== '' && $answerValue !== []) { + $answer = new SurveyAnswer(); + $answer->setQuestion($question); + $answer->setResponse($response); + + // Handle different question types + if ($question->getQuestionType() === SurveyQuestionType::TAG_SELECTION) { + // $answerValue is array of Tag entities + foreach ($answerValue as $tag) { + $answer->addSelectedTag($tag); + } + } elseif ($question->getQuestionType() === SurveyQuestionType::SINGLE_CHOICE) { + // $answerValue is SurveyQuestionOption entity + $answer->addSelectedOption($answerValue); + } elseif ($question->getQuestionType() === SurveyQuestionType::MULTIPLE_CHOICE) { + // $answerValue is array of SurveyQuestionOption entities + foreach ($answerValue as $option) { + $answer->addSelectedOption($option); + } + } else { + // TEXT, TEXTAREA, RATING + $answer->setAnswerText((string) $answerValue); + } + + $response->addAnswer($answer); + } + } + + // Generate initial match suggestions + $matchSuggestions = $this->matchingService->generateMatchSuggestions($response); + $response->setMatchSuggestions($matchSuggestions); + + // Save response + $responseRepository->save($response); + + $this->addFlash('success', $this->translator->trans('survey.submitted_successfully')); + + return $this->redirectToRoute('public_larp_details', ['slug' => $larp->getSlug()]); + } + + return $this->render('public/survey/submit.html.twig', [ + 'larp' => $larp, + 'survey' => $survey, + 'form' => $form->createView(), + ]); + } + + private function buildSurveyForm(FormFactoryInterface $formFactory, Survey $survey): \Symfony\Component\Form\FormInterface + { + $builder = $formFactory->createBuilder(); + + foreach ($survey->getQuestions() as $question) { + $fieldName = 'question_' . $question->getId()->toRfc4122(); + $options = [ + 'label' => $question->getQuestionText(), + 'required' => $question->isRequired(), + 'help' => $question->getHelpText(), + 'attr' => ['class' => 'form-control'], + ]; + + switch ($question->getQuestionType()) { + case SurveyQuestionType::TEXT: + $builder->add($fieldName, TextType::class, $options); + break; + + case SurveyQuestionType::TEXTAREA: + $options['attr']['rows'] = 4; + $builder->add($fieldName, TextareaType::class, $options); + break; + + case SurveyQuestionType::SINGLE_CHOICE: + $options['choices'] = []; + foreach ($question->getOptions() as $option) { + $options['choices'][$option->getOptionText()] = $option; + } + $options['expanded'] = true; + $options['attr'] = ['class' => 'form-check']; + $builder->add($fieldName, ChoiceType::class, $options); + break; + + case SurveyQuestionType::MULTIPLE_CHOICE: + $options['choices'] = []; + foreach ($question->getOptions() as $option) { + $options['choices'][$option->getOptionText()] = $option; + } + $options['multiple'] = true; + $options['expanded'] = true; + $options['attr'] = ['class' => 'form-check']; + $builder->add($fieldName, ChoiceType::class, $options); + break; + + case SurveyQuestionType::RATING: + $options['attr']['min'] = 1; + $options['attr']['max'] = 5; + $builder->add($fieldName, IntegerType::class, $options); + break; + + case SurveyQuestionType::TAG_SELECTION: + // Use EntityType for tags + $options['class'] = \App\Domain\Core\Entity\Tag::class; + $options['query_builder'] = fn ($repo) => $repo->createQueryBuilder('t') + ->where('t.larp = :larp') + ->setParameter('larp', $survey->getLarp()) + ->orderBy('t.title', 'ASC'); + $options['choice_label'] = 'title'; + $options['multiple'] = true; + $options['autocomplete'] = true; + $options['attr'] = ['class' => 'form-select']; + $builder->add($fieldName, \Symfony\Bridge\Doctrine\Form\Type\EntityType::class, $options); + break; + } + } + + $builder->add('submit', SubmitType::class, [ + 'label' => 'survey.submit', + 'attr' => ['class' => 'btn btn-success btn-lg'], + ]); + + return $builder->getForm(); + } +} diff --git a/src/Domain/Survey/Entity/SurveyResponse.php b/src/Domain/Survey/Entity/SurveyResponse.php index 47d653a..a70d475 100644 --- a/src/Domain/Survey/Entity/SurveyResponse.php +++ b/src/Domain/Survey/Entity/SurveyResponse.php @@ -41,7 +41,7 @@ class SurveyResponse implements Timestampable private ?User $user = null; #[ORM\Column(type: Types::STRING, length: 50, enumType: SubmissionStatus::class)] - private ?SubmissionStatus $status = SubmissionStatus::NEW; + private SubmissionStatus $status = SubmissionStatus::NEW; /** @var Collection */ #[ORM\OneToMany(targetEntity: SurveyAnswer::class, mappedBy: 'response', cascade: ['persist', 'remove'], orphanRemoval: true)] diff --git a/src/Domain/Survey/Form/SurveyQuestionOptionType.php b/src/Domain/Survey/Form/SurveyQuestionOptionType.php new file mode 100644 index 0000000..9ed414a --- /dev/null +++ b/src/Domain/Survey/Form/SurveyQuestionOptionType.php @@ -0,0 +1,39 @@ +add('optionText', TextType::class, [ + 'label' => 'Option', + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'Enter option text...', + ], + ]) + ->add('orderPosition', HiddenType::class, [ + 'attr' => ['class' => 'option-order-position'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SurveyQuestionOption::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Form/SurveyQuestionType.php b/src/Domain/Survey/Form/SurveyQuestionType.php new file mode 100644 index 0000000..1f56bed --- /dev/null +++ b/src/Domain/Survey/Form/SurveyQuestionType.php @@ -0,0 +1,78 @@ +add('questionText', TextareaType::class, [ + 'label' => 'Question', + 'attr' => [ + 'class' => 'form-control', + 'rows' => 2, + 'placeholder' => 'Enter your question here...', + ], + ]) + ->add('helpText', TextType::class, [ + 'label' => 'Help Text', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'placeholder' => 'Optional guidance for the applicant', + ], + ]) + ->add('questionType', EnumType::class, [ + 'class' => SurveyQuestionTypeEnum::class, + 'choice_label' => fn (SurveyQuestionTypeEnum $type): string => $type->getLabel(), + 'label' => 'Question Type', + 'attr' => [ + 'class' => 'form-select', + 'data-action' => 'change->survey-builder#onQuestionTypeChange', + ], + ]) + ->add('isRequired', CheckboxType::class, [ + 'label' => 'Required', + 'required' => false, + 'attr' => ['class' => 'form-check-input'], + ]) + ->add('orderPosition', HiddenType::class, [ + 'attr' => ['class' => 'question-order-position'], + ]) + ->add('options', CollectionType::class, [ + 'entry_type' => SurveyQuestionOptionType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'label' => 'Options', + 'attr' => [ + 'class' => 'question-options-container', + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => SurveyQuestion::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Form/SurveyType.php b/src/Domain/Survey/Form/SurveyType.php new file mode 100644 index 0000000..d315ca6 --- /dev/null +++ b/src/Domain/Survey/Form/SurveyType.php @@ -0,0 +1,67 @@ +add('title', TextType::class, [ + 'label' => 'Survey Title', + 'attr' => ['class' => 'form-control'], + ]) + ->add('description', TextareaType::class, [ + 'label' => 'Description', + 'required' => false, + 'attr' => [ + 'class' => 'form-control', + 'rows' => 3, + ], + 'help' => 'Introduce the survey to applicants', + ]) + ->add('isActive', CheckboxType::class, [ + 'label' => 'Active', + 'required' => false, + 'help' => 'Only active surveys can receive responses', + 'attr' => ['class' => 'form-check-input'], + ]) + ->add('questions', CollectionType::class, [ + 'entry_type' => SurveyQuestionType::class, + 'entry_options' => ['label' => false], + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + 'attr' => [ + 'class' => 'survey-questions-container', + 'data-controller' => 'sortable-questions', + ], + 'label' => false, + ]) + ->add('submit', SubmitType::class, [ + 'label' => 'Save Survey', + 'attr' => ['class' => 'btn btn-primary'], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => Survey::class, + 'translation_domain' => 'forms', + ]); + } +} diff --git a/src/Domain/Survey/Service/CharacterMatchingService.php b/src/Domain/Survey/Service/CharacterMatchingService.php new file mode 100644 index 0000000..1a1c405 --- /dev/null +++ b/src/Domain/Survey/Service/CharacterMatchingService.php @@ -0,0 +1,242 @@ +> Array of match suggestions with character IDs and scores + */ + public function generateMatchSuggestions(SurveyResponse $response): array + { + $larp = $response->getLarp(); + + // Extract tags from survey answers + $preferredTags = $this->extractPreferredTags($response); + $unwantedTags = $this->extractUnwantedTags($response); + + // Load eligible characters (Player type or available for recruitment) + $characters = $this->characterRepository->createQueryBuilder('c') + ->where('c.larp = :larp') + ->andWhere('(c.characterType = :playerType OR c.availableForRecruitment = true)') + ->setParameter('larp', $larp) + ->setParameter('playerType', CharacterType::Player) + ->getQuery() + ->getResult(); + + // Calculate match score for each character + $matches = []; + foreach ($characters as $character) { + $score = $this->calculateMatchScore($character, $preferredTags, $unwantedTags, $response); + + // Exclude characters with unwanted tags (blocking condition) + if ($score === null) { + continue; + } + + $matches[] = [ + 'characterId' => $character->getId()->toRfc4122(), + 'characterTitle' => $character->getTitle(), + 'score' => $score, + 'matchReasons' => $this->getMatchReasons($character, $preferredTags), + ]; + } + + // Sort by score descending + usort($matches, fn ($a, $b) => $b['score'] <=> $a['score']); + + // Return top 5 suggestions + return array_slice($matches, 0, 5); + } + + /** + * Calculate match score for a character. + * + * @param Tag[] $preferredTags + * @param Tag[] $unwantedTags + * + * @return int|null Match score, or null if character has unwanted tags (blocking condition) + */ + private function calculateMatchScore( + Character $character, + array $preferredTags, + array $unwantedTags, + SurveyResponse $response + ): ?int { + $score = 0; + $characterTags = $character->getTags()->toArray(); + $characterTagIds = array_map(fn (Tag $tag) => $tag->getId()->toRfc4122(), $characterTags); + + // BLOCKING: Exclude if character has any unwanted tags + foreach ($unwantedTags as $unwantedTag) { + if (in_array($unwantedTag->getId()->toRfc4122(), $characterTagIds, true)) { + return null; // Block this character + } + } + + // +10 points per matching preferred tag + foreach ($preferredTags as $preferredTag) { + if (in_array($preferredTag->getId()->toRfc4122(), $characterTagIds, true)) { + $score += 10; + } + } + + // +5 points for gender preference match + $genderPreference = $this->extractGenderPreference($response); + if ($character->getGender() === $genderPreference) { + $score += 5; + } + + // +3 points for complexity match (if rating question answered) + $complexityPreference = $this->extractComplexityPreference($response); + if ($complexityPreference !== null) { + $characterComplexity = $this->estimateCharacterComplexity($character); + $complexityDiff = abs($complexityPreference - $characterComplexity); + if ($complexityDiff <= 1) { + $score += 3; + } + } + + return $score; + } + + /** + * Extract preferred tags from survey answers. + * + * @return Tag[] + */ + private function extractPreferredTags(SurveyResponse $response): array + { + $tags = []; + + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'What themes or elements are you most interested in?') { + $tags = array_merge($tags, $answer->getSelectedTags()->toArray()); + } + } + + return $tags; + } + + /** + * Extract unwanted tags from survey answers. + * + * @return Tag[] + */ + private function extractUnwantedTags(SurveyResponse $response): array + { + $tags = []; + + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'Are there any themes or elements you want to avoid?') { + $tags = array_merge($tags, $answer->getSelectedTags()->toArray()); + } + } + + return $tags; + } + + /** + * Extract gender preference from survey answers. + */ + private function extractGenderPreference(SurveyResponse $response): ?Gender + { + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'Do you have a preferred character gender?') { + $selectedOptions = $answer->getSelectedOptions(); + if ($selectedOptions->count() > 0) { + $optionText = $selectedOptions->first()->getOptionText(); + // Map option text to Gender enum values + return match ($optionText) { + 'Male character' => Gender::Male, + 'Female character' => Gender::Female, + 'Non-binary character' => Gender::Other, + default => null, + }; + } + } + } + + return null; + } + + /** + * Extract complexity preference (1-5 rating). + */ + private function extractComplexityPreference(SurveyResponse $response): ?int + { + foreach ($response->getAnswers() as $answer) { + $question = $answer->getQuestion(); + if ($question->getQuestionText() === 'How complex do you want your character to be?') { + $answerText = $answer->getAnswerText(); + if ($answerText !== null && is_numeric($answerText)) { + return (int) $answerText; + } + } + } + + return null; + } + + /** + * Estimate character complexity based on relationships and threads. + */ + private function estimateCharacterComplexity(Character $character): int + { + $threadCount = $character->getThreads()->count(); + $questCount = $character->getQuests()->count(); + $factionCount = $character->getFactions()->count(); + + $totalConnections = $threadCount + $questCount + $factionCount; + + // Map connection count to 1-5 scale + return match (true) { + $totalConnections === 0 => 1, + $totalConnections <= 2 => 2, + $totalConnections <= 5 => 3, + $totalConnections <= 10 => 4, + default => 5, + }; + } + + /** + * Get human-readable match reasons. + * + * @param Tag[] $preferredTags + * + * @return string[] + */ + private function getMatchReasons(Character $character, array $preferredTags): array + { + $reasons = []; + $characterTags = $character->getTags()->toArray(); + $characterTagIds = array_map(fn (Tag $tag) => $tag->getId()->toRfc4122(), $characterTags); + + foreach ($preferredTags as $preferredTag) { + if (in_array($preferredTag->getId()->toRfc4122(), $characterTagIds, true)) { + $reasons[] = sprintf('Matches your interest in: %s', $preferredTag->getTitle()); + } + } + + return $reasons; + } +} diff --git a/src/Domain/Survey/Service/SurveyTemplateService.php b/src/Domain/Survey/Service/SurveyTemplateService.php new file mode 100644 index 0000000..78c205a --- /dev/null +++ b/src/Domain/Survey/Service/SurveyTemplateService.php @@ -0,0 +1,182 @@ +setLarp($larp); + $survey->setTitle('LARP Application Survey'); + $survey->setDescription('This survey helps us match you with the perfect character for this LARP.'); + $survey->setIsActive(false); + + $this->addTemplateQuestions($survey); + + return $survey; + } + + /** + * Add all template questions to a survey. + */ + private function addTemplateQuestions(Survey $survey): void + { + $position = 1; + + // Question 1: Favourite playstyle + $question = new SurveyQuestion(); + $question->setQuestionText('What is your favourite playstyle?'); + $question->setHelpText('Describe the type of roleplay you enjoy most (e.g., political intrigue, combat, investigation, social interaction, etc.)'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 2: LARP experience level + $question = new SurveyQuestion(); + $question->setQuestionText('What is your LARP experience level?'); + $question->setQuestionType(SurveyQuestionType::SINGLE_CHOICE); + $question->setIsRequired(true); + $question->setOrderPosition($position++); + $this->addExperienceLevelOptions($question); + $survey->addQuestion($question); + + // Question 3: Preferred tags + $question = new SurveyQuestion(); + $question->setQuestionText('What themes or elements are you most interested in?'); + $question->setHelpText('Select the tags that interest you most'); + $question->setQuestionType(SurveyQuestionType::TAG_SELECTION); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 4: Unwanted tags + $question = new SurveyQuestion(); + $question->setQuestionText('Are there any themes or elements you want to avoid?'); + $question->setHelpText('Select tags for content you prefer not to encounter'); + $question->setQuestionType(SurveyQuestionType::TAG_SELECTION); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 5: Triggers/safety concerns + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any triggers or safety concerns?'); + $question->setHelpText('Please list any content or situations you need to avoid for safety or comfort reasons'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 6: Preferred character gender + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have a preferred character gender?'); + $question->setQuestionType(SurveyQuestionType::SINGLE_CHOICE); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $this->addGenderPreferenceOptions($question); + $survey->addQuestion($question); + + // Question 7: Character complexity preference + $question = new SurveyQuestion(); + $question->setQuestionText('How complex do you want your character to be?'); + $question->setHelpText('1 = Simple and straightforward, 5 = Very complex with many connections'); + $question->setQuestionType(SurveyQuestionType::RATING); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 8: Costume and prop skills + $question = new SurveyQuestion(); + $question->setQuestionText('What are your costume and prop creation skills?'); + $question->setQuestionType(SurveyQuestionType::MULTIPLE_CHOICE); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $this->addCostumeSkillOptions($question); + $survey->addQuestion($question); + + // Question 9: Dietary restrictions + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any dietary restrictions?'); + $question->setHelpText('Please list any allergies, dietary requirements, or food preferences'); + $question->setQuestionType(SurveyQuestionType::TEXT); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + + // Question 10: Accessibility needs + $question = new SurveyQuestion(); + $question->setQuestionText('Do you have any accessibility needs?'); + $question->setHelpText('Please describe any accommodations you may need (mobility, sensory, medical, etc.)'); + $question->setQuestionType(SurveyQuestionType::TEXTAREA); + $question->setIsRequired(false); + $question->setOrderPosition($position++); + $survey->addQuestion($question); + } + + private function addExperienceLevelOptions(SurveyQuestion $question): void + { + $options = [ + 'This is my first LARP', + 'I have attended 1-3 LARPs', + 'I have attended 4-10 LARPs', + 'I have attended more than 10 LARPs', + 'I am an experienced LARPer (20+ games)', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } + + private function addGenderPreferenceOptions(SurveyQuestion $question): void + { + $options = [ + 'Male character', + 'Female character', + 'Non-binary character', + 'No preference', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } + + private function addCostumeSkillOptions(SurveyQuestion $question): void + { + $options = [ + 'I can create my own costume', + 'I can modify existing clothing', + 'I can create simple props', + 'I can create complex props', + 'I can do makeup/prosthetics', + 'I need help with costume', + ]; + + foreach ($options as $index => $optionText) { + $option = new SurveyQuestionOption(); + $option->setOptionText($optionText); + $option->setOrderPosition($index + 1); + $question->addOption($option); + } + } +} diff --git a/src/PHPStan/Rules/DomainBoundaryRule.php b/src/PHPStan/Rules/DomainBoundaryRule.php index b501807..6a3dbab 100755 --- a/src/PHPStan/Rules/DomainBoundaryRule.php +++ b/src/PHPStan/Rules/DomainBoundaryRule.php @@ -38,23 +38,26 @@ final class DomainBoundaryRule implements Rule */ private const DOMAIN_DEPENDENCIES = [ 'Infrastructure' => [], // Shared kernel, no dependencies on other domains - 'Core' => ['StoryObject', 'Infrastructure', 'Account'], // Shared kernel (legacy name, being migrated to Infrastructure) - 'Account' => ['Infrastructure', 'Core'], - 'Public' => ['Infrastructure', 'Core', 'Account', 'Larp'], + 'Core' => ['StoryObject', 'Infrastructure', 'Account', 'Application', 'Integrations', 'Mailing', 'Survey'], // Shared kernel with necessary cross-domain access + 'Account' => ['Infrastructure', 'Core', 'Application', 'Integrations'], // Account needs Application for user-application relation, Integrations for social accounts + 'Public' => ['Infrastructure', 'Core', 'Account', 'Larp', 'StoryObject', 'Application'], 'Larp' => ['Infrastructure', 'Core', 'Account'], - 'StoryObject' => ['Infrastructure', 'Core', 'Larp', 'Integrations', 'Account', 'StoryMarketplace'], + 'StoryObject' => ['Infrastructure', 'Core', 'Larp', 'Integrations', 'Account', 'StoryMarketplace', 'Application'], 'Application' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Participant', 'Account'], 'Participant' => ['Infrastructure', 'Core', 'Account', 'Larp'], - 'StoryMarketplace' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Account'], + 'StoryMarketplace' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Account', 'Application'], 'Kanban' => ['Infrastructure', 'Core', 'Larp'], - 'Incident' => ['Infrastructure', 'Core', 'Larp'], - 'Incidents' => ['Infrastructure', 'Core', 'Larp'], - 'Map' => ['Infrastructure', 'Core', 'Larp'], - 'EventPlanning' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Map', 'Participant'], - 'Integration' => ['Infrastructure', 'Core', 'Larp', 'StoryObject'], - 'Integrations' => ['Infrastructure', 'Core', 'Larp', 'StoryObject'], + 'Incident' => ['Infrastructure', 'Core', 'Larp', 'Account'], + 'Incidents' => ['Infrastructure', 'Core', 'Larp', 'Account'], + 'Map' => ['Infrastructure', 'Core', 'Larp', 'StoryObject'], + 'EventPlanning' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Map', 'Participant', 'Account'], + 'Integration' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Account'], + 'Integrations' => ['Infrastructure', 'Core', 'Larp', 'StoryObject', 'Account'], 'Feedback' => ['Infrastructure', 'Core'], 'Gallery' => ['Infrastructure', 'Core', 'Larp', 'Account'], + 'Survey' => ['Infrastructure', 'Core', 'Larp', 'Account', 'Application', 'StoryObject'], + 'Mailing' => ['Infrastructure', 'Core', 'Larp'], + 'SuperAdmin' => ['Infrastructure', 'Core', 'Account'], ]; public function getNodeType(): string @@ -64,7 +67,7 @@ public function getNodeType(): string /** * @param Node\Stmt\Use_ $node - * @return array + * @return array<\PHPStan\Rules\IdentifierRuleError> */ public function processNode(Node $node, Scope $scope): array { diff --git a/src/Security/DiscordAuthenticator.php b/src/Security/DiscordAuthenticator.php index ac45d5f..81b54d8 100755 --- a/src/Security/DiscordAuthenticator.php +++ b/src/Security/DiscordAuthenticator.php @@ -92,7 +92,7 @@ public function start(Request $request, AuthenticationException $authException = ); } - private function addSocialAccountToUser(DiscordResourceOwner $user, UserInterface|User $currentUser): UserInterface + private function addSocialAccountToUser(DiscordResourceOwner $user, User $currentUser): UserInterface { $command = new AddSocialAccountToUserCommand( provider: SocialAccountProvider::Discord, diff --git a/src/Security/FacebookAuthenticator.php b/src/Security/FacebookAuthenticator.php index 4f80a0c..46cdd9b 100755 --- a/src/Security/FacebookAuthenticator.php +++ b/src/Security/FacebookAuthenticator.php @@ -91,7 +91,7 @@ public function start(Request $request, AuthenticationException $authException = ); } - private function addSocialAccountToUser(FacebookUser $user, UserInterface|User $currentUser): UserInterface + private function addSocialAccountToUser(FacebookUser $user, User $currentUser): UserInterface { $command = new AddSocialAccountToUserCommand( provider: SocialAccountProvider::Facebook, diff --git a/src/Security/GoogleAuthenticator.php b/src/Security/GoogleAuthenticator.php index f7ed83b..64f5462 100755 --- a/src/Security/GoogleAuthenticator.php +++ b/src/Security/GoogleAuthenticator.php @@ -93,7 +93,7 @@ public function start(Request $request, AuthenticationException $authException = ); } - private function addSocialAccountToUser(GoogleUser $googleUser, UserInterface|User $currentUser): UserInterface + private function addSocialAccountToUser(GoogleUser $googleUser, User $currentUser): UserInterface { $command = new AddSocialAccountToUserCommand( provider: SocialAccountProvider::Google, diff --git a/src/Story/AppStory.php b/src/Story/AppStory.php deleted file mode 100644 index 5cc5657..0000000 --- a/src/Story/AppStory.php +++ /dev/null @@ -1,15 +0,0 @@ -]+>)/u', $html, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) ?: []; foreach ($tokens as $token) { - if ($token !== '' && $token[0] === '<') { + if ($token[0] === '<') { // HTML tag // Track open/close tags for proper closing later (ignore self-closing) if (preg_match('#^<\s*([a-zA-Z0-9:_-]+)(\s[^>]*)?>$#u', $token, $m)) { diff --git a/templates/backoffice/larp/application/list.html.twig b/templates/backoffice/larp/application/list.html.twig index 424e608..e167d49 100755 --- a/templates/backoffice/larp/application/list.html.twig +++ b/templates/backoffice/larp/application/list.html.twig @@ -237,7 +237,7 @@ {% for choice in application.choices %} {{ choice.character.title }} - #{{ choice.priority }} + #{{ choice.priority }} {% endfor %} diff --git a/templates/backoffice/larp/application/view.html.twig b/templates/backoffice/larp/application/view.html.twig index b824de1..d5cf4fa 100755 --- a/templates/backoffice/larp/application/view.html.twig +++ b/templates/backoffice/larp/application/view.html.twig @@ -193,7 +193,7 @@
{# Voting Form #} -
+
{{ 'larp.applications.cast_vote'|trans }}
diff --git a/templates/backoffice/larp/mailing/edit.html.twig b/templates/backoffice/larp/mailing/edit.html.twig index 06585e8..ca40728 100644 --- a/templates/backoffice/larp/mailing/edit.html.twig +++ b/templates/backoffice/larp/mailing/edit.html.twig @@ -25,7 +25,7 @@
{{ 'mail_template.available_placeholders'|trans }}
{% for placeholder in definition.placeholders %} - {{ '{{ ' ~ placeholder ~ ' }}' }} + {{ '{{ ' ~ placeholder ~ ' }}' }} {% endfor %}
diff --git a/templates/backoffice/larp/mailing/list.html.twig b/templates/backoffice/larp/mailing/list.html.twig index c5b2925..85e0fe3 100644 --- a/templates/backoffice/larp/mailing/list.html.twig +++ b/templates/backoffice/larp/mailing/list.html.twig @@ -43,11 +43,11 @@ {{ 'mail_template.optional'|trans }} {% endif %} {% if template.type.belongsToFinanceContext %} - {{ 'mail_template.finance'|trans }} + {{ 'mail_template.finance'|trans }} {% endif %} - {{ template.type.label }} + {{ template.type.label }} {{ definition ? definition.description : '-' }} diff --git a/templates/backoffice/larp/map/modify.html.twig b/templates/backoffice/larp/map/modify.html.twig index 865d3f0..d68bb54 100755 --- a/templates/backoffice/larp/map/modify.html.twig +++ b/templates/backoffice/larp/map/modify.html.twig @@ -5,7 +5,13 @@ {% block larp_title %}{{ (map is defined and map.id ? 'larp.map.modify' : 'larp.map.create')|trans }} - {{ larp.title }}{% endblock %} {% block larp_content %} -
+

{% if isNew %} @@ -17,7 +23,170 @@

{{ form_start(form) }} - {{ form_widget(form) }} + +
+ {# Left column: Basic info #} +
+
{{ 'game_map.basic_info'|trans({}, 'forms') }}
+ + {{ form_row(form.name) }} + {{ form_row(form.description) }} + +
+ {{ form_label(form.imageFile) }} + + {% if form.imageFile.vars.help %} +
{{ form.imageFile.vars.help|trans({}, 'forms') }}
+ {% endif %} + {{ form_errors(form.imageFile) }} + + {% if map is defined and map.imagePath %} +
+ + {{ 'game_map.current_image'|trans({}, 'forms') }}: {{ map.imageFile }} +
+ {% endif %} +
+
+ + {# Right column: Grid configuration #} +
+
{{ 'game_map.grid_settings'|trans({}, 'forms') }}
+ +
+
+
+ {{ form_label(form.gridRows) }} + + {% if form.gridRows.vars.help %} +
{{ form.gridRows.vars.help|trans({}, 'forms') }}
+ {% endif %} + {{ form_errors(form.gridRows) }} +
+
+
+
+ {{ form_label(form.gridColumns) }} + + {% if form.gridColumns.vars.help %} +
{{ form.gridColumns.vars.help|trans({}, 'forms') }}
+ {% endif %} + {{ form_errors(form.gridColumns) }} +
+
+
+ +
+ {{ form_label(form.gridOpacity) }} +
+ + +
+ {% if form.gridOpacity.vars.help %} +
{{ form.gridOpacity.vars.help|trans({}, 'forms') }}
+ {% endif %} + {{ form_errors(form.gridOpacity) }} +
+ +
+
+ + {{ form_label(form.gridVisible, null, { 'label_attr': { 'class': 'form-check-label' } }) }} +
+ {% if form.gridVisible.vars.help %} +
{{ form.gridVisible.vars.help|trans({}, 'forms') }}
+ {% endif %} + {{ form_errors(form.gridVisible) }} +
+ +
+ + {{ 'game_map.grid_preview_hint'|trans({}, 'forms') }} +
+
+
+ + {# Map Preview Section #} +
+
+ {{ 'game_map.preview'|trans({}, 'forms') }} +
+ +
+ {# Placeholder shown when no image #} +
+
+ +

{{ 'game_map.upload_to_preview'|trans({}, 'forms') }}

+
+
+ + {# Map preview container #} +
+
+
+ +
+ +
+
+ {{ ui.form_actions( map is defined and map.id ? 'save' : 'create', 'bi-check-circle', diff --git a/templates/backoffice/larp/partials/_sharedFileList.html.twig b/templates/backoffice/larp/partials/_sharedFileList.html.twig index 4ab43d1..329c8e9 100755 --- a/templates/backoffice/larp/partials/_sharedFileList.html.twig +++ b/templates/backoffice/larp/partials/_sharedFileList.html.twig @@ -45,8 +45,8 @@
-
{{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
-
{{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
{{ mapping.mappingConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
+
{{ mapping.metaConfiguration|json_encode(constant('JSON_PRETTY_PRINT')) }}
diff --git a/templates/backoffice/larp/story/comment/_comment_thread.html.twig b/templates/backoffice/larp/story/comment/_comment_thread.html.twig index ebc80c6..335ef8e 100644 --- a/templates/backoffice/larp/story/comment/_comment_thread.html.twig +++ b/templates/backoffice/larp/story/comment/_comment_thread.html.twig @@ -18,11 +18,11 @@
- {{ comment.author.username }} + {{ comment.author.username }} {{ comment.createdAt|date('M d, Y H:i') }} {% if comment.createdAt != comment.updatedAt %} - + {% endif %} @@ -138,7 +138,7 @@
- {{ reply.author.username }} + {{ reply.author.username }} {{ reply.createdAt|date('M d, Y H:i') }} diff --git a/templates/backoffice/larp/story/comment/discussions.html.twig b/templates/backoffice/larp/story/comment/discussions.html.twig index b6c8fda..4bde14f 100644 --- a/templates/backoffice/larp/story/comment/discussions.html.twig +++ b/templates/backoffice/larp/story/comment/discussions.html.twig @@ -24,7 +24,7 @@ data-comments-show-resolved-value="{{ showResolved ? 'true' : 'false' }}"> {# Header with show/hide resolved toggle #} -
+
diff --git a/templates/backoffice/location/list.html.twig b/templates/backoffice/location/list.html.twig index f0c716b..ff0d44a 100755 --- a/templates/backoffice/location/list.html.twig +++ b/templates/backoffice/location/list.html.twig @@ -8,7 +8,7 @@
{% set actions %} {% if is_granted('ROLE_SUPER_ADMIN') %} -{# #} +{# #} {# {{ 'location.pending_locations'|trans }}#} {# #} {% endif %} @@ -22,17 +22,17 @@ {% else %}
- - - - - - - - - - - + + + + + + + + + + + {% for location in locations %} @@ -63,7 +63,6 @@ {{ ui.delete_button(location.id, location.title, path('backoffice_location_delete', {'id': location.id})) }} {% endif %} {% if is_granted('ROLE_SUPER_ADMIN') and location.isApproved == false %} -
{{ 'location.reject'|trans }} -
{% endif %} diff --git a/templates/backoffice/location/modify.html.twig b/templates/backoffice/location/modify.html.twig index 8b01639..7d6eb93 100755 --- a/templates/backoffice/location/modify.html.twig +++ b/templates/backoffice/location/modify.html.twig @@ -43,10 +43,10 @@
{{ form_row(form.title) }}
-
+
{{ form_row(form.isActive) }}
-
+
{{ form_row(form.isPublic) }}
diff --git a/templates/backoffice/location/reject.html.twig b/templates/backoffice/location/reject.html.twig index 0b402ea..7f64a6f 100644 --- a/templates/backoffice/location/reject.html.twig +++ b/templates/backoffice/location/reject.html.twig @@ -31,7 +31,7 @@ {{ form_row(form.reason) }}
- + {{ 'cancel'|trans }} +
+ +
+ + {% for question in form.questions %} +
+
+
+
+ + {{ loop.index }} + {{ 'survey.question'|trans }} {{ loop.index }} +
+ +
+
+ +
+
+
+ {{ form_label(question.questionText) }} + {{ form_widget(question.questionText) }} + {{ form_errors(question.questionText) }} +
+ +
+ {{ form_label(question.questionType) }} + {{ form_widget(question.questionType) }} + {{ form_errors(question.questionType) }} +
+
+ +
+
+ {{ form_label(question.helpText) }} + {{ form_widget(question.helpText) }} + {{ form_errors(question.helpText) }} +
+ +
+
+ {{ form_widget(question.isRequired) }} + {{ form_label(question.isRequired) }} + {{ form_errors(question.isRequired) }} +
+
+
+ + {# Options section for choice-based questions #} +
+ + {% if question.questionType.vars.value in ['single_choice', 'multiple_choice'] %} +
+
+ + +
+ +
+ {% for option in question.options %} +
+ {{ loop.index }} + {{ form_widget(option.optionText) }} + {{ form_widget(option.orderPosition, {'attr': {'class': 'd-none'}}) }} + +
+ {{ form_errors(option.optionText) }} + {% endfor %} +
+
+ {% endif %} +
+ + {{ form_widget(question.orderPosition, {'attr': {'class': 'd-none'}}) }} +
+
+ {% endfor %} +
+ + {{ form_errors(form.questions) }} +
+ + {# Form actions #} +
+ + {{ 'common.back'|trans }} + + +
+ + {{ form_end(form) }} + + + + {# Help card #} +
+
+
{{ 'survey.help.title'|trans }}
+
    +
  • {{ 'survey.help.drag_reorder'|trans }}
  • +
  • {{ 'survey.help.question_types'|trans }}
  • +
  • {{ 'survey.help.tag_selection'|trans }}
  • +
  • {{ 'survey.help.preview'|trans }}
  • +
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/preview.html.twig b/templates/backoffice/survey/preview.html.twig new file mode 100644 index 0000000..639f59b --- /dev/null +++ b/templates/backoffice/survey/preview.html.twig @@ -0,0 +1,127 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.preview'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} +
+
+
+

{{ 'survey.preview'|trans }}

+ + {{ 'survey.edit'|trans }} + +
+
+ +
+ {# Survey header #} +
+

{{ survey.title }}

+ {% if survey.description %} +

{{ survey.description }}

+ {% endif %} +
+ {% if survey.isActive %} + {{ 'survey.active'|trans }} + {% else %} + {{ 'survey.inactive'|trans }} + {% endif %} +
+
+ +
+ + {# Preview questions #} + {% if survey.questions|length > 0 %} +
+ {% for question in survey.questions %} +
+
+ {# Question header #} +
+
+ {{ loop.index }} + {{ question.questionText }} + {% if question.isRequired %} + * + {% endif %} +
+ {% if question.helpText %} + {{ question.helpText }} + {% endif %} +
+ + {# Question type badge #} +
+ + {{ question.questionType.label }} + +
+ + {# Preview based on question type #} +
+ {% if question.questionType.value == 'text' %} + + + {% elseif question.questionType.value == 'textarea' %} + + + {% elseif question.questionType.value == 'single_choice' %} +
+ {% for option in question.options %} +
+ + +
+ {% endfor %} +
+ + {% elseif question.questionType.value == 'multiple_choice' %} +
+ {% for option in question.options %} +
+ + +
+ {% endfor %} +
+ + {% elseif question.questionType.value == 'rating' %} +
+ {% for i in 1..5 %} + + {% endfor %} +
+ + {% elseif question.questionType.value == 'tag_selection' %} +
+ {{ 'survey.preview.tag_selection_info'|trans }} +
+ {% endif %} +
+
+
+ {% endfor %} +
+ {% else %} +
+ {{ 'survey.no_questions'|trans }} +
+ {% endif %} + + {# Preview submit button #} +
+ + {{ 'common.back'|trans }} + + +
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/responses/list.html.twig b/templates/backoffice/survey/responses/list.html.twig new file mode 100644 index 0000000..61aa3ac --- /dev/null +++ b/templates/backoffice/survey/responses/list.html.twig @@ -0,0 +1,144 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.responses.list'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} +
+
+
+

{{ 'survey.responses.list'|trans }}

+
+ + + +
+
+
+ +
+ {% if responses is not empty %} +
+
TitleAddressCityCapacityStatusApprovalActions
{{ 'location.title'|trans }}{{ 'location.address'|trans }}{{ 'location.city'|trans }}{{ 'location.capacity'|trans }}{{ 'location.status'|trans }}{{ 'location.approval'|trans }}{{ 'actions'|trans }}
+ + + + + + + + + + + + + {% for response in responses %} + + + + + + + + + + {% endfor %} + +
{{ 'common.user'|trans }}{{ 'common.email'|trans }}{{ 'common.status'|trans }}{{ 'survey.responses.assigned_character'|trans }}{{ 'survey.responses.match_score'|trans }}{{ 'common.submitted_at'|trans }}{{ 'common.actions'|trans }}
+ + {{ response.user.displayName }} + + {{ response.user.contactEmail }} + {% set statusClass = { + 'NEW': 'secondary', + 'CONSIDER': 'warning', + 'OFFERED': 'info', + 'CONFIRMED': 'success', + 'REJECTED': 'danger', + 'DECLINED': 'danger' + } %} + + {{ response.status.value }} + + + {% if response.assignedCharacter %} + {{ response.assignedCharacter.title }} + {% else %} + - + {% endif %} + + {% if response.matchSuggestions and response.matchSuggestions|length > 0 %} + + {{ response.matchSuggestions[0].score }} pts + + {% else %} + - + {% endif %} + + + {{ response.createdAt|date('Y-m-d H:i') }} + + + + {{ 'common.view'|trans }} + +
+
+ {% else %} +
+ +

{{ 'survey.responses.empty'|trans }}

+
+ {% endif %} +
+
+ + {# Summary cards #} +
+
+
+
+
{{ 'survey.responses.total'|trans }}
+

{{ responses|length }}

+
+
+
+
+
+
+
{{ 'survey.responses.pending'|trans }}
+

+ {{ responses|filter(r => r.status.value == 'NEW')|length }} +

+
+
+
+
+
+
+
{{ 'survey.responses.assigned'|trans }}
+

+ {{ responses|filter(r => r.assignedCharacter is not null)|length }} +

+
+
+
+
+
+
+
{{ 'survey.responses.confirmed'|trans }}
+

+ {{ responses|filter(r => r.status.value == 'CONFIRMED')|length }} +

+
+
+
+
+{% endblock %} diff --git a/templates/backoffice/survey/responses/show.html.twig b/templates/backoffice/survey/responses/show.html.twig new file mode 100644 index 0000000..2cadf71 --- /dev/null +++ b/templates/backoffice/survey/responses/show.html.twig @@ -0,0 +1,196 @@ +{% extends 'backoffice/larp/base.html.twig' %} + +{% block larp_title %}{{ 'survey.response.details'|trans }} - {{ larp.title }}{% endblock %} + +{% block larp_content %} + {# Response header #} +
+
+
+

{{ 'survey.response.details'|trans }}

+
+
+ +
+ + {{ 'common.back'|trans }} + +
+
+
+ +
+
+
+
{{ 'common.applicant'|trans }}
+

{{ response.user.displayName }}

+

{{ response.user.contactEmail }}

+
+
+
{{ 'common.status'|trans }}
+ {% set statusClass = { + 'NEW': 'secondary', + 'CONSIDER': 'warning', + 'OFFERED': 'info', + 'CONFIRMED': 'success', + 'REJECTED': 'danger', + 'DECLINED': 'danger' + } %} + + {{ response.status.value }} + +
+
+
{{ 'common.submitted_at'|trans }}
+

{{ response.createdAt|date('Y-m-d H:i') }}

+
+
+ + {% if response.assignedCharacter %} +
+
+
{{ 'survey.response.assigned_character'|trans }}
+

+ {{ response.assignedCharacter.title }} + {% if response.assignedCharacter.description %} +
{{ response.assignedCharacter.description|sanitize_html|striptags|slice(0, 200) }}... + {% endif %} +

+
+ {% endif %} +
+
+ + {# Match suggestions #} + {% if matchSuggestions and matchSuggestions|length > 0 %} +
+
+

{{ 'survey.response.match_suggestions'|trans }}

+
+
+
+ {% for suggestion in matchSuggestions %} +
+
+
+
+ #{{ loop.index }} + {{ suggestion.characterTitle }} +
+ {% if suggestion.matchReasons %} +
    + {% for reason in suggestion.matchReasons %} +
  • {{ reason }}
  • + {% endfor %} +
+ {% endif %} +
+
+
+
{{ suggestion.score }}
+ {{ 'survey.response.points'|trans }} +
+
+
+
+ +
+
+
+
+ {% endfor %} +
+
+
+ {% else %} +
+ {{ 'survey.response.no_matches'|trans }} +
+ {% endif %} + + {# Survey answers #} +
+
+

{{ 'survey.response.answers'|trans }}

+
+
+ {% if response.answers|length > 0 %} + {% for answer in response.answers %} +
+
+ Q{{ loop.index }} + {{ answer.question.questionText }} +
+ + {% if answer.question.questionType.value == 'text' or answer.question.questionType.value == 'textarea' %} +

{{ answer.answerText|default('-') }}

+ + {% elseif answer.question.questionType.value in ['single_choice', 'multiple_choice'] %} + {% if answer.selectedOptions|length > 0 %} +
    + {% for option in answer.selectedOptions %} +
  • {{ option.optionText }}
  • + {% endfor %} +
+ {% else %} +

{{ 'common.not_answered'|trans }}

+ {% endif %} + + {% elseif answer.question.questionType.value == 'tag_selection' %} + {% if answer.selectedTags|length > 0 %} +
+ {% for tag in answer.selectedTags %} + {{ tag.title }} + {% endfor %} +
+ {% else %} +

{{ 'common.not_answered'|trans }}

+ {% endif %} + + {% elseif answer.question.questionType.value == 'rating' %} +

+ {% if answer.answerText %} + {{ answer.answerText }} / 5 + {% for i in 1..(answer.answerText|number_format) %} + + {% endfor %} + {% else %} + {{ 'common.not_answered'|trans }} + {% endif %} +

+ {% endif %} +
+ {% endfor %} + {% else %} +

{{ 'survey.response.no_answers'|trans }}

+ {% endif %} +
+
+ + {# Organizer notes #} +
+
+
{{ 'survey.response.organizer_notes'|trans }}
+
+
+ {% if response.organizerNotes %} +

{{ response.organizerNotes }}

+ {% else %} +

{{ 'survey.response.no_notes'|trans }}

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/components/CookieConsent.html.twig b/templates/components/CookieConsent.html.twig index 719eaf7..f6c1ed3 100644 --- a/templates/components/CookieConsent.html.twig +++ b/templates/components/CookieConsent.html.twig @@ -40,7 +40,7 @@ - +