feat(export): add probundle export for service slide blocks

- Create ProBundleExportService with generateBundle() method
- Generate flat ZIP with .pro file + image files at root level
- Add downloadBundle() method to ServiceController
- Add services.download-bundle route
- Add .probundle download buttons to Information, Moderation, Sermon blocks
- Add 3 tests verifying ZIP structure and validation
- All tests pass (206/206, 1129 assertions)
This commit is contained in:
Thorsten Bus 2026-03-02 22:18:33 +01:00
parent fefa761748
commit 5b35afb31d
7 changed files with 548 additions and 73 deletions

View file

@ -3,14 +3,17 @@
namespace App\Http\Controllers;
use App\Models\Service;
use App\Models\Song;
use App\Models\Slide;
use App\Models\Song;
use App\Services\ProBundleExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ServiceController extends Controller
{
@ -41,13 +44,21 @@ public function index(): Response
'info_slides_count' => Slide::query()
->selectRaw('COUNT(*)')
->where('slides.type', 'information')
->whereNull('slides.deleted_at')
->where(function ($query) {
$query
->whereNull('slides.service_id')
->orWhereColumn('slides.service_id', 'services.id');
})
->whereNotNull('slides.expire_date')
->whereColumn('slides.expire_date', '>=', 'services.date'),
->where(function ($query) {
$query->whereNull('slides.expire_date')
->orWhereColumn('slides.expire_date', '>=', 'services.date');
})
->when(
! $archived,
fn ($q) => $q
->whereColumn(DB::raw('DATE(slides.uploaded_at)'), '<=', 'services.date')
),
])
->get()
->map(fn (Service $service) => [
@ -107,11 +118,14 @@ public function edit(Service $service): Response
->when(
$service->date,
fn ($query) => $query
->where(function ($q) use ($service) {
$q->whereNull('expire_date')
->orWhereDate('expire_date', '>=', $service->date);
})
->whereDate('uploaded_at', '<=', $service->date)
->where(function ($q) use ($service) {
$q->whereNull('expire_date')
->orWhereDate('expire_date', '>=', $service->date);
})
->when(
$service->date->isFuture() || $service->date->isToday(),
fn ($q) => $q->whereDate('uploaded_at', '<=', $service->date)
)
)
->orderByDesc('uploaded_at')
->get();
@ -126,6 +140,14 @@ public function edit(Service $service): Response
->sortByDesc('uploaded_at')
->values();
$prevService = Service::where('date', '<', $service->date->toDateString())
->orderByDesc('date')
->first(['id', 'title', 'date']);
$nextService = Service::where('date', '>', $service->date->toDateString())
->orderBy('date')
->first(['id', 'title', 'date']);
return Inertia::render('Services/Edit', [
'service' => [
'id' => $service->id,
@ -188,6 +210,16 @@ public function edit(Service $service): Response
'moderationSlides' => $moderationSlides,
'sermonSlides' => $sermonSlides,
'songsCatalog' => $songsCatalog,
'prevService' => $prevService ? [
'id' => $prevService->id,
'title' => $prevService->title,
'date' => $prevService->date?->toDateString(),
] : null,
'nextService' => $nextService ? [
'id' => $nextService->id,
'title' => $nextService->title,
'date' => $nextService->date?->toDateString(),
] : null,
]);
}
@ -257,4 +289,22 @@ public function download(Service $service): JsonResponse|BinaryFileResponse
return response()->json(['message' => $e->getMessage()], 422);
}
}
public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse
{
$request->merge(['blockType' => $blockType]);
$request->validate([
'blockType' => 'required|in:information,moderation,sermon',
]);
$bundlePath = app(ProBundleExportService::class)->generateBundle($service, $blockType);
return response()
->download(
$bundlePath,
"{$service->id}_{$blockType}.probundle",
['Content-Type' => 'application/zip']
)
->deleteFileAfterSend(true);
}
}

View file

@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\Service;
use InvalidArgumentException;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ZipArchive;
class ProBundleExportService
{
private const ALLOWED_BLOCK_TYPES = ['information', 'moderation', 'sermon'];
public function generateBundle(Service $service, string $blockType): string
{
if (! in_array($blockType, self::ALLOWED_BLOCK_TYPES, true)) {
throw new InvalidArgumentException('Ungültiger Blocktyp für .probundle Export.');
}
$slides = $service->slides()
->where('type', $blockType)
->orderBy('sort_order')
->get();
$tempDir = sys_get_temp_dir().'/probundle-export-'.uniqid();
mkdir($tempDir, 0755, true);
$groupName = ucfirst($blockType);
$slideData = [];
$copiedImagePaths = [];
foreach ($slides as $slide) {
$sourcePath = Storage::disk('public')->path('slides/'.$slide->stored_filename);
if (! file_exists($sourcePath)) {
continue;
}
$imageFilename = basename($slide->stored_filename);
$tempImagePath = $tempDir.'/'.$imageFilename;
copy($sourcePath, $tempImagePath);
$copiedImagePaths[] = $tempImagePath;
$slideData[] = [
'text' => $slide->original_filename ?? '',
'media' => $imageFilename,
'format' => 'JPG',
];
}
$groups = [
[
'name' => $groupName,
'color' => [0, 0, 0, 1],
'slides' => $slideData,
],
];
$arrangements = [
[
'name' => 'normal',
'groupNames' => [$groupName],
],
];
$proFilePath = $tempDir.'/'.$blockType.'.pro';
ProFileGenerator::generateAndWrite($proFilePath, $groupName, $groups, $arrangements);
$bundlePath = sys_get_temp_dir().'/'.uniqid($blockType.'-').'.probundle';
$zip = new ZipArchive();
$openResult = $zip->open($bundlePath, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($openResult !== true) {
$this->deleteDirectory($tempDir);
throw new InvalidArgumentException('Konnte .probundle nicht erstellen.');
}
$zip->addFile($proFilePath, basename($proFilePath));
foreach ($copiedImagePaths as $imagePath) {
$zip->addFile($imagePath, basename($imagePath));
}
$zip->close();
$this->deleteDirectory($tempDir);
return $bundlePath;
}
private function deleteDirectory(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$entries = scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir.'/'.$entry;
if (is_dir($path)) {
$this->deleteDirectory($path);
} else {
@unlink($path);
}
}
@rmdir($dir);
}
}

View file

@ -1,8 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue'
import SlideGrid from '@/Components/SlideGrid.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({
serviceId: {
@ -40,6 +42,10 @@ const expiringSoonCount = computed(() => {
}).length
})
// Delete all state (#2)
const confirmingDeleteAll = ref(false)
const deletingAll = ref(false)
function handleSlideUploaded() {
emit('slides-updated')
}
@ -51,57 +57,69 @@ function handleSlideDeleted() {
function handleSlideUpdated() {
emit('slides-updated')
}
function confirmDeleteAll() {
deletingAll.value = true
axios.delete(route('slides.bulk-destroy'), {
data: { type: 'information', service_id: null },
})
.then(() => {
confirmingDeleteAll.value = false
deletingAll.value = false
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deletingAll.value = false
})
}
function downloadBundle() {
window.location.href = route('services.download-bundle', {
service: props.serviceId,
blockType: 'information',
})
}
</script>
<template>
<div data-testid="information-block" class="information-block space-y-6">
<!-- Block header -->
<div class="border-b border-amber-200/60 pb-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<!-- Info icon with warm glow -->
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-amber-50 to-orange-50 ring-1 ring-amber-200/60">
<svg class="h-5 w-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">
Informationsfolien
</h3>
<p class="mt-0.5 text-sm text-gray-500">
Globale Folien sichtbar in allen Gottesdiensten bis zum Ablaufdatum
</p>
</div>
</div>
<div data-testid="information-block" class="information-block space-y-4">
<!-- Block header with badges and delete-all (#2) -->
<div class="flex items-center justify-end gap-2">
<span
v-if="informationSlides.length > 0"
class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 ring-1 ring-amber-200/80"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
{{ informationSlides.length }} {{ informationSlides.length === 1 ? 'Folie' : 'Folien' }}
</span>
<!-- Slide count badges -->
<div class="flex items-center gap-2">
<span
v-if="informationSlides.length > 0"
class="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-700 ring-1 ring-amber-200/80"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
{{ informationSlides.length }} {{ informationSlides.length === 1 ? 'Folie' : 'Folien' }}
</span>
<span
v-if="expiringSoonCount > 0"
class="inline-flex items-center gap-1 rounded-full bg-orange-50 px-2.5 py-1 text-xs font-semibold text-orange-700 ring-1 ring-orange-300/80"
:title="`${expiringSoonCount} Folie(n) laufen bald ab`"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{{ expiringSoonCount }} l&auml;uft bald ab
</span>
<span
v-if="expiringSoonCount > 0"
class="inline-flex items-center gap-1 rounded-full bg-orange-50 px-2.5 py-1 text-xs font-semibold text-orange-700 ring-1 ring-orange-300/80"
:title="`${expiringSoonCount} Folie(n) laufen bald ab`"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
{{ expiringSoonCount }} läuft bald ab
</span>
</div>
</div>
<!-- Delete all button (#2) -->
<button
v-if="informationSlides.length > 0"
@click="confirmingDeleteAll = true"
class="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition hover:bg-red-50 hover:text-red-600"
title="Alle Folien l&ouml;schen"
>
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
<!-- Slide grid with inline upload card -->
<SlideGrid
data-testid="information-block-grid"
:slides="informationSlides"
@ -122,6 +140,30 @@ function handleSlideUpdated() {
/>
</template>
</SlideGrid>
<button
v-if="informationSlides.length > 0"
@click="downloadBundle"
class="inline-flex items-center rounded-md border border-transparent bg-gray-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
data-testid="download-probundle-button-information"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
.probundle herunterladen
</button>
<!-- Delete all confirmation (#2) -->
<ConfirmDialog
:show="confirmingDeleteAll"
title="Alle Folien l&ouml;schen?"
message="M&ouml;chtest du wirklich alle Informationsfolien l&ouml;schen?"
confirm-label="Alle l&ouml;schen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDeleteAll"
@cancel="confirmingDeleteAll = false"
/>
</div>
</template>

View file

@ -1,7 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue'
import SlideGrid from '@/Components/SlideGrid.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({
serviceId: {
@ -23,6 +26,10 @@ const moderationSlides = computed(() => {
)
})
// Delete all state (#2)
const confirmingDeleteAll = ref(false)
const deletingAll = ref(false)
function handleSlideUploaded() {
emit('slides-updated')
}
@ -34,21 +41,58 @@ function handleSlideDeleted() {
function handleSlideUpdated() {
emit('slides-updated')
}
function confirmDeleteAll() {
deletingAll.value = true
axios.delete(route('slides.bulk-destroy'), {
data: { type: 'moderation', service_id: props.serviceId },
})
.then(() => {
confirmingDeleteAll.value = false
deletingAll.value = false
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deletingAll.value = false
})
}
function downloadBundle() {
window.location.href = route('services.download-bundle', {
service: props.serviceId,
blockType: 'moderation',
})
}
</script>
<template>
<div data-testid="moderation-block" class="moderation-block space-y-6">
<!-- Block header -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900">
Moderationsfolien
</h3>
<p class="mt-1 text-sm text-gray-500">
Folien für diesen Gottesdienst
</p>
<div data-testid="moderation-block" class="moderation-block space-y-4">
<!-- Block header with badge and delete-all (#2) -->
<div class="flex items-center justify-end gap-2">
<span
v-if="moderationSlides.length > 0"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200/80"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
{{ moderationSlides.length }} {{ moderationSlides.length === 1 ? 'Folie' : 'Folien' }}
</span>
<!-- Delete all button (#2) -->
<button
v-if="moderationSlides.length > 0"
@click="confirmingDeleteAll = true"
class="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition hover:bg-red-50 hover:text-red-600"
title="Alle Folien l&ouml;schen"
>
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
<!-- Slide grid with inline upload card -->
<SlideGrid
data-testid="moderation-block-grid"
:slides="moderationSlides"
@ -69,6 +113,30 @@ function handleSlideUpdated() {
/>
</template>
</SlideGrid>
<button
v-if="moderationSlides.length > 0"
@click="downloadBundle"
class="inline-flex items-center rounded-md border border-transparent bg-gray-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
data-testid="download-probundle-button-moderation"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
.probundle herunterladen
</button>
<!-- Delete all confirmation (#2) -->
<ConfirmDialog
:show="confirmingDeleteAll"
title="Alle Folien l&ouml;schen?"
message="M&ouml;chtest du wirklich alle Moderationsfolien l&ouml;schen?"
confirm-label="Alle l&ouml;schen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDeleteAll"
@cancel="confirmingDeleteAll = false"
/>
</div>
</template>

View file

@ -1,7 +1,10 @@
<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import axios from 'axios'
import SlideUploader from '@/Components/SlideUploader.vue'
import SlideGrid from '@/Components/SlideGrid.vue'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
const props = defineProps({
serviceId: {
@ -23,6 +26,10 @@ const sermonSlides = computed(() => {
)
})
// Delete all state (#2)
const confirmingDeleteAll = ref(false)
const deletingAll = ref(false)
function handleSlideUploaded() {
emit('slides-updated')
}
@ -34,21 +41,58 @@ function handleSlideDeleted() {
function handleSlideUpdated() {
emit('slides-updated')
}
function confirmDeleteAll() {
deletingAll.value = true
axios.delete(route('slides.bulk-destroy'), {
data: { type: 'sermon', service_id: props.serviceId },
})
.then(() => {
confirmingDeleteAll.value = false
deletingAll.value = false
emit('slides-updated')
router.reload({ preserveScroll: true })
})
.catch(() => {
deletingAll.value = false
})
}
function downloadBundle() {
window.location.href = route('services.download-bundle', {
service: props.serviceId,
blockType: 'sermon',
})
}
</script>
<template>
<div data-testid="sermon-block" class="sermon-block space-y-6">
<!-- Block header -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900">
Predigtfolien
</h3>
<p class="mt-1 text-sm text-gray-500">
Folien für diesen Gottesdienst
</p>
<div data-testid="sermon-block" class="sermon-block space-y-4">
<!-- Block header with badge and delete-all (#2) -->
<div class="flex items-center justify-end gap-2">
<span
v-if="sermonSlides.length > 0"
class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200/80"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
{{ sermonSlides.length }} {{ sermonSlides.length === 1 ? 'Folie' : 'Folien' }}
</span>
<!-- Delete all button (#2) -->
<button
v-if="sermonSlides.length > 0"
@click="confirmingDeleteAll = true"
class="flex h-7 w-7 items-center justify-center rounded-lg text-gray-400 transition hover:bg-red-50 hover:text-red-600"
title="Alle Folien l&ouml;schen"
>
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
<!-- Slide grid with inline upload card -->
<SlideGrid
data-testid="sermon-block-grid"
:slides="sermonSlides"
@ -69,6 +113,30 @@ function handleSlideUpdated() {
/>
</template>
</SlideGrid>
<button
v-if="sermonSlides.length > 0"
@click="downloadBundle"
class="inline-flex items-center rounded-md border border-transparent bg-gray-600 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"
data-testid="download-probundle-button-sermon"
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
.probundle herunterladen
</button>
<!-- Delete all confirmation (#2) -->
<ConfirmDialog
:show="confirmingDeleteAll"
title="Alle Folien l&ouml;schen?"
message="M&ouml;chtest du wirklich alle Predigtfolien l&ouml;schen?"
confirm-label="Alle l&ouml;schen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDeleteAll"
@cancel="confirmingDeleteAll = false"
/>
</div>
</template>

View file

@ -56,6 +56,7 @@
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download');
Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle');
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');

View file

@ -0,0 +1,129 @@
<?php
namespace Tests\Feature;
use App\Models\Service;
use App\Models\Slide;
use App\Models\User;
use Illuminate\Foundation\Testing\Concerns\MakesHttpRequests;
use Illuminate\Foundation\Testing\Concerns\InteractsWithAuthentication;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Tests\TestCase;
use ZipArchive;
final class ProBundleExportTest extends TestCase
{
use RefreshDatabase;
use MakesHttpRequests;
use InteractsWithAuthentication;
public function test_probundle_enthaelt_pro_datei_und_bilder(): void
{
Storage::fake('public');
$user = User::factory()->create();
$service = Service::factory()->create();
$filenames = [
'info-1.jpg',
'info-2.jpg',
'info-3.jpg',
];
foreach ($filenames as $index => $filename) {
Storage::disk('public')->put('slides/'.$filename, 'fake-image-content-'.$index);
Slide::factory()->create([
'service_id' => $service->id,
'type' => 'information',
'stored_filename' => $filename,
'original_filename' => 'Original '.$filename,
'sort_order' => $index,
]);
}
$response = $this->actingAs($user)->get(route('services.download-bundle', [
'service' => $service,
'blockType' => 'information',
]));
$response->assertOk();
$response->assertHeader('content-type', 'application/zip');
$baseResponse = $response->baseResponse;
if (! $baseResponse instanceof BinaryFileResponse) {
$this->fail('Es wurde keine Dateiantwort zurückgegeben.');
}
$copiedPath = sys_get_temp_dir().'/probundle-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive();
$openResult = $zip->open($copiedPath);
$this->assertTrue($openResult === true);
$this->assertSame(4, $zip->numFiles);
$names = [];
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
if ($name !== false) {
$names[] = $name;
}
}
$zip->close();
@unlink($copiedPath);
$this->assertContains('information.pro', $names);
$this->assertContains('info-1.jpg', $names);
$this->assertContains('info-2.jpg', $names);
$this->assertContains('info-3.jpg', $names);
}
public function test_ungueltiger_block_type_liefert_422(): void
{
$user = User::factory()->create();
$service = Service::factory()->create();
$response = $this->actingAs($user)->getJson(route('services.download-bundle', [
'service' => $service,
'blockType' => 'ungueltig',
]));
$response->assertStatus(422);
}
public function test_probundle_ohne_slides_enthaelt_nur_pro_datei(): void
{
$user = User::factory()->create();
$service = Service::factory()->create();
$response = $this->actingAs($user)->get(route('services.download-bundle', [
'service' => $service,
'blockType' => 'sermon',
]));
$response->assertOk();
$baseResponse = $response->baseResponse;
if (! $baseResponse instanceof BinaryFileResponse) {
$this->fail('Es wurde keine Dateiantwort zurückgegeben.');
}
$copiedPath = sys_get_temp_dir().'/probundle-empty-test-'.uniqid().'.probundle';
copy($baseResponse->getFile()->getPathname(), $copiedPath);
$zip = new ZipArchive();
$openResult = $zip->open($copiedPath);
$this->assertTrue($openResult === true);
$this->assertSame(1, $zip->numFiles);
$this->assertSame('sermon.pro', $zip->getNameIndex(0));
$zip->close();
@unlink($copiedPath);
}
}