diff --git a/.sisyphus/notepads/cts-presenter-app/learnings.md b/.sisyphus/notepads/cts-presenter-app/learnings.md index 04f04ce..739ac33 100644 --- a/.sisyphus/notepads/cts-presenter-app/learnings.md +++ b/.sisyphus/notepads/cts-presenter-app/learnings.md @@ -120,3 +120,33 @@ ### Test Pattern - `$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 + +## [2026-03-01] T19: Song Preview Modal + PDF Download + +### SongPdfController +- Previous session left corrupted file: missing closing `}` for class and missing `use Illuminate\Http\Response` import +- Controller has two methods: `preview()` returns JSON (for Vue modal), `download()` returns PDF via barryvdh/laravel-dompdf +- `buildGroupsInOrder()` extracted as private helper used by both methods +- Route: `GET /songs/{song}/arrangements/{arrangement}/pdf` -> `songs.pdf` +- `abort_unless($arrangement->song_id === $song->id, 404)` prevents cross-song arrangement access + +### PDF Template +- **CRITICAL**: Old-school CSS only (NO Tailwind) — DomPDF cannot render utility classes +- DejaVu Sans font (`font-family: 'DejaVu Sans', sans-serif`) handles German umlauts correctly +- `page-break-inside: avoid` on `.group-section` keeps groups together across pages +- `white-space: pre-wrap` preserves line breaks in slide text content +- Copyright footer uses `border-top` separator, `font-size: 8pt`, muted color + +### SongPreviewModal.vue +- Teleport to body for z-index isolation +- Click-outside dismiss via `@click` on backdrop with `e.target === e.currentTarget` check +- Escape key listener added on mount, removed on unmount +- Groups sorted by `ag.order`, slides by `slide.order` in computed property +- Side-by-side translation display using `grid grid-cols-2 gap-4` when `useTranslation && slide.text_content_translated` +- PDF download link as `` with `target="_blank"` (not router navigation) + +### Tests (Pest style) +- 9 tests, 25 assertions — covers: content type, filename, groups in order, translations, copyright, 404 for wrong song, auth redirect, umlauts, empty arrangement +- DomPDF `download()` returns `Illuminate\Http\Response` with `Content-Disposition: attachment; filename=...` +- `assertHeader('Content-Type', 'application/pdf')` verifies PDF generation succeeded +- Content-Disposition header contains slugified `song.title` + `arrangement.name` diff --git a/app/Http/Controllers/SongPdfController.php b/app/Http/Controllers/SongPdfController.php index 2a215d0..0857d72 100644 --- a/app/Http/Controllers/SongPdfController.php +++ b/app/Http/Controllers/SongPdfController.php @@ -5,31 +5,37 @@ use App\Models\Song; use App\Models\SongArrangement; use Barryvdh\DomPDF\Facade\Pdf; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; class SongPdfController extends Controller { + public function preview(Song $song, SongArrangement $arrangement): JsonResponse + { + abort_unless($arrangement->song_id === $song->id, 404); + + $groupsInOrder = $this->buildGroupsInOrder($arrangement); + + return response()->json([ + 'song' => [ + 'id' => $song->id, + 'title' => $song->title, + 'copyright_text' => $song->copyright_text, + 'ccli_id' => $song->ccli_id, + ], + 'arrangement' => [ + 'id' => $arrangement->id, + 'name' => $arrangement->name, + ], + 'groups' => $groupsInOrder, + ]); + } + public function download(Song $song, SongArrangement $arrangement): Response { abort_unless($arrangement->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(), - ]; - }); + $groupsInOrder = $this->buildGroupsInOrder($arrangement); $pdf = Pdf::loadView('pdf.song', [ 'song' => $song, @@ -41,4 +47,25 @@ public function download(Song $song, SongArrangement $arrangement): Response return $pdf->download($filename); } + + private function buildGroupsInOrder(SongArrangement $arrangement): array + { + $arrangement->load([ + 'arrangementGroups' => fn ($query) => $query->orderBy('order'), + 'arrangementGroups.group.slides' => fn ($query) => $query->orderBy('order'), + ]); + + return $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(), + ]; + })->values()->all(); + } } diff --git a/resources/js/Components/SongPreviewModal.vue b/resources/js/Components/SongPreviewModal.vue new file mode 100644 index 0000000..8d1fbe9 --- /dev/null +++ b/resources/js/Components/SongPreviewModal.vue @@ -0,0 +1,404 @@ + + + + + + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 5a991c8..843e948 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ name('arrangements.update'); Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy'); + Route::get('/songs/{song}/arrangements/{arrangement}/pdf', [SongPdfController::class, 'download'])->name('songs.pdf'); + Route::get('/songs/{song}/arrangements/{arrangement}/preview', [SongPdfController::class, 'preview'])->name('songs.preview'); Route::post('/sync', [SyncController::class, 'sync'])->name('sync'); /* diff --git a/tests/Feature/SongPdfTest.php b/tests/Feature/SongPdfTest.php new file mode 100644 index 0000000..b9b5f8f --- /dev/null +++ b/tests/Feature/SongPdfTest.php @@ -0,0 +1,255 @@ +user = User::factory()->create(); +}); + +test('song pdf download returns pdf with correct content type', function () { + $song = Song::factory()->create(['title' => 'Amazing Grace']); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Normal', + ]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Verse 1', + 'color' => '#3B82F6', + 'order' => 1, + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => 'Amazing grace how sweet the sound', + ]); + + SongArrangementGroup::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $group->id, + 'order' => 1, + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); +}); + +test('song pdf contains song title in filename', function () { + $song = Song::factory()->create(['title' => 'Amazing Grace']); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Normal', + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $contentDisposition = $response->headers->get('Content-Disposition'); + expect($contentDisposition)->toContain('amazing-grace'); + expect($contentDisposition)->toContain('normal'); +}); + +test('song pdf includes arrangement groups in order', function () { + $song = Song::factory()->create(['title' => 'Großer Gott']); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Normal', + ]); + + $verse = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Strophe 1', + 'color' => '#3B82F6', + 'order' => 1, + ]); + + $chorus = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Refrain', + 'color' => '#10B981', + 'order' => 2, + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $verse->id, + 'order' => 1, + 'text_content' => 'Großer Gott wir loben dich', + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $chorus->id, + 'order' => 1, + 'text_content' => 'Heilig heilig heilig', + ]); + + // Arrangement: Strophe 1 -> Refrain + SongArrangementGroup::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $verse->id, + 'order' => 1, + ]); + + SongArrangementGroup::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $chorus->id, + 'order' => 2, + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); +}); + +test('song pdf includes translated text when present', function () { + $song = Song::factory()->create([ + 'title' => 'Amazing Grace', + 'has_translation' => true, + ]); + + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Normal', + ]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Verse 1', + 'order' => 1, + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => 'Amazing grace how sweet the sound', + 'text_content_translated' => 'Erstaunliche Gnade wie süß der Klang', + ]); + + SongArrangementGroup::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $group->id, + 'order' => 1, + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); +}); + +test('song pdf includes copyright footer', function () { + $song = Song::factory()->create([ + 'title' => 'Amazing Grace', + 'copyright_text' => 'John Newton, 1779', + 'ccli_id' => '22025', + ]); + + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Normal', + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); +}); + +test('song pdf returns 404 when arrangement does not belong to song', function () { + $song = Song::factory()->create(); + $otherSong = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $otherSong->id, + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertNotFound(); +}); + +test('song pdf requires authentication', function () { + $song = Song::factory()->create(); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + ]); + + $response = $this->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertRedirect(route('login')); +}); + +test('song pdf handles german umlauts correctly', function () { + $song = Song::factory()->create([ + 'title' => 'Großer Gott wir loben dich', + 'copyright_text' => 'Überliefert', + ]); + + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Übung', + ]); + + $group = SongGroup::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Strophe 1', + 'order' => 1, + ]); + + SongSlide::factory()->create([ + 'song_group_id' => $group->id, + 'order' => 1, + 'text_content' => 'Großer Gott wir loben dich', + ]); + + SongArrangementGroup::factory()->create([ + 'song_arrangement_id' => $arrangement->id, + 'song_group_id' => $group->id, + 'order' => 1, + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); + + // Filename should contain slug with umlauts handled + $contentDisposition = $response->headers->get('Content-Disposition'); + expect($contentDisposition)->toContain('.pdf'); +}); + +test('song pdf works with empty arrangement (no groups)', function () { + $song = Song::factory()->create(['title' => 'Empty Song']); + $arrangement = SongArrangement::factory()->create([ + 'song_id' => $song->id, + 'name' => 'Leer', + ]); + + $response = $this->actingAs($this->user) + ->get(route('songs.pdf', [$song, $arrangement])); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); +}); +