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:
parent
fefa761748
commit
5b35afb31d
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
app/Services/ProBundleExportService.php
Normal file
117
app/Services/ProBundleExportService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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ä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ö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öschen?"
|
||||
message="Möchtest du wirklich alle Informationsfolien löschen?"
|
||||
confirm-label="Alle löschen"
|
||||
cancel-label="Abbrechen"
|
||||
variant="danger"
|
||||
@confirm="confirmDeleteAll"
|
||||
@cancel="confirmingDeleteAll = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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ö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öschen?"
|
||||
message="Möchtest du wirklich alle Moderationsfolien löschen?"
|
||||
confirm-label="Alle löschen"
|
||||
cancel-label="Abbrechen"
|
||||
variant="danger"
|
||||
@confirm="confirmDeleteAll"
|
||||
@cancel="confirmingDeleteAll = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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ö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öschen?"
|
||||
message="Möchtest du wirklich alle Predigtfolien löschen?"
|
||||
confirm-label="Alle löschen"
|
||||
cancel-label="Abbrechen"
|
||||
variant="danger"
|
||||
@confirm="confirmDeleteAll"
|
||||
@cancel="confirmingDeleteAll = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
129
tests/Feature/ProBundleExportTest.php
Normal file
129
tests/Feature/ProBundleExportTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue