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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\Song;
|
|
||||||
use App\Models\Slide;
|
use App\Models\Slide;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Services\ProBundleExportService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
|
||||||
class ServiceController extends Controller
|
class ServiceController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -41,13 +44,21 @@ public function index(): Response
|
||||||
'info_slides_count' => Slide::query()
|
'info_slides_count' => Slide::query()
|
||||||
->selectRaw('COUNT(*)')
|
->selectRaw('COUNT(*)')
|
||||||
->where('slides.type', 'information')
|
->where('slides.type', 'information')
|
||||||
|
->whereNull('slides.deleted_at')
|
||||||
->where(function ($query) {
|
->where(function ($query) {
|
||||||
$query
|
$query
|
||||||
->whereNull('slides.service_id')
|
->whereNull('slides.service_id')
|
||||||
->orWhereColumn('slides.service_id', 'services.id');
|
->orWhereColumn('slides.service_id', 'services.id');
|
||||||
})
|
})
|
||||||
->whereNotNull('slides.expire_date')
|
->where(function ($query) {
|
||||||
->whereColumn('slides.expire_date', '>=', 'services.date'),
|
$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()
|
->get()
|
||||||
->map(fn (Service $service) => [
|
->map(fn (Service $service) => [
|
||||||
|
|
@ -107,11 +118,14 @@ public function edit(Service $service): Response
|
||||||
->when(
|
->when(
|
||||||
$service->date,
|
$service->date,
|
||||||
fn ($query) => $query
|
fn ($query) => $query
|
||||||
->where(function ($q) use ($service) {
|
->where(function ($q) use ($service) {
|
||||||
$q->whereNull('expire_date')
|
$q->whereNull('expire_date')
|
||||||
->orWhereDate('expire_date', '>=', $service->date);
|
->orWhereDate('expire_date', '>=', $service->date);
|
||||||
})
|
})
|
||||||
->whereDate('uploaded_at', '<=', $service->date)
|
->when(
|
||||||
|
$service->date->isFuture() || $service->date->isToday(),
|
||||||
|
fn ($q) => $q->whereDate('uploaded_at', '<=', $service->date)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
->orderByDesc('uploaded_at')
|
->orderByDesc('uploaded_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
@ -126,6 +140,14 @@ public function edit(Service $service): Response
|
||||||
->sortByDesc('uploaded_at')
|
->sortByDesc('uploaded_at')
|
||||||
->values();
|
->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', [
|
return Inertia::render('Services/Edit', [
|
||||||
'service' => [
|
'service' => [
|
||||||
'id' => $service->id,
|
'id' => $service->id,
|
||||||
|
|
@ -188,6 +210,16 @@ public function edit(Service $service): Response
|
||||||
'moderationSlides' => $moderationSlides,
|
'moderationSlides' => $moderationSlides,
|
||||||
'sermonSlides' => $sermonSlides,
|
'sermonSlides' => $sermonSlides,
|
||||||
'songsCatalog' => $songsCatalog,
|
'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);
|
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>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { router } from '@inertiajs/vue3'
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import axios from 'axios'
|
||||||
import SlideUploader from '@/Components/SlideUploader.vue'
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
import SlideGrid from '@/Components/SlideGrid.vue'
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
serviceId: {
|
serviceId: {
|
||||||
|
|
@ -40,6 +42,10 @@ const expiringSoonCount = computed(() => {
|
||||||
}).length
|
}).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Delete all state (#2)
|
||||||
|
const confirmingDeleteAll = ref(false)
|
||||||
|
const deletingAll = ref(false)
|
||||||
|
|
||||||
function handleSlideUploaded() {
|
function handleSlideUploaded() {
|
||||||
emit('slides-updated')
|
emit('slides-updated')
|
||||||
}
|
}
|
||||||
|
|
@ -51,57 +57,69 @@ function handleSlideDeleted() {
|
||||||
function handleSlideUpdated() {
|
function handleSlideUpdated() {
|
||||||
emit('slides-updated')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="information-block" class="information-block space-y-6">
|
<div data-testid="information-block" class="information-block space-y-4">
|
||||||
<!-- Block header -->
|
<!-- Block header with badges and delete-all (#2) -->
|
||||||
<div class="border-b border-amber-200/60 pb-4">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<div class="flex items-center justify-between">
|
<span
|
||||||
<div class="flex items-center gap-3">
|
v-if="informationSlides.length > 0"
|
||||||
<!-- Info icon with warm glow -->
|
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"
|
||||||
<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">
|
<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="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" />
|
<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>
|
</svg>
|
||||||
</div>
|
{{ informationSlides.length }} {{ informationSlides.length === 1 ? 'Folie' : 'Folien' }}
|
||||||
<div>
|
</span>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Slide count badges -->
|
<span
|
||||||
<div class="flex items-center gap-2">
|
v-if="expiringSoonCount > 0"
|
||||||
<span
|
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"
|
||||||
v-if="informationSlides.length > 0"
|
:title="`${expiringSoonCount} Folie(n) laufen bald ab`"
|
||||||
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">
|
||||||
<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" />
|
||||||
<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>
|
||||||
</svg>
|
{{ expiringSoonCount }} läuft bald ab
|
||||||
{{ informationSlides.length }} {{ informationSlides.length === 1 ? 'Folie' : 'Folien' }}
|
</span>
|
||||||
</span>
|
|
||||||
|
|
||||||
<span
|
<!-- Delete all button (#2) -->
|
||||||
v-if="expiringSoonCount > 0"
|
<button
|
||||||
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"
|
v-if="informationSlides.length > 0"
|
||||||
:title="`${expiringSoonCount} Folie(n) laufen bald ab`"
|
@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"
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
title="Alle Folien löschen"
|
||||||
<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>
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
{{ expiringSoonCount }} läuft bald ab
|
<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" />
|
||||||
</span>
|
</svg>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Slide grid with inline upload card -->
|
|
||||||
<SlideGrid
|
<SlideGrid
|
||||||
data-testid="information-block-grid"
|
data-testid="information-block-grid"
|
||||||
:slides="informationSlides"
|
:slides="informationSlides"
|
||||||
|
|
@ -122,6 +140,30 @@ function handleSlideUpdated() {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SlideGrid>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import axios from 'axios'
|
||||||
import SlideUploader from '@/Components/SlideUploader.vue'
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
import SlideGrid from '@/Components/SlideGrid.vue'
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
serviceId: {
|
serviceId: {
|
||||||
|
|
@ -23,6 +26,10 @@ const moderationSlides = computed(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Delete all state (#2)
|
||||||
|
const confirmingDeleteAll = ref(false)
|
||||||
|
const deletingAll = ref(false)
|
||||||
|
|
||||||
function handleSlideUploaded() {
|
function handleSlideUploaded() {
|
||||||
emit('slides-updated')
|
emit('slides-updated')
|
||||||
}
|
}
|
||||||
|
|
@ -34,21 +41,58 @@ function handleSlideDeleted() {
|
||||||
function handleSlideUpdated() {
|
function handleSlideUpdated() {
|
||||||
emit('slides-updated')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="moderation-block" class="moderation-block space-y-6">
|
<div data-testid="moderation-block" class="moderation-block space-y-4">
|
||||||
<!-- Block header -->
|
<!-- Block header with badge and delete-all (#2) -->
|
||||||
<div class="border-b border-gray-200 pb-4">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
<span
|
||||||
Moderationsfolien
|
v-if="moderationSlides.length > 0"
|
||||||
</h3>
|
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"
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
>
|
||||||
Folien für diesen Gottesdienst
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Slide grid with inline upload card -->
|
|
||||||
<SlideGrid
|
<SlideGrid
|
||||||
data-testid="moderation-block-grid"
|
data-testid="moderation-block-grid"
|
||||||
:slides="moderationSlides"
|
:slides="moderationSlides"
|
||||||
|
|
@ -69,6 +113,30 @@ function handleSlideUpdated() {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SlideGrid>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import axios from 'axios'
|
||||||
import SlideUploader from '@/Components/SlideUploader.vue'
|
import SlideUploader from '@/Components/SlideUploader.vue'
|
||||||
import SlideGrid from '@/Components/SlideGrid.vue'
|
import SlideGrid from '@/Components/SlideGrid.vue'
|
||||||
|
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
serviceId: {
|
serviceId: {
|
||||||
|
|
@ -23,6 +26,10 @@ const sermonSlides = computed(() => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Delete all state (#2)
|
||||||
|
const confirmingDeleteAll = ref(false)
|
||||||
|
const deletingAll = ref(false)
|
||||||
|
|
||||||
function handleSlideUploaded() {
|
function handleSlideUploaded() {
|
||||||
emit('slides-updated')
|
emit('slides-updated')
|
||||||
}
|
}
|
||||||
|
|
@ -34,21 +41,58 @@ function handleSlideDeleted() {
|
||||||
function handleSlideUpdated() {
|
function handleSlideUpdated() {
|
||||||
emit('slides-updated')
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div data-testid="sermon-block" class="sermon-block space-y-6">
|
<div data-testid="sermon-block" class="sermon-block space-y-4">
|
||||||
<!-- Block header -->
|
<!-- Block header with badge and delete-all (#2) -->
|
||||||
<div class="border-b border-gray-200 pb-4">
|
<div class="flex items-center justify-end gap-2">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
<span
|
||||||
Predigtfolien
|
v-if="sermonSlides.length > 0"
|
||||||
</h3>
|
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"
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
>
|
||||||
Folien für diesen Gottesdienst
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Slide grid with inline upload card -->
|
|
||||||
<SlideGrid
|
<SlideGrid
|
||||||
data-testid="sermon-block-grid"
|
data-testid="sermon-block-grid"
|
||||||
:slides="sermonSlides"
|
:slides="sermonSlides"
|
||||||
|
|
@ -69,6 +113,30 @@ function handleSlideUpdated() {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</SlideGrid>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@
|
||||||
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
||||||
Route::delete('/services/{service}', [ServiceController::class, 'destroy'])->name('services.destroy');
|
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', [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('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
||||||
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
|
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