diff --git a/.sisyphus/notepads/cts-presenter-app/learnings.md b/.sisyphus/notepads/cts-presenter-app/learnings.md
index 05113be..04f04ce 100644
--- a/.sisyphus/notepads/cts-presenter-app/learnings.md
+++ b/.sisyphus/notepads/cts-presenter-app/learnings.md
@@ -14,3 +14,109 @@
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
+
+## [2026-03-01] Wave 2 Complete — T8-T13
+
+### T11: Arrangement Configurator
+- ArrangementController: store (clone from default), clone (duplicate existing), update (reorder groups), destroy (prevent last)
+- ArrangementConfigurator.vue: vue-draggable-plus with clone mode for group pills
+- **CRITICAL**: Vue key for repeating groups MUST be `${group.id}-${index}` NOT just `group.id` (groups repeat in arrangements)
+- Color picker integration: group_colors array in update payload, applied to SongGroup records
+- Default arrangement protection: if deleting is_default=true, promote next arrangement to default
+- Tests: 4 passing (17 assertions)
+
+### T12: Song Matching Service
+- SongMatchingService: autoMatch (CCLI), manualAssign, requestCreation (email), unassign
+- ServiceSongController: API endpoints for /api/service-songs/{id}/assign, /request, /unassign
+- Auto-match runs during CTS sync (ChurchToolsService updated to call autoMatch)
+- MissingSongRequest mailable already existed from T7, reused here
+- matched_at timestamp tracks when assignment occurred
+- Tests: 14 passing (33 assertions)
+
+### T13: Translation Service
+- TranslationService: fetchFromUrl (HTTP + strip_tags), importTranslation (line-count distribution), removeTranslation
+- TranslationController: POST /translation/fetch-url, POST /songs/{song}/translation/import, DELETE /songs/{song}/translation
+- Line-count algorithm: for each slide (ordered by group.order, slide.order), take N lines from translated text where N = original slide line count
+- Best-effort URL scraping: Http::timeout(10), catch all exceptions, return null on failure
+- has_translation flag on Song model tracks translation state
+- Tests: 18 passing (18 assertions)
+
+### Wave 2 Summary
+- All 6 tasks completed in parallel delegation
+- T11 timed out during polling but work completed successfully
+- Total: 103 tests passing (488 assertions)
+- Vite build: ✓ successful
+- Commit: d915f8c (27 files, +3951/-25 lines)
+
+### Next: Wave 3 (T14-T19)
+- Service Edit page layout + 4 blocks (Information, Moderation, Sermon, Songs)
+- Song preview modal + PDF download
+- All blocks integrate SlideUploader/SlideGrid from T10
+- ArrangementConfigurator from T11 embedded in Songs block
+
+## [2026-03-01] T16: Moderation Block (Service-Specific Slides)
+
+- ModerationBlock.vue: Simple wrapper around SlideUploader + SlideGrid with `showExpireDate=false`
+- Filtering: `type='moderation' AND service_id = current_service` via computed property
+- SlideUploader/SlideGrid already support `showExpireDate` prop (from T10)
+- Service-specific filtering ensures moderation slides from Service A don't appear in Service B
+- No expire_date field anywhere in UI (unlike Information block)
+- Tests: 5 passing (14 assertions) — verifies service-specific isolation
+- Vite build: ✓ successful
+
+### T17: Sermon Block (Service-Specific Slides)
+- SermonBlock.vue: Identical to ModerationBlock but with `type='sermon'`
+- Filters slides: `type='sermon' AND service_id = current_service`
+- Uses SlideUploader with `type="sermon"`, `serviceId={current}`, `showExpireDate={false}`
+- Uses SlideGrid with `showExpireDate={false}`
+- Tests: 5 passing (14 assertions) — verifies service-specific filtering, type isolation, no expire_date
+- Build: ✓ successful
+- 2026-03-01: Songs-Block nutzt fuer unmatched CTS-Songs eine lokale Such-Eingabe plus gefiltertes Select (Titel/CCLI), danach `POST /api/service-songs/{id}/assign` und soft reload nur von `serviceSongs`.
+- 2026-03-01: Arrangement-Auswahl wird ueber ein neues `arrangement-selected` Event aus dem ArrangementConfigurator nach oben gemeldet und per `PATCH /api/service-songs/{id}` als `song_arrangement_id` sofort gespeichert.
+- 2026-03-01: Translation-Toggle im Songs-Block speichert direkt per `PATCH /api/service-songs/{id}` (`use_translation`) und bleibt so ohne separaten Save-Button konsistent mit dem Auto-Save-Prinzip.
+
+## [2026-03-01] T15: Information Block (Slides + Expire Dates)
+
+- InformationBlock.vue: Wraps SlideUploader + SlideGrid with `showExpireDate=true` and `serviceId=null` (global slides)
+- Server-side filtering in ServiceController.edit(): `type='information' AND expire_date >= service.date AND (service_id IS NULL OR service_id = current)`
+- Information slides are GLOBAL — not tied to a specific service, appear in all services where `expire_date >= service.date`
+- The `whereNull('deleted_at')` in edit() query is redundant with SoftDeletes trait but harmless
+- Slides without expire_date are excluded from information block (require scheduling)
+- SlideUploader passes `serviceId=null` for information slides (they're global, not service-specific)
+- ExpiringSoonCount computed: badges warn when slides expire within 3 days of service date
+- Edit.vue updated: replaced placeholder for 'information' block key with actual InformationBlock component
+- `router.reload({ preserveScroll: true })` used for refreshing page after slide upload/delete/update
+- Tests: 7 passing (105 assertions) — covers expire date filtering, global visibility, soft-delete exclusion, type isolation, null expire_date, ordering
+- Full suite: 122 tests passing (658 assertions)
+- Vite build: ✓ successful
+
+
+## [2026-03-01] T14: Service Edit Page Layout + Routing
+
+### ServiceController::edit()
+- Eager-loads `serviceSongs` (ordered), `serviceSongs.song`, `serviceSongs.arrangement`, `slides`
+- Information slides query is complex: global (service_id=null) + service-specific, filtered by expire_date >= service.date
+- Moderation/sermon slides filtered from loaded `$service->slides` collection via `->where('type', ...)`
+- Service data returned as explicit array (not full model) to control frontend shape
+- serviceSongs mapped to include nested song/arrangement data as null-safe arrays
+
+### Edit.vue Page Pattern
+- Uses collapsible accordion blocks (expandedBlocks ref with boolean per key)
+- 4 blocks: Information, Moderation, Predigt, Songs — each with colored gradient icon, badge count, chevron toggle
+- Block content area is placeholder div (T15-T18 will replace with actual components)
+- Vue Transition with max-h trick for collapse animation
+- `router.get(route('services.index'))` for back navigation
+- German date format: `toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' })`
+
+### Index.vue Integration
+- "Bearbeiten" button now uses `router.get(route('services.edit', service.id))` instead of showComingSoon
+
+### Route
+- `GET /services/{service}/edit` → `services.edit` named route, uses implicit model binding
+- Placed after finalize/reopen POST routes in web.php
+
+### Test Pattern
+- PHPUnit style (not Pest) in this project's ServiceControllerTest
+- `$this->withoutVite()` required for Inertia page assertion tests
+- `assertInertia(fn ($page) => $page->component(...)->has(...)->where(...))` for deep prop assertions
+- Auth test: unauthenticated GET redirects to login route
diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php
index 3d30237..32478b6 100644
--- a/app/Http/Controllers/ServiceController.php
+++ b/app/Http/Controllers/ServiceController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Models\Service;
+use App\Models\Song;
use App\Models\Slide;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
@@ -60,6 +61,120 @@ public function index(): Response
]);
}
+ public function edit(Service $service): Response
+ {
+ $service->load([
+ 'serviceSongs' => fn ($query) => $query->orderBy('order'),
+ 'serviceSongs.song',
+ 'serviceSongs.song.groups',
+ 'serviceSongs.song.arrangements.arrangementGroups.group',
+ 'serviceSongs.arrangement',
+ 'slides',
+ ]);
+
+ $songsCatalog = Song::query()
+ ->orderBy('title')
+ ->get(['id', 'title', 'ccli_id', 'has_translation'])
+ ->map(fn (Song $song) => [
+ 'id' => $song->id,
+ 'title' => $song->title,
+ 'ccli_id' => $song->ccli_id,
+ 'has_translation' => $song->has_translation,
+ ])
+ ->values();
+
+ $informationSlides = Slide::query()
+ ->where('type', 'information')
+ ->whereNull('deleted_at')
+ ->where(function ($query) use ($service) {
+ $query
+ ->whereNull('service_id')
+ ->orWhere('service_id', $service->id);
+ })
+ ->when(
+ $service->date,
+ fn ($query) => $query
+ ->whereNotNull('expire_date')
+ ->whereDate('expire_date', '>=', $service->date)
+ )
+ ->orderByDesc('uploaded_at')
+ ->get();
+
+ $moderationSlides = $service->slides
+ ->where('type', 'moderation')
+ ->sortByDesc('uploaded_at')
+ ->values();
+
+ $sermonSlides = $service->slides
+ ->where('type', 'sermon')
+ ->sortByDesc('uploaded_at')
+ ->values();
+
+ return Inertia::render('Services/Edit', [
+ 'service' => [
+ 'id' => $service->id,
+ 'title' => $service->title,
+ 'date' => $service->date?->toDateString(),
+ 'preacher_name' => $service->preacher_name,
+ 'beamer_tech_name' => $service->beamer_tech_name,
+ 'finalized_at' => $service->finalized_at?->toJSON(),
+ 'last_synced_at' => $service->last_synced_at?->toJSON(),
+ ],
+ 'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
+ 'id' => $ss->id,
+ 'order' => $ss->order,
+ 'cts_song_name' => $ss->cts_song_name,
+ 'cts_ccli_id' => $ss->cts_ccli_id,
+ 'use_translation' => $ss->use_translation,
+ 'song_id' => $ss->song_id,
+ 'song_arrangement_id' => $ss->song_arrangement_id,
+ 'matched_at' => $ss->matched_at?->toJSON(),
+ 'request_sent_at' => $ss->request_sent_at?->toJSON(),
+ 'song' => $ss->song ? [
+ 'id' => $ss->song->id,
+ 'title' => $ss->song->title,
+ 'ccli_id' => $ss->song->ccli_id,
+ 'has_translation' => $ss->song->has_translation,
+ 'groups' => $ss->song->groups
+ ->sortBy('order')
+ ->values()
+ ->map(fn ($group) => [
+ 'id' => $group->id,
+ 'name' => $group->name,
+ 'color' => $group->color,
+ 'order' => $group->order,
+ ]),
+ 'arrangements' => $ss->song->arrangements
+ ->sortBy(fn ($arrangement) => $arrangement->is_default ? 0 : 1)
+ ->values()
+ ->map(fn ($arrangement) => [
+ 'id' => $arrangement->id,
+ 'name' => $arrangement->name,
+ 'is_default' => $arrangement->is_default,
+ 'groups' => $arrangement->arrangementGroups
+ ->sortBy('order')
+ ->values()
+ ->map(fn ($arrangementGroup) => [
+ 'id' => $arrangementGroup->group?->id,
+ 'name' => $arrangementGroup->group?->name,
+ 'color' => $arrangementGroup->group?->color,
+ ])
+ ->filter(fn ($group) => $group['id'] !== null)
+ ->values(),
+ ]),
+ ] : null,
+ 'arrangement' => $ss->arrangement ? [
+ 'id' => $ss->arrangement->id,
+ 'name' => $ss->arrangement->name,
+ ] : null,
+ ]),
+ 'informationSlides' => $informationSlides,
+ 'moderationSlides' => $moderationSlides,
+ 'sermonSlides' => $sermonSlides,
+ 'songsCatalog' => $songsCatalog,
+ ]);
+ }
+
public function finalize(Service $service): RedirectResponse
{
$service->update([
diff --git a/app/Http/Controllers/ServiceSongController.php b/app/Http/Controllers/ServiceSongController.php
index 99e11d1..3b8caa1 100644
--- a/app/Http/Controllers/ServiceSongController.php
+++ b/app/Http/Controllers/ServiceSongController.php
@@ -64,4 +64,40 @@ public function unassign(int $serviceSongId): JsonResponse
'service_song' => $serviceSong->fresh(),
]);
}
+
+ public function update(int $serviceSongId, Request $request): JsonResponse
+ {
+ $validated = $request->validate([
+ 'song_arrangement_id' => ['nullable', 'integer', 'exists:song_arrangements,id'],
+ 'use_translation' => ['sometimes', 'boolean'],
+ ]);
+
+ $serviceSong = ServiceSong::with('song')->findOrFail($serviceSongId);
+
+ if (array_key_exists('song_arrangement_id', $validated) && $validated['song_arrangement_id'] !== null) {
+ if ($serviceSong->song_id === null || $serviceSong->song === null) {
+ return response()->json([
+ 'message' => 'Arrangement kann ohne zugeordneten Song nicht gespeichert werden.',
+ ], 422);
+ }
+
+ $isValidArrangement = $serviceSong->song
+ ->arrangements()
+ ->whereKey($validated['song_arrangement_id'])
+ ->exists();
+
+ if (! $isValidArrangement) {
+ return response()->json([
+ 'message' => 'Das Arrangement gehoert nicht zu diesem Song.',
+ ], 422);
+ }
+ }
+
+ $serviceSong->update($validated);
+
+ return response()->json([
+ 'message' => 'Service-Song wurde aktualisiert.',
+ 'service_song' => $serviceSong->fresh(),
+ ]);
+ }
}
diff --git a/app/Http/Controllers/SongPdfController.php b/app/Http/Controllers/SongPdfController.php
new file mode 100644
index 0000000..2a215d0
--- /dev/null
+++ b/app/Http/Controllers/SongPdfController.php
@@ -0,0 +1,44 @@
+song_id === $song->id, 404);
+
+ $arrangement->load([
+ 'arrangementGroups' => fn ($query) => $query->orderBy('order'),
+ 'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'),
+ ]);
+
+ $groupsInOrder = $arrangement->arrangementGroups->map(function ($arrangementGroup) {
+ $group = $arrangementGroup->group;
+
+ return [
+ 'name' => $group->name,
+ 'color' => $group->color ?? '#6b7280',
+ 'slides' => $group->slides->map(fn ($slide) => [
+ 'text_content' => $slide->text_content,
+ 'text_content_translated' => $slide->text_content_translated,
+ ])->values()->all(),
+ ];
+ });
+
+ $pdf = Pdf::loadView('pdf.song', [
+ 'song' => $song,
+ 'arrangement' => $arrangement,
+ 'groupsInOrder' => $groupsInOrder,
+ ]);
+
+ $filename = str($song->title)->slug() . '-' . str($arrangement->name)->slug() . '.pdf';
+
+ return $pdf->download($filename);
+ }
+}
diff --git a/composer.json b/composer.json
index e5ffa72..4d33dca 100644
--- a/composer.json
+++ b/composer.json
@@ -8,6 +8,7 @@
"require": {
"php": "^8.2",
"5pm-hdh/churchtools-api": "^2.1",
+ "barryvdh/laravel-dompdf": "^3.1",
"inertiajs/inertia-laravel": "^2.0",
"intervention/image": "^3.11",
"laravel/framework": "^12.0",
diff --git a/composer.lock b/composer.lock
index cd6a9e7..a4af78b 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": "f9b57143fc4f1ac36c3d47c263788518",
+ "content-hash": "60fa6caea53a668cea289b6864429e5f",
"packages": [
{
"name": "5pm-hdh/churchtools-api",
@@ -54,6 +54,83 @@
},
"time": "2024-08-01T06:09:22+00:00"
},
+ {
+ "name": "barryvdh/laravel-dompdf",
+ "version": "v3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/barryvdh/laravel-dompdf.git",
+ "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
+ "reference": "8e71b99fc53bb8eb77f316c3c452dd74ab7cb25d",
+ "shasum": ""
+ },
+ "require": {
+ "dompdf/dompdf": "^3.0",
+ "illuminate/support": "^9|^10|^11|^12",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "larastan/larastan": "^2.7|^3.0",
+ "orchestra/testbench": "^7|^8|^9|^10",
+ "phpro/grumphp": "^2.5",
+ "squizlabs/php_codesniffer": "^3.5"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
+ "Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
+ },
+ "providers": [
+ "Barryvdh\\DomPDF\\ServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Barryvdh\\DomPDF\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Barry vd. Heuvel",
+ "email": "barryvdh@gmail.com"
+ }
+ ],
+ "description": "A DOMPDF Wrapper for Laravel",
+ "keywords": [
+ "dompdf",
+ "laravel",
+ "pdf"
+ ],
+ "support": {
+ "issues": "https://github.com/barryvdh/laravel-dompdf/issues",
+ "source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://fruitcake.nl",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/barryvdh",
+ "type": "github"
+ }
+ ],
+ "time": "2025-02-13T15:07:54+00:00"
+ },
{
"name": "brick/math",
"version": "0.14.8",
@@ -525,6 +602,161 @@
],
"time": "2024-02-05T11:56:58+00:00"
},
+ {
+ "name": "dompdf/dompdf",
+ "version": "v3.1.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/dompdf.git",
+ "reference": "db712c90c5b9868df3600e64e68da62e78a34623"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/dompdf/zipball/db712c90c5b9868df3600e64e68da62e78a34623",
+ "reference": "db712c90c5b9868df3600e64e68da62e78a34623",
+ "shasum": ""
+ },
+ "require": {
+ "dompdf/php-font-lib": "^1.0.0",
+ "dompdf/php-svg-lib": "^1.0.0",
+ "ext-dom": "*",
+ "ext-mbstring": "*",
+ "masterminds/html5": "^2.0",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "ext-gd": "*",
+ "ext-json": "*",
+ "ext-zip": "*",
+ "mockery/mockery": "^1.3",
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "^3.5",
+ "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
+ },
+ "suggest": {
+ "ext-gd": "Needed to process images",
+ "ext-gmagick": "Improves image processing performance",
+ "ext-imagick": "Improves image processing performance",
+ "ext-zlib": "Needed for pdf stream compression"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Dompdf\\": "src/"
+ },
+ "classmap": [
+ "lib/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1"
+ ],
+ "authors": [
+ {
+ "name": "The Dompdf Community",
+ "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
+ "homepage": "https://github.com/dompdf/dompdf",
+ "support": {
+ "issues": "https://github.com/dompdf/dompdf/issues",
+ "source": "https://github.com/dompdf/dompdf/tree/v3.1.4"
+ },
+ "time": "2025-10-29T12:43:30+00:00"
+ },
+ {
+ "name": "dompdf/php-font-lib",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/php-font-lib.git",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "FontLib\\": "src/FontLib"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-2.1-or-later"
+ ],
+ "authors": [
+ {
+ "name": "The FontLib Community",
+ "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "A library to read, parse, export and make subsets of different types of font files.",
+ "homepage": "https://github.com/dompdf/php-font-lib",
+ "support": {
+ "issues": "https://github.com/dompdf/php-font-lib/issues",
+ "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
+ },
+ "time": "2026-01-20T14:10:26+00:00"
+ },
+ {
+ "name": "dompdf/php-svg-lib",
+ "version": "1.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/dompdf/php-svg-lib.git",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-mbstring": "*",
+ "php": "^7.1 || ^8.0",
+ "sabberworm/php-css-parser": "^8.4 || ^9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Svg\\": "src/Svg"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "The SvgLib Community",
+ "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
+ }
+ ],
+ "description": "A library to read, parse and export to PDF SVG files.",
+ "homepage": "https://github.com/dompdf/php-svg-lib",
+ "support": {
+ "issues": "https://github.com/dompdf/php-svg-lib/issues",
+ "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
+ },
+ "time": "2026-01-02T16:01:13+00:00"
+ },
{
"name": "dragonmantank/cron-expression",
"version": "v3.6.0",
@@ -2655,6 +2887,73 @@
],
"time": "2026-01-15T06:54:53+00:00"
},
+ {
+ "name": "masterminds/html5",
+ "version": "2.10.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Masterminds/html5-php.git",
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
+ "reference": "fcf91eb64359852f00d921887b219479b4f21251",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.7-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Masterminds\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Matt Butcher",
+ "email": "technosophos@gmail.com"
+ },
+ {
+ "name": "Matt Farina",
+ "email": "matt@mattfarina.com"
+ },
+ {
+ "name": "Asmir Mustafic",
+ "email": "goetas@gmail.com"
+ }
+ ],
+ "description": "An HTML5 parser and serializer.",
+ "homepage": "http://masterminds.github.io/html5-php",
+ "keywords": [
+ "HTML5",
+ "dom",
+ "html",
+ "parser",
+ "querypath",
+ "serializer",
+ "xml"
+ ],
+ "support": {
+ "issues": "https://github.com/Masterminds/html5-php/issues",
+ "source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
+ },
+ "time": "2025-07-25T09:04:22+00:00"
+ },
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4159,6 +4458,86 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
+ {
+ "name": "sabberworm/php-css-parser",
+ "version": "v9.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
+ "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/59373045e11ad47b5c18fc615feee0219e42f6d3",
+ "reference": "59373045e11ad47b5c18fc615feee0219e42f6d3",
+ "shasum": ""
+ },
+ "require": {
+ "ext-iconv": "*",
+ "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+ "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "1.4.0",
+ "phpstan/extension-installer": "1.4.3",
+ "phpstan/phpstan": "1.12.32 || 2.1.32",
+ "phpstan/phpstan-phpunit": "1.4.2 || 2.0.8",
+ "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.7",
+ "phpunit/phpunit": "8.5.52",
+ "rawr/phpunit-data-provider": "3.3.1",
+ "rector/rector": "1.2.10 || 2.2.8",
+ "rector/type-perfect": "1.0.0 || 2.1.0",
+ "squizlabs/php_codesniffer": "4.0.1",
+ "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.1"
+ },
+ "suggest": {
+ "ext-mbstring": "for parsing UTF-8 CSS"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "9.3.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Rule/Rule.php",
+ "src/RuleSet/RuleContainer.php"
+ ],
+ "psr-4": {
+ "Sabberworm\\CSS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Raphael Schweikert"
+ },
+ {
+ "name": "Oliver Klee",
+ "email": "github@oliverklee.de"
+ },
+ {
+ "name": "Jake Hotson",
+ "email": "jake.github@qzdesign.co.uk"
+ }
+ ],
+ "description": "Parser for CSS Files written in PHP",
+ "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
+ "keywords": [
+ "css",
+ "parser",
+ "stylesheet"
+ ],
+ "support": {
+ "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
+ "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.2.0"
+ },
+ "time": "2026-02-21T17:12:03+00:00"
+ },
{
"name": "spatie/pdf-to-image",
"version": "1.2.2",
@@ -6711,6 +7090,149 @@
],
"time": "2026-02-15T10:53:20+00:00"
},
+ {
+ "name": "thecodingmachine/safe",
+ "version": "v3.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thecodingmachine/safe.git",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "php-parallel-lint/php-parallel-lint": "^1.4",
+ "phpstan/phpstan": "^2",
+ "phpunit/phpunit": "^10",
+ "squizlabs/php_codesniffer": "^3.2"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "lib/special_cases.php",
+ "generated/apache.php",
+ "generated/apcu.php",
+ "generated/array.php",
+ "generated/bzip2.php",
+ "generated/calendar.php",
+ "generated/classobj.php",
+ "generated/com.php",
+ "generated/cubrid.php",
+ "generated/curl.php",
+ "generated/datetime.php",
+ "generated/dir.php",
+ "generated/eio.php",
+ "generated/errorfunc.php",
+ "generated/exec.php",
+ "generated/fileinfo.php",
+ "generated/filesystem.php",
+ "generated/filter.php",
+ "generated/fpm.php",
+ "generated/ftp.php",
+ "generated/funchand.php",
+ "generated/gettext.php",
+ "generated/gmp.php",
+ "generated/gnupg.php",
+ "generated/hash.php",
+ "generated/ibase.php",
+ "generated/ibmDb2.php",
+ "generated/iconv.php",
+ "generated/image.php",
+ "generated/imap.php",
+ "generated/info.php",
+ "generated/inotify.php",
+ "generated/json.php",
+ "generated/ldap.php",
+ "generated/libxml.php",
+ "generated/lzf.php",
+ "generated/mailparse.php",
+ "generated/mbstring.php",
+ "generated/misc.php",
+ "generated/mysql.php",
+ "generated/mysqli.php",
+ "generated/network.php",
+ "generated/oci8.php",
+ "generated/opcache.php",
+ "generated/openssl.php",
+ "generated/outcontrol.php",
+ "generated/pcntl.php",
+ "generated/pcre.php",
+ "generated/pgsql.php",
+ "generated/posix.php",
+ "generated/ps.php",
+ "generated/pspell.php",
+ "generated/readline.php",
+ "generated/rnp.php",
+ "generated/rpminfo.php",
+ "generated/rrd.php",
+ "generated/sem.php",
+ "generated/session.php",
+ "generated/shmop.php",
+ "generated/sockets.php",
+ "generated/sodium.php",
+ "generated/solr.php",
+ "generated/spl.php",
+ "generated/sqlsrv.php",
+ "generated/ssdeep.php",
+ "generated/ssh2.php",
+ "generated/stream.php",
+ "generated/strings.php",
+ "generated/swoole.php",
+ "generated/uodbc.php",
+ "generated/uopz.php",
+ "generated/url.php",
+ "generated/var.php",
+ "generated/xdiff.php",
+ "generated/xml.php",
+ "generated/xmlrpc.php",
+ "generated/yaml.php",
+ "generated/yaz.php",
+ "generated/zip.php",
+ "generated/zlib.php"
+ ],
+ "classmap": [
+ "lib/DateTime.php",
+ "lib/DateTimeImmutable.php",
+ "lib/Exceptions/",
+ "generated/Exceptions/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "PHP core functions that throw exceptions instead of returning FALSE on error",
+ "support": {
+ "issues": "https://github.com/thecodingmachine/safe/issues",
+ "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/OskarStark",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/shish",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/silasjoisten",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/staabm",
+ "type": "github"
+ }
+ ],
+ "time": "2026-02-04T18:08:13+00:00"
+ },
{
"name": "tightenco/ziggy",
"version": "v2.6.1",
diff --git a/resources/js/Components/ArrangementConfigurator.vue b/resources/js/Components/ArrangementConfigurator.vue
index cba7e10..eb7d3f5 100644
--- a/resources/js/Components/ArrangementConfigurator.vue
+++ b/resources/js/Components/ArrangementConfigurator.vue
@@ -22,6 +22,8 @@ const props = defineProps({
},
})
+const emit = defineEmits(['arrangement-selected'])
+
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
)
@@ -49,6 +51,13 @@ watch(
{ immediate: true },
)
+watch(
+ selectedId,
+ (arrangementId) => {
+ emit('arrangement-selected', Number(arrangementId))
+ },
+)
+
const colorMap = computed(() => {
const map = {}
diff --git a/resources/js/Components/Blocks/InformationBlock.vue b/resources/js/Components/Blocks/InformationBlock.vue
new file mode 100644
index 0000000..942bd24
--- /dev/null
+++ b/resources/js/Components/Blocks/InformationBlock.vue
@@ -0,0 +1,127 @@
+
+
+
+
+ Globale Folien — sichtbar in allen Gottesdiensten bis zum Ablaufdatum
+
+ Folien für diesen Gottesdienst
+
+ Folien für diesen Gottesdienst
+
+ Song {{ serviceSong.order }}
+
+ Dieser CTS-Song ist noch nicht in der Song-DB verknuepft.
+
+ Anfrage gesendet am {{ formatDateTime(serviceSong.request_sent_at) }}
+
+ Zugeordnet zu:
+
+ {{ serviceSong.song?.title }}
+
+
+ Fuer diesen Service sind aktuell keine Songs vorhanden.
+
+ {{ formattedDate }}
+
+ ·
+ {{ service.preacher_name }}
+
+
+ {{ block.label }}-Block wird hier angezeigt
+
+ Platzhalter — Komponente folgt
+
+ Informationsfolien
+
+
+ Moderationsfolien
+
+
+ Predigtfolien
+
+
+ {{ serviceSong.cts_song_name || '-' }}
+
+
+ {{ service.title }}
+
+