feat: T19 - Song Preview Modal + PDF Download

- SongPreviewModal.vue: Teleport modal with song text in arrangement order
  - Group headers with group.color background
  - Side-by-side translations when use_translation=true
  - Copyright footer from song metadata
  - Close button + click-outside/Escape dismiss
  - PDF download button

- SongPdfController.php: PDF generation endpoint
  - GET /songs/{song}/arrangements/{arrangement}/pdf
  - Uses barryvdh/laravel-dompdf with DejaVu Sans font
  - Old-school CSS only (NO Tailwind)
  - Handles German umlauts correctly

- resources/views/pdf/song.blade.php: PDF template
  - Title header, groups with colored headers, slide text, copyright footer
  - Includes translation text when present
  - Simple CSS: font-family, color, background-color, margin, padding

- Tests: 9 new (25 assertions)
  - PDF content type verification
  - Filename includes song title
  - Groups in correct arrangement order
  - Translation text included when present
  - Copyright footer present
  - German umlauts render correctly
  - Auth guard
  - 404 when arrangement doesn't belong to song
  - Empty arrangement handling

All tests passing: 133/133 (728 assertions)
Build: ✓ Vite production build successful
Fix: Removed duplicate content from SongPdfTest.php (parse error)
This commit is contained in:
Thorsten Bus 2026-03-01 20:15:02 +01:00
parent b2d230e991
commit d75d748417
5 changed files with 736 additions and 17 deletions

View file

@ -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 `<a>` 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`

View file

@ -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();
}
}

View file

@ -0,0 +1,404 @@
<script setup>
import { computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
song: {
type: Object,
required: true,
},
arrangement: {
type: Object,
required: true,
},
useTranslation: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
/**
* Groups in arrangement order: arrangement.groups contains the ordered
* arrangement_groups with nested group + slides data.
*/
const groupsInOrder = computed(() => {
if (!props.arrangement?.groups) {
return []
}
return props.arrangement.groups
.slice()
.sort((a, b) => a.order - b.order)
.map((ag) => {
const group = ag.group ?? ag
const slides = (group.slides ?? [])
.slice()
.sort((a, b) => a.order - b.order)
return {
name: group.name,
color: group.color ?? '#6b7280',
slides,
}
})
})
const pdfUrl = computed(() => {
if (!props.song?.id || !props.arrangement?.id) {
return '#'
}
return `/songs/${props.song.id}/arrangements/${props.arrangement.id}/pdf`
})
const closeOnEscape = (e) => {
if (e.key === 'Escape' && props.show) {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', closeOnEscape)
})
onUnmounted(() => {
document.removeEventListener('keydown', closeOnEscape)
})
const closeOnBackdrop = (e) => {
if (e.target === e.currentTarget) {
emit('close')
}
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
@click="closeOnBackdrop"
>
<div class="relative w-full max-w-3xl rounded-xl bg-white shadow-2xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<div>
<h2 class="text-xl font-bold text-gray-900">
{{ song.title }}
</h2>
<p class="text-sm text-gray-500">
Arrangement: {{ arrangement.name }}
</p>
</div>
<div class="flex items-center gap-3">
<a
:href="pdfUrl"
class="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-indigo-700"
target="_blank"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
PDF
</a>
<button
type="button"
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
@click="emit('close')"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Song Content -->
<div class="max-h-[70vh] overflow-y-auto px-6 py-4">
<div
v-for="(group, groupIndex) in groupsInOrder"
:key="`${groupIndex}-${group.name}`"
class="mb-5"
>
<!-- Group header with color -->
<div
class="mb-2 inline-block rounded-md px-3 py-1 text-sm font-bold text-white"
:style="{ backgroundColor: group.color }"
>
{{ group.name }}
</div>
<!-- Slides -->
<div
v-for="(slide, slideIndex) in group.slides"
:key="slideIndex"
class="mb-3 pl-3"
>
<template v-if="useTranslation && slide.text_content_translated">
<!-- Side-by-side: Original + Translation -->
<div class="grid grid-cols-2 gap-4">
<div class="whitespace-pre-wrap text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</div>
<div class="whitespace-pre-wrap border-l-2 border-gray-200 pl-4 text-sm italic leading-relaxed text-gray-500">{{ slide.text_content_translated }}</div>
</div>
</template>
<template v-else>
<div class="whitespace-pre-wrap text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</div>
</template>
</div>
</div>
<!-- Empty state -->
<div
v-if="groupsInOrder.length === 0"
class="py-12 text-center text-gray-400"
>
Keine Gruppen in diesem Arrangement vorhanden.
</div>
</div>
<!-- Copyright Footer -->
<div
v-if="song.copyright_text || song.ccli_id"
class="border-t border-gray-200 px-6 py-3 text-center text-xs text-gray-400"
>
<span v-if="song.copyright_text">&copy; {{ song.copyright_text }}</span>
<span v-if="song.copyright_text && song.ccli_id"> &middot; </span>
<span v-if="song.ccli_id">CCLI {{ song.ccli_id }}</span>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import Modal from '@/Components/Modal.vue'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
songId: {
type: Number,
default: null,
},
arrangementId: {
type: Number,
default: null,
},
useTranslation: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close'])
const loading = ref(false)
const error = ref(null)
const previewData = ref(null)
watch(
() => props.show,
async (isVisible) => {
if (!isVisible || !props.songId || !props.arrangementId) {
return
}
loading.value = true
error.value = null
try {
const response = await fetch(`/songs/${props.songId}/arrangements/${props.arrangementId}/preview`)
if (!response.ok) {
throw new Error('Vorschau konnte nicht geladen werden.')
}
previewData.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
},
)
const close = () => {
emit('close')
}
const contrastColor = (hexColor) => {
if (!hexColor) return '#ffffff'
const hex = hexColor.replace('#', '')
const r = parseInt(hex.substring(0, 2), 16)
const g = parseInt(hex.substring(2, 4), 16)
const b = parseInt(hex.substring(4, 6), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? '#1a1a1a' : '#ffffff'
}
</script>
<template>
<Modal
:show="show"
max-width="2xl"
@close="close"
>
<div class="p-6">
<!-- Loading -->
<div
v-if="loading"
class="flex items-center justify-center py-12"
>
<svg
class="h-8 w-8 animate-spin text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
/>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span class="ml-3 text-gray-600">Vorschau wird geladen</span>
</div>
<!-- Error -->
<div
v-else-if="error"
class="py-12 text-center"
>
<p class="text-red-600">{{ error }}</p>
<button
type="button"
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"
>
Schließen
</button>
</div>
<!-- Preview Content -->
<div v-else-if="previewData">
<!-- Header -->
<div class="mb-5 border-b-2 border-gray-300 pb-4 text-center">
<h2 class="text-2xl font-bold text-gray-900">
{{ previewData.song.title }}
</h2>
<p class="mt-1 text-sm text-gray-500">
Arrangement: {{ previewData.arrangement.name }}
</p>
</div>
<!-- Groups -->
<div
v-for="(group, groupIndex) in previewData.groups"
:key="groupIndex"
class="mb-5"
>
<div
class="mb-2 rounded px-3 py-1.5 text-sm font-bold"
:style="{
backgroundColor: group.color || '#6b7280',
color: contrastColor(group.color),
}"
>
{{ group.name }}
</div>
<div
v-for="(slide, slideIndex) in group.slides"
:key="slideIndex"
class="mb-3 pl-3"
>
<pre class="whitespace-pre-wrap font-sans text-sm leading-relaxed text-gray-800">{{ slide.text_content }}</pre>
<div
v-if="useTranslation && slide.text_content_translated"
class="mt-1 border-l-2 border-gray-300 pl-3"
>
<pre class="whitespace-pre-wrap font-sans text-sm italic leading-relaxed text-gray-500">{{ slide.text_content_translated }}</pre>
</div>
</div>
</div>
<!-- Copyright Footer -->
<div
v-if="previewData.song.copyright_text"
class="mt-6 border-t border-gray-200 pt-3 text-center text-xs text-gray-400"
>
&copy; {{ previewData.song.copyright_text }}
<span v-if="previewData.song.ccli_id">
&middot; CCLI {{ previewData.song.ccli_id }}
</span>
</div>
<!-- Close Button -->
<div class="mt-6 flex justify-end">
<button
type="button"
class="rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
@click="close"
>
Schließen
</button>
</div>
</div>
</div>
</Modal>
</template>

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\ArrangementController;
use App\Http\Controllers\ServiceController;
use App\Http\Controllers\SlideController;
@ -37,6 +38,8 @@
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->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');
/*

View file

@ -0,0 +1,255 @@
<?php
use App\Models\Song;
use App\Models\SongArrangement;
use App\Models\SongArrangementGroup;
use App\Models\SongGroup;
use App\Models\SongSlide;
use App\Models\User;
/*
|--------------------------------------------------------------------------
| Song PDF Download Tests
|--------------------------------------------------------------------------
*/
beforeEach(function () {
$this->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');
});