feat: song pre/postfix, settings overhaul, export & schedule fixes
Resolves a batch of bugs and feature requests across songs, services, settings and export: Songs & sections - Every song now carries permanent, empty, locked PREFIX (COPYRIGHT) and POSTFIX (BLANK) sections, deduplicated on import; locked sections cannot be edited or deleted via UI or API. - Song edit modal: explicit Speichern/Schließen with dirty-tracking, editable section headline (combobox + custom values), and a fix for the 419 CSRF errors after CCLI "Importieren & Bearbeiten" (token read fresh per request). - CCLI bookmarklet "Importieren & Bearbeiten" now opens the edit dialog. Service schedule & arrangements - Fixed assigned songs showing no sections (slides loaded for all arrangements, not just the default). - Added "Song entfernen / neu zuordnen" to reassign an assigned song. - Worship-leader arrangement is created/selected lazily when the arrangement dialog opens (only when not user-overridden); the leader is resolved from the "Lobpreis" agenda item, and manual create/clone names are prefixed with the leader name. Navigation - "/" redirects to the next upcoming service's edit page (or the list). - Service titles link to the edit page. Settings - Renamed "Makro-Import"/"Label-Import" menu items; fixed drag-and-drop imports (were downloading the dropped file); added label-import hint; made the panel scrollable. - Nametag now uses a single MacroPicker; added song prefix/postfix label defaults (COPYRIGHT #24B34C / BLANK #000000); new "Export-Dateien" menu to upload prefix/postfix .pro files added to every export. Export - Filenames/playlist names are date-first ("YYYY-MM-DD <Title>"). - Keyvisual slide only for the first content-less item after real content; all other content-less items render as headlines. - New "Vorschau herunterladen" for non-finalized services (filename and import name prefixed "Vorschau" with export timestamp). - Uploaded prefix/postfix .pro files wrap every export. Tests updated to the new behavior; full suite green (569 passed).
This commit is contained in:
parent
ff3484466b
commit
e33418f716
57
app/Http/Controllers/ExportProFileController.php
Normal file
57
app/Http/Controllers/ExportProFileController.php
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ExportProFile;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ExportProFileController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'type' => ['required', Rule::in(['prefix', 'postfix'])],
|
||||
'files' => ['required', 'array'],
|
||||
'files.*' => ['required', 'file', 'max:10240', 'extensions:pro'],
|
||||
]);
|
||||
|
||||
$type = $request->input('type');
|
||||
$created = [];
|
||||
|
||||
foreach ($request->file('files') as $file) {
|
||||
$storedPath = $file->store('export-pro-files/'.$type, 'local');
|
||||
$maxOrder = ExportProFile::where('type', $type)->max('order') ?? 0;
|
||||
|
||||
$record = ExportProFile::create([
|
||||
'type' => $type,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_path' => $storedPath,
|
||||
'order' => $maxOrder + 1,
|
||||
]);
|
||||
|
||||
$created[] = [
|
||||
'id' => $record->id,
|
||||
'original_name' => $record->original_name,
|
||||
'order' => $record->order,
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'files' => $created,
|
||||
'message' => count($created).' Datei(en) erfolgreich hochgeladen.',
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(ExportProFile $exportProFile): JsonResponse
|
||||
{
|
||||
Storage::disk('local')->delete($exportProFile->stored_path);
|
||||
$exportProFile->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Datei erfolgreich gelöscht.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,7 +27,7 @@ public function importPro(Request $request): JsonResponse
|
|||
}
|
||||
|
||||
try {
|
||||
$service = new ProImportService;
|
||||
$service = app(ProImportService::class);
|
||||
$songs = $service->import($file);
|
||||
|
||||
return response()->json([
|
||||
|
|
|
|||
|
|
@ -131,11 +131,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
$service->load([
|
||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||
'serviceSongs.song',
|
||||
'serviceSongs.song.sections.label',
|
||||
'serviceSongs.song.sections.slides',
|
||||
'serviceSongs.song.arrangements.arrangementSections.section.label',
|
||||
'serviceSongs.arrangement',
|
||||
'slides',
|
||||
'agendaItems' => fn ($q) => $q->orderBy('sort_order'),
|
||||
'agendaItems.slides',
|
||||
'agendaItems.serviceSong.song.sections.label',
|
||||
'agendaItems.serviceSong.song.sections.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.slides',
|
||||
'agendaItems.serviceSong.song.arrangements.arrangementSections.section.label',
|
||||
'agendaItems.serviceSong.arrangement.arrangementSections.section.label',
|
||||
|
|
@ -227,6 +231,15 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
? $matcher->matchesAny($item->title, array_map('trim', explode(',', $sermonPatterns)))
|
||||
: false;
|
||||
|
||||
// Inject `groups` into the nested song so the frontend ArrangementDialog
|
||||
// can resolve all sections (including those not in the default arrangement).
|
||||
if (isset($arr['service_song']['song'])) {
|
||||
$song = $item->serviceSong?->song;
|
||||
if ($song !== null) {
|
||||
$arr['service_song']['song']['groups'] = $this->collectSongLabels($song)->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return $arr;
|
||||
}, $filteredItems);
|
||||
|
||||
|
|
@ -283,6 +296,7 @@ public function edit(Service $service, \App\Services\ServiceImageResolver $image
|
|||
'background_is_own' => $backgroundIsOwn,
|
||||
'moderator_name' => $service->moderator_name,
|
||||
'preacher_name_override' => $service->preacher_name_override,
|
||||
'worship_leader_name' => app(\App\Services\NameTagResolver::class)->worshipLeaderFor($service),
|
||||
],
|
||||
'serviceSongs' => $service->serviceSongs->map(fn ($ss) => [
|
||||
'id' => $ss->id,
|
||||
|
|
@ -438,6 +452,24 @@ public function download(Service $service): JsonResponse|BinaryFileResponse
|
|||
}
|
||||
}
|
||||
|
||||
public function downloadPreview(Service $service): JsonResponse|BinaryFileResponse
|
||||
{
|
||||
try {
|
||||
$playlistService = app(\App\Services\PlaylistExportService::class);
|
||||
$result = $playlistService->generatePlaylist($service, true);
|
||||
|
||||
$response = response()->download($result['path'], $result['filename']);
|
||||
|
||||
if ($result['skipped'] > 0) {
|
||||
$response->headers->set('X-Skipped-Songs', (string) $result['skipped']);
|
||||
}
|
||||
|
||||
return $response->deleteFileAfterSend(false);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadBundle(Request $request, Service $service, string $blockType): BinaryFileResponse
|
||||
{
|
||||
$request->merge(['blockType' => $blockType]);
|
||||
|
|
@ -481,23 +513,28 @@ public function downloadAgendaItem(Service $service, ServiceAgendaItem $agendaIt
|
|||
|
||||
private function collectSongLabels(Song $song): \Illuminate\Support\Collection
|
||||
{
|
||||
$defaultArr = $song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first();
|
||||
|
||||
if ($defaultArr === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $defaultArr->arrangementSections
|
||||
// Build availableGroups from ALL song sections (not just the default arrangement),
|
||||
// so every arrangement's referenced sections resolve to real slides on the frontend.
|
||||
return $song->sections
|
||||
->filter(fn ($section) => $section->label !== null)
|
||||
->map(fn ($section) => [
|
||||
'id' => $section->label->id,
|
||||
'section_id' => $section->id,
|
||||
'name' => $section->label->name,
|
||||
'color' => $section->label->color,
|
||||
'order' => $section->order,
|
||||
'locked' => (bool) ($section->locked ?? false),
|
||||
'slides' => $section->slides
|
||||
->sortBy('order')
|
||||
->values()
|
||||
->map(fn ($arrangementSection) => [
|
||||
'id' => $arrangementSection->section?->label?->id,
|
||||
'section_id' => $arrangementSection->section?->id,
|
||||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
'order' => $arrangementSection->order,
|
||||
->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes ?? null,
|
||||
])->toArray(),
|
||||
])
|
||||
->filter(fn ($group) => $group['id'] !== null)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ExportProFile;
|
||||
use App\Models\Label;
|
||||
use App\Models\Macro;
|
||||
use App\Models\MacroAssignment;
|
||||
|
|
@ -27,10 +28,24 @@ class SettingsController extends Controller
|
|||
'namenseinblender_macro_uuid',
|
||||
'namenseinblender_macro_collection_name',
|
||||
'namenseinblender_macro_collection_uuid',
|
||||
'namenseinblender_macro_id',
|
||||
'song_prefix_label_id',
|
||||
'song_postfix_label_id',
|
||||
];
|
||||
|
||||
public function index(): Response
|
||||
{
|
||||
$copyright = Label::firstOrCreate(['name' => 'COPYRIGHT'], ['color' => '#24B34C']);
|
||||
$blank = Label::firstOrCreate(['name' => 'BLANK'], ['color' => '#000000']);
|
||||
|
||||
if (Setting::get('song_prefix_label_id') === null) {
|
||||
Setting::set('song_prefix_label_id', (string) $copyright->id);
|
||||
}
|
||||
|
||||
if (Setting::get('song_postfix_label_id') === null) {
|
||||
Setting::set('song_postfix_label_id', (string) $blank->id);
|
||||
}
|
||||
|
||||
$settings = [];
|
||||
foreach (self::AGENDA_KEYS as $key) {
|
||||
$settings[$key] = Setting::get($key);
|
||||
|
|
@ -50,6 +65,10 @@ public function index(): Response
|
|||
'at' => Setting::get('labels_last_imported_at'),
|
||||
'filename' => Setting::get('labels_last_imported_filename'),
|
||||
],
|
||||
'export_pro_files' => [
|
||||
'prefix' => ExportProFile::prefix()->orderBy('order')->get(['id', 'original_name', 'order']),
|
||||
'postfix' => ExportProFile::postfix()->orderBy('order')->get(['id', 'original_name', 'order']),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ public function formatSongDetail(Song $song): array
|
|||
'name' => $arrangementSection->section?->label?->name,
|
||||
'color' => $arrangementSection->section?->label?->color,
|
||||
'order' => $arrangementSection->order,
|
||||
'locked' => (bool) ($arrangementSection->section?->locked ?? false),
|
||||
'slides' => $arrangementSection->section
|
||||
? $arrangementSection->section->slides
|
||||
->sortBy('order')
|
||||
|
|
|
|||
|
|
@ -77,11 +77,17 @@ public function update(Request $request, Song $song, SongSection $section): Json
|
|||
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
if ($section->locked) {
|
||||
return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht bearbeitet werden.'], 422);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'slides' => ['required', 'array'],
|
||||
'slides.*.text_content' => ['required', 'string'],
|
||||
'slides.*.text_content_translated' => ['nullable', 'string'],
|
||||
'order' => ['sometimes', 'integer'],
|
||||
'label_name' => ['sometimes', 'string', 'max:255'],
|
||||
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
], $this->validationMessages());
|
||||
|
||||
$responseSong = DB::transaction(function () use ($song, $section, $data): Song {
|
||||
|
|
@ -89,6 +95,17 @@ public function update(Request $request, Song $song, SongSection $section): Json
|
|||
$section->update(['order' => $data['order']]);
|
||||
}
|
||||
|
||||
if (array_key_exists('label_name', $data) && trim($data['label_name']) !== '') {
|
||||
$normalizedLabelName = CcliLabels::normalizeLabelName($data['label_name']);
|
||||
|
||||
$label = Label::firstOrCreate(
|
||||
['name' => $normalizedLabelName],
|
||||
['color' => $data['color'] ?? self::DEFAULT_LABEL_COLOR],
|
||||
);
|
||||
|
||||
$section->update(['label_id' => $label->id]);
|
||||
}
|
||||
|
||||
$this->replaceSlides($section, $data['slides']);
|
||||
$this->recomputeHasTranslation($song);
|
||||
|
||||
|
|
@ -107,6 +124,10 @@ public function destroy(Song $song, SongSection $section): JsonResponse
|
|||
return response()->json(['message' => 'Sektion nicht gefunden.'], 404);
|
||||
}
|
||||
|
||||
if ($section->locked) {
|
||||
return response()->json(['message' => 'Diese Sektion ist gesperrt und kann nicht gelöscht werden.'], 422);
|
||||
}
|
||||
|
||||
$responseSong = DB::transaction(function () use ($song, $section): Song {
|
||||
SongArrangementSection::query()
|
||||
->where('song_section_id', $section->id)
|
||||
|
|
|
|||
33
app/Models/ExportProFile.php
Normal file
33
app/Models/ExportProFile.php
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExportProFile extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'type',
|
||||
'original_name',
|
||||
'stored_path',
|
||||
'order',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePrefix(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'prefix');
|
||||
}
|
||||
|
||||
public function scopePostfix(Builder $query): Builder
|
||||
{
|
||||
return $query->where('type', 'postfix');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Services\AgendaMatcherService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -64,6 +66,14 @@ public function agendaItems(): HasMany
|
|||
return $this->hasMany(ServiceAgendaItem::class)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: nächster bevorstehender Gottesdienst (heute oder später), aufsteigend sortiert.
|
||||
*/
|
||||
public function scopeNextUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query->whereDate('date', '>=', Carbon::today())->orderBy('date');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check finalization prerequisites and return warnings.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -15,12 +15,14 @@ class SongSection extends Model
|
|||
'song_id',
|
||||
'label_id',
|
||||
'order',
|
||||
'locked',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'order' => 'integer',
|
||||
'locked' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ final class CcliImportService
|
|||
|
||||
public function __construct(
|
||||
private readonly CcliPasteParser $parser,
|
||||
private readonly SongPrefixPostfixService $songPrefixPostfixService,
|
||||
) {}
|
||||
|
||||
/** @return array{song: Song, status: 'created'|'restored', warnings: string[]} */
|
||||
|
|
@ -128,6 +129,8 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
]);
|
||||
}
|
||||
|
||||
$this->songPrefixPostfixService->ensure($song);
|
||||
|
||||
$song = $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
|
||||
ApiRequestLog::create([
|
||||
|
|
|
|||
|
|
@ -30,6 +30,18 @@ public function moderatorFor(Service $service): ?string
|
|||
return $firstAgendaItem ? $this->namesFromResponsible($firstAgendaItem->responsible) : null;
|
||||
}
|
||||
|
||||
public function worshipLeaderFor(Service $service): ?string
|
||||
{
|
||||
$worshipItem = $service->agendaItems()
|
||||
->where('is_before_event', false)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->first(fn (ServiceAgendaItem $item) => $this->isWorshipItem($item));
|
||||
|
||||
return $worshipItem ? $this->namesFromResponsible($worshipItem->responsible) : null;
|
||||
}
|
||||
|
||||
public function preacherFor(Service $service): ?string
|
||||
{
|
||||
$override = $this->filledString($service->preacher_name_override);
|
||||
|
|
@ -99,6 +111,15 @@ private function nameFromResponsiblePerson(mixed $person): ?string
|
|||
return $fullName === '' ? null : $fullName;
|
||||
}
|
||||
|
||||
private function isWorshipItem(ServiceAgendaItem $item): bool
|
||||
{
|
||||
$title = Str::lower($item->title);
|
||||
$type = Str::lower($item->type ?? '');
|
||||
|
||||
return str_contains($title, 'lobpreis')
|
||||
|| str_contains($type, 'lobpreis');
|
||||
}
|
||||
|
||||
private function isSermonItem(ServiceAgendaItem $item): bool
|
||||
{
|
||||
$configuredPatterns = $this->patternsFromSetting(Setting::get('agenda_sermon_matching'));
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Macro;
|
||||
use App\Models\Setting;
|
||||
|
||||
class NameTagSlideBuilder
|
||||
|
|
@ -18,6 +19,26 @@ public function buildPreacherSlide(string $name): ?array
|
|||
|
||||
public function build(string $name, string $title): ?array
|
||||
{
|
||||
$macroId = Setting::get('namenseinblender_macro_id');
|
||||
|
||||
if ($macroId !== null && trim($macroId) !== '') {
|
||||
$macro = Macro::with('collections')->find((int) $macroId);
|
||||
|
||||
if ($macro !== null) {
|
||||
$collection = $macro->collections->first();
|
||||
|
||||
return [
|
||||
'text' => $name."\n".$title,
|
||||
'macro' => [
|
||||
'name' => $macro->name,
|
||||
'uuid' => $macro->uuid,
|
||||
'collectionName' => $collection?->name ?? '--MAIN--',
|
||||
'collectionUuid' => $collection?->uuid ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$macroName = Setting::get('namenseinblender_macro_name');
|
||||
|
||||
if ($macroName === null || trim($macroName) === '') {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\ExportProFile;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceAgendaItem;
|
||||
use App\Models\Setting;
|
||||
|
|
@ -14,7 +15,7 @@
|
|||
class PlaylistExportService
|
||||
{
|
||||
/** @return array{path: string, filename: string, skipped: int} */
|
||||
public function generatePlaylist(Service $service): array
|
||||
public function generatePlaylist(Service $service, bool $preview = false): array
|
||||
{
|
||||
$agendaItems = ServiceAgendaItem::where('service_id', $service->id)
|
||||
->where('is_before_event', false)
|
||||
|
|
@ -28,17 +29,17 @@ public function generatePlaylist(Service $service): array
|
|||
->get();
|
||||
|
||||
if ($agendaItems->isEmpty()) {
|
||||
return $this->generatePlaylistLegacy($service);
|
||||
return $this->generatePlaylistLegacy($service, $preview);
|
||||
}
|
||||
|
||||
return $this->generatePlaylistFromAgenda($service, $agendaItems);
|
||||
return $this->generatePlaylistFromAgenda($service, $agendaItems, $preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, ServiceAgendaItem> $agendaItems
|
||||
* @return array{path: string, filename: string, skipped: int, temp_dir: string}
|
||||
*/
|
||||
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems): array
|
||||
private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
|
||||
{
|
||||
$informationSlides = Slide::where('type', 'information')
|
||||
->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date))
|
||||
|
|
@ -65,6 +66,9 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$moderatorSlideData = $this->buildModeratorSlideData($service);
|
||||
$firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
|
||||
|
||||
$realContentEmitted = false;
|
||||
$keyvisualFallbackEmitted = false;
|
||||
|
||||
foreach ($agendaItems as $item) {
|
||||
if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
|
||||
$this->writeProAndEmbed(
|
||||
|
|
@ -119,6 +123,8 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
'name' => $song->title,
|
||||
'path' => $proFilename,
|
||||
];
|
||||
|
||||
$realContentEmitted = true;
|
||||
} else {
|
||||
$skippedUnmatched++;
|
||||
}
|
||||
|
|
@ -132,6 +138,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$this->addPreacherNameTag($service, $tempDir, $playlistItems, $embeddedFiles);
|
||||
}
|
||||
|
||||
$countBefore = count($playlistItems);
|
||||
$label = $item->title ?: 'Folien';
|
||||
$this->addSlidesFromCollection(
|
||||
$item->slides,
|
||||
|
|
@ -144,10 +151,15 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$this->backgroundPartTypeForAgendaItem($item),
|
||||
);
|
||||
|
||||
if (count($playlistItems) > $countBefore) {
|
||||
$realContentEmitted = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->isNameTagAgendaItem($item)) {
|
||||
if ($realContentEmitted && ! $keyvisualFallbackEmitted) {
|
||||
$this->addKeyVisualFallbackPresentation(
|
||||
$item,
|
||||
$service,
|
||||
|
|
@ -155,6 +167,15 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
);
|
||||
$keyvisualFallbackEmitted = true;
|
||||
} else {
|
||||
$this->addHeadlinePresentation(
|
||||
$item,
|
||||
$tempDir,
|
||||
$playlistItems,
|
||||
$embeddedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,9 +201,20 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$playlistName = $service->title.' - '.$dateFormatted;
|
||||
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist';
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
||||
if ($preview) {
|
||||
$exportStamp = now()->format('Y-m-d_H-i');
|
||||
$playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
|
||||
$outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
|
||||
} else {
|
||||
$playlistName = $dateFormatted.' '.$service->title;
|
||||
$outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
|
||||
}
|
||||
|
||||
$outputPath = $tempDir.'/'.$outputFilename;
|
||||
|
||||
$this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);
|
||||
|
|
@ -201,7 +233,7 @@ private function generatePlaylistFromAgenda(Service $service, Collection $agenda
|
|||
*
|
||||
* @return array{path: string, filename: string, skipped: int, temp_dir: string}
|
||||
*/
|
||||
private function generatePlaylistLegacy(Service $service): array
|
||||
private function generatePlaylistLegacy(Service $service, bool $preview = false): array
|
||||
{
|
||||
$service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');
|
||||
|
||||
|
|
@ -277,9 +309,20 @@ private function generatePlaylistLegacy(Service $service): array
|
|||
throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
|
||||
}
|
||||
|
||||
$this->injectExportProFiles($playlistItems, $embeddedFiles, $tempDir);
|
||||
|
||||
$dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
|
||||
$playlistName = $service->title.' - '.$dateFormatted;
|
||||
$outputFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title).'_'.$dateFormatted.'.proplaylist';
|
||||
$safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);
|
||||
|
||||
if ($preview) {
|
||||
$exportStamp = now()->format('Y-m-d_H-i');
|
||||
$playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
|
||||
$outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
|
||||
} else {
|
||||
$playlistName = $dateFormatted.' '.$service->title;
|
||||
$outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
|
||||
}
|
||||
|
||||
$outputPath = $tempDir.'/'.$outputFilename;
|
||||
|
||||
$this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);
|
||||
|
|
@ -471,6 +514,99 @@ private function addKeyVisualFallbackPresentation(
|
|||
];
|
||||
}
|
||||
|
||||
private function addHeadlinePresentation(
|
||||
ServiceAgendaItem $item,
|
||||
string $tempDir,
|
||||
array &$playlistItems,
|
||||
array &$embeddedFiles,
|
||||
): void {
|
||||
$label = $item->title ?: 'Ablaufpunkt';
|
||||
$slideData = ['text' => $label];
|
||||
|
||||
$groups = [
|
||||
[
|
||||
'name' => $label,
|
||||
'color' => [0, 0, 0, 1],
|
||||
'slides' => [$slideData],
|
||||
],
|
||||
];
|
||||
$arrangements = [
|
||||
[
|
||||
'name' => 'normal',
|
||||
'groupNames' => [$label],
|
||||
],
|
||||
];
|
||||
|
||||
$safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
|
||||
$proFilename = $safeLabel.'-headline-'.uniqid().'.pro';
|
||||
$proPath = $tempDir.'/'.$proFilename;
|
||||
|
||||
$this->writeProFile($proPath, $label, $groups, $arrangements);
|
||||
|
||||
$embeddedFiles[$proFilename] = file_get_contents($proPath);
|
||||
|
||||
$playlistItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => $label,
|
||||
'path' => $proFilename,
|
||||
];
|
||||
}
|
||||
|
||||
private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, string $tempDir): void
|
||||
{
|
||||
$prefixFiles = ExportProFile::prefix()->orderBy('order')->get();
|
||||
$postfixFiles = ExportProFile::postfix()->orderBy('order')->get();
|
||||
|
||||
$prefixItems = [];
|
||||
$prefixEmbedded = [];
|
||||
|
||||
foreach ($prefixFiles as $file) {
|
||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($bytes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||
$embeddedFilename = 'PREFIX_'.$file->order.'_'.$safeBase.'.pro';
|
||||
$prefixEmbedded[$embeddedFilename] = $bytes;
|
||||
$prefixItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
|
||||
'path' => $embeddedFilename,
|
||||
];
|
||||
}
|
||||
|
||||
$postfixItems = [];
|
||||
$postfixEmbedded = [];
|
||||
|
||||
foreach ($postfixFiles as $file) {
|
||||
if (! Storage::disk('local')->exists($file->stored_path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bytes = Storage::disk('local')->get($file->stored_path);
|
||||
if ($bytes === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
|
||||
$embeddedFilename = 'POSTFIX_'.$file->order.'_'.$safeBase.'.pro';
|
||||
$postfixEmbedded[$embeddedFilename] = $bytes;
|
||||
$postfixItems[] = [
|
||||
'type' => 'presentation',
|
||||
'name' => pathinfo($file->original_name, PATHINFO_FILENAME),
|
||||
'path' => $embeddedFilename,
|
||||
];
|
||||
}
|
||||
|
||||
$playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
|
||||
$embeddedFiles = array_merge($prefixEmbedded, $embeddedFiles, $postfixEmbedded);
|
||||
}
|
||||
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
{
|
||||
ProFileGenerator::generateAndWrite($path, $name, $groups, $arrangements);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@
|
|||
|
||||
class ProImportService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongPrefixPostfixService $songPrefixPostfixService,
|
||||
) {}
|
||||
|
||||
/** @return Song[] */
|
||||
public function import(UploadedFile $file): array
|
||||
{
|
||||
|
|
@ -174,6 +178,8 @@ private function upsertSong(ProSong $proSong): Song
|
|||
}
|
||||
}
|
||||
|
||||
$this->songPrefixPostfixService->ensure($song);
|
||||
|
||||
return $song->fresh(['arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label']);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@
|
|||
|
||||
class SongMatchingService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongPrefixPostfixService $songPrefixPostfixService,
|
||||
) {}
|
||||
|
||||
public function autoMatch(ServiceSong $serviceSong): bool
|
||||
{
|
||||
if ($serviceSong->song_id !== null) {
|
||||
|
|
@ -76,6 +80,8 @@ public function manualAssign(ServiceSong $serviceSong, Song $song): void
|
|||
}
|
||||
|
||||
$serviceSong->update($updateData);
|
||||
|
||||
$this->songPrefixPostfixService->ensure($song);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
133
app/Services/SongPrefixPostfixService.php
Normal file
133
app/Services/SongPrefixPostfixService.php
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Label;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementSection;
|
||||
use App\Models\SongSection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongPrefixPostfixService
|
||||
{
|
||||
public function ensure(Song $song): void
|
||||
{
|
||||
DB::transaction(function () use ($song): void {
|
||||
$prefixLabelId = $this->resolvePrefixLabelId();
|
||||
$postfixLabelId = $this->resolvePostfixLabelId();
|
||||
|
||||
$prefixSection = $this->ensureLockedSection($song, $prefixLabelId, 0);
|
||||
$postfixSection = $this->ensureLockedSection($song, $postfixLabelId, PHP_INT_MAX);
|
||||
|
||||
$arrangement = $this->resolveDefaultArrangement($song);
|
||||
|
||||
$this->ensureSectionInArrangement($arrangement, $prefixSection, 'first');
|
||||
$this->ensureSectionInArrangement($arrangement, $postfixSection, 'last');
|
||||
});
|
||||
}
|
||||
|
||||
private function resolvePrefixLabelId(): int
|
||||
{
|
||||
$id = Setting::get('song_prefix_label_id');
|
||||
|
||||
if ($id !== null && $id !== '') {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
$label = Label::firstOrCreate(
|
||||
['name' => 'COPYRIGHT'],
|
||||
['color' => '#24B34C'],
|
||||
);
|
||||
|
||||
Setting::set('song_prefix_label_id', (string) $label->id);
|
||||
|
||||
return $label->id;
|
||||
}
|
||||
|
||||
private function resolvePostfixLabelId(): int
|
||||
{
|
||||
$id = Setting::get('song_postfix_label_id');
|
||||
|
||||
if ($id !== null && $id !== '') {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
$label = Label::firstOrCreate(
|
||||
['name' => 'BLANK'],
|
||||
['color' => '#000000'],
|
||||
);
|
||||
|
||||
Setting::set('song_postfix_label_id', (string) $label->id);
|
||||
|
||||
return $label->id;
|
||||
}
|
||||
|
||||
private function ensureLockedSection(Song $song, int $labelId, int $orderHint): SongSection
|
||||
{
|
||||
$section = SongSection::firstOrCreate(
|
||||
['song_id' => $song->id, 'label_id' => $labelId],
|
||||
['order' => $orderHint, 'locked' => true],
|
||||
);
|
||||
|
||||
$section->update(['locked' => true]);
|
||||
$section->slides()->delete();
|
||||
|
||||
return $section;
|
||||
}
|
||||
|
||||
private function resolveDefaultArrangement(Song $song): SongArrangement
|
||||
{
|
||||
return SongArrangement::firstOrCreate(
|
||||
['song_id' => $song->id, 'name' => 'normal'],
|
||||
['is_default' => true],
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureSectionInArrangement(
|
||||
SongArrangement $arrangement,
|
||||
SongSection $section,
|
||||
string $position,
|
||||
): void {
|
||||
$existing = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
|
||||
->where('song_section_id', $section->id)
|
||||
->first();
|
||||
|
||||
if ($position === 'first') {
|
||||
if ($existing === null) {
|
||||
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
|
||||
->increment('order');
|
||||
|
||||
SongArrangementSection::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => 0,
|
||||
]);
|
||||
} else {
|
||||
if ($existing->order !== 0) {
|
||||
SongArrangementSection::where('song_arrangement_id', $arrangement->id)
|
||||
->where('id', '!=', $existing->id)
|
||||
->where('order', '<', $existing->order)
|
||||
->increment('order');
|
||||
|
||||
$existing->update(['order' => 0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$maxOrder = SongArrangementSection::where('song_arrangement_id', $arrangement->id)
|
||||
->where('song_section_id', '!=', $section->id)
|
||||
->max('order') ?? 0;
|
||||
|
||||
if ($existing === null) {
|
||||
SongArrangementSection::create([
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_section_id' => $section->id,
|
||||
'order' => $maxOrder + 1,
|
||||
]);
|
||||
} else {
|
||||
$existing->update(['order' => $maxOrder + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('export_pro_files', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('type');
|
||||
$table->string('original_name');
|
||||
$table->string('stored_path');
|
||||
$table->integer('order')->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('export_pro_files');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('song_sections', function (Blueprint $table) {
|
||||
$table->boolean('locked')->default(false)->after('order');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('song_sections', function (Blueprint $table) {
|
||||
$table->dropColumn('locked');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -40,9 +40,21 @@ const props = defineProps({
|
|||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
worshipLeaderName: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
serviceSongArrangementId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
defaultArrangementId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'arrangement-selected'])
|
||||
const emit = defineEmits(['close', 'arrangement-selected', 'unassigned'])
|
||||
|
||||
/* ── Song assignment (unmatched songs) ── */
|
||||
|
||||
|
|
@ -118,6 +130,19 @@ async function assignSong() {
|
|||
}
|
||||
}
|
||||
|
||||
async function unassignSong() {
|
||||
if (!confirm('Diese Songzuordnung wirklich aufheben?')) return
|
||||
|
||||
try {
|
||||
await window.axios.post(`/api/service-songs/${props.serviceSongId}/unassign`)
|
||||
emit('unassigned')
|
||||
emit('close')
|
||||
} catch {
|
||||
// Silently ignore — parent will reload on close
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State ── */
|
||||
|
||||
// Virtual MASTER arrangement — always first, computed from availableGroups
|
||||
|
|
@ -272,6 +297,71 @@ function closeOnEscape(e) {
|
|||
}
|
||||
}
|
||||
|
||||
async function selectArrangementForServiceSong(arrangementId) {
|
||||
try {
|
||||
await window.axios.patch(`/api/service-songs/${props.serviceSongId}`, {
|
||||
song_arrangement_id: arrangementId,
|
||||
})
|
||||
currentArrangementId.value = arrangementId
|
||||
} catch {
|
||||
// Ignore — user can select manually
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLeaderArrangement() {
|
||||
if (!props.worshipLeaderName || isUnmatched.value) return
|
||||
|
||||
// Only auto-act if the user hasn't manually chosen an arrangement
|
||||
const isUnchanged =
|
||||
props.serviceSongArrangementId === null ||
|
||||
props.serviceSongArrangementId === props.defaultArrangementId
|
||||
|
||||
if (!isUnchanged) return
|
||||
|
||||
const leaderName = props.worshipLeaderName.trim()
|
||||
if (!leaderName) return
|
||||
|
||||
// Check if an arrangement with this name already exists
|
||||
const existing = props.arrangements.find(
|
||||
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
await selectArrangementForServiceSong(existing.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Create by cloning the default arrangement
|
||||
const defaultArr = props.arrangements.find((a) => a.is_default) ?? props.arrangements[0]
|
||||
if (!defaultArr) return
|
||||
|
||||
try {
|
||||
pendingAutoSelect.value = true
|
||||
router.post(
|
||||
`/arrangements/${defaultArr.id}/clone`,
|
||||
{ name: leaderName },
|
||||
{
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
router.reload({
|
||||
preserveScroll: true,
|
||||
onSuccess: async () => {
|
||||
const created = props.arrangements.find(
|
||||
(a) => a.name.trim().toLowerCase() === leaderName.toLowerCase(),
|
||||
)
|
||||
if (created) {
|
||||
await selectArrangementForServiceSong(created.id)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
document.addEventListener('click', onBodyClick)
|
||||
|
|
@ -279,6 +369,9 @@ onMounted(() => {
|
|||
// For unmatched songs: show search results immediately (prefilled with the song name).
|
||||
if (isUnmatched.value) {
|
||||
dropdownOpen.value = true
|
||||
} else {
|
||||
// For matched songs: auto-create/select worship-leader arrangement if un-changed.
|
||||
ensureLeaderArrangement()
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -289,8 +382,13 @@ onUnmounted(() => {
|
|||
|
||||
/* ── Arrangement CRUD (Inertia router, matching ArrangementConfigurator patterns) ── */
|
||||
|
||||
function leaderPrefix() {
|
||||
return props.worshipLeaderName?.trim() ? `${props.worshipLeaderName.trim()} - ` : ''
|
||||
}
|
||||
|
||||
function createArrangement() {
|
||||
const name = window.prompt('Name für das neue Arrangement:')
|
||||
const defaultName = leaderPrefix()
|
||||
const name = window.prompt('Name für das neue Arrangement:', defaultName)
|
||||
if (!name?.trim()) return
|
||||
pendingAutoSelect.value = true
|
||||
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
|
||||
|
|
@ -306,7 +404,8 @@ function cloneArrangement() {
|
|||
|
||||
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
|
||||
if (isMasterSelected.value) {
|
||||
const name = window.prompt('Name für das geklonte Arrangement:', 'MASTER Kopie')
|
||||
const defaultName = leaderPrefix() || 'MASTER Kopie'
|
||||
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
|
||||
if (!name?.trim()) return
|
||||
pendingAutoSelect.value = true
|
||||
router.post(`/songs/${props.songId}/arrangements`, { name: name.trim() }, {
|
||||
|
|
@ -318,7 +417,10 @@ function cloneArrangement() {
|
|||
return
|
||||
}
|
||||
|
||||
const name = window.prompt('Name für das geklonte Arrangement:', `${currentArrangement.value?.name ?? ''} Kopie`)
|
||||
const defaultName = leaderPrefix()
|
||||
? `${leaderPrefix()}${currentArrangement.value?.name ?? ''}`
|
||||
: `${currentArrangement.value?.name ?? ''} Kopie`
|
||||
const name = window.prompt('Name für das geklonte Arrangement:', defaultName)
|
||||
if (!name?.trim()) return
|
||||
pendingAutoSelect.value = true
|
||||
router.post(`/arrangements/${currentArrangementId.value}/clone`, { name: name.trim() }, {
|
||||
|
|
@ -497,6 +599,15 @@ function closeOnBackdrop(e) {
|
|||
Löschen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="arrangement-unassign-button"
|
||||
type="button"
|
||||
class="rounded-md border border-orange-300 bg-orange-50 px-3 py-2 text-sm font-semibold text-orange-700 shadow hover:bg-orange-100"
|
||||
@click="unassignSong"
|
||||
>
|
||||
Song entfernen / neu zuordnen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="arrangement-dialog-close-btn"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const props = defineProps({
|
|||
serviceSongId: { type: Number, default: null },
|
||||
pairWithSongId: { type: Number, default: null },
|
||||
prefilledText: { type: String, default: null },
|
||||
autoEdit: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'imported', 'paired', 'edit-song'])
|
||||
|
|
@ -47,6 +48,9 @@ async function doPreview() {
|
|||
error.value = data.message || 'Fehler beim Verarbeiten des Textes.'
|
||||
} else {
|
||||
preview.value = data
|
||||
if (props.autoEdit && (props.mode === 'songdb' || props.mode === 'service-form')) {
|
||||
await doImport('edit')
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler. Bitte versuche es erneut.'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -22,6 +21,7 @@ const loading = ref(false)
|
|||
const error = ref(null)
|
||||
const songData = ref(null)
|
||||
const sectionDrafts = ref({})
|
||||
const sectionLabels = ref({})
|
||||
|
||||
const title = ref('')
|
||||
const ccliId = ref('')
|
||||
|
|
@ -30,11 +30,63 @@ const showAddSectionForm = ref(false)
|
|||
const newSectionLabel = ref('')
|
||||
const newSectionText = ref('')
|
||||
const sectionLabelDropdownOpen = ref(false)
|
||||
const headlineDropdownFor = ref(null)
|
||||
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
let savedTimeout = null
|
||||
const sectionSaveDebouncers = new Map()
|
||||
|
||||
/* ── Pristine snapshot for dirty tracking ── */
|
||||
|
||||
const pristine = ref(null)
|
||||
|
||||
function snapshotPristine() {
|
||||
pristine.value = {
|
||||
title: title.value,
|
||||
ccliId: ccliId.value,
|
||||
copyrightText: copyrightText.value,
|
||||
sections: JSON.parse(JSON.stringify(sectionDrafts.value)),
|
||||
labels: { ...sectionLabels.value },
|
||||
}
|
||||
}
|
||||
|
||||
const isDirty = computed(() => {
|
||||
if (!pristine.value) return false
|
||||
|
||||
if (
|
||||
title.value !== pristine.value.title ||
|
||||
ccliId.value !== pristine.value.ccliId ||
|
||||
copyrightText.value !== pristine.value.copyrightText
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
const keys = new Set([
|
||||
...Object.keys(pristine.value.sections),
|
||||
...Object.keys(sectionDrafts.value),
|
||||
])
|
||||
|
||||
for (const key of keys) {
|
||||
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
|
||||
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
|
||||
|
||||
if ((before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((pristine.value.labels[key] ?? '') !== (sectionLabels.value[key] ?? '')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
/* ── CSRF (read fresh on every request to avoid stale token after CCLI import) ── */
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
}
|
||||
|
||||
/* ── Save status ── */
|
||||
|
||||
|
|
@ -72,15 +124,19 @@ function sectionKey(group) {
|
|||
|
||||
function setSectionDrafts(data) {
|
||||
const drafts = {}
|
||||
const labels = {}
|
||||
|
||||
;(data?.groups ?? []).forEach((group) => {
|
||||
drafts[sectionKey(group)] = {
|
||||
const key = sectionKey(group)
|
||||
drafts[key] = {
|
||||
text: slidesToText(group.slides, 'text_content'),
|
||||
translated: slidesToText(group.slides, 'text_content_translated'),
|
||||
}
|
||||
labels[key] = group.name ?? ''
|
||||
})
|
||||
|
||||
sectionDrafts.value = drafts
|
||||
sectionLabels.value = labels
|
||||
}
|
||||
|
||||
function draftFor(group) {
|
||||
|
|
@ -119,6 +175,8 @@ const fetchSong = async () => {
|
|||
title.value = json.data.title ?? ''
|
||||
ccliId.value = json.data.ccli_id ?? ''
|
||||
copyrightText.value = json.data.copyright_text ?? ''
|
||||
|
||||
snapshotPristine()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
|
|
@ -136,67 +194,119 @@ watch(
|
|||
if (!isVisible) {
|
||||
songData.value = null
|
||||
sectionDrafts.value = {}
|
||||
sectionLabels.value = {}
|
||||
pristine.value = null
|
||||
error.value = null
|
||||
showAddSectionForm.value = false
|
||||
newSectionLabel.value = ''
|
||||
newSectionText.value = ''
|
||||
headlineDropdownFor.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */
|
||||
|
||||
const performSave = async (data) => {
|
||||
if (!props.songId) return
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
/* ── Save metadata ── */
|
||||
|
||||
const performSaveMetadata = async () => {
|
||||
const response = await fetch(`/api/songs/${props.songId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify({
|
||||
title: title.value,
|
||||
ccli_id: ccliId.value || null,
|
||||
copyright_text: copyrightText.value || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Speichern fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Save all changes (manual Save button) ── */
|
||||
|
||||
async function saveAll() {
|
||||
if (!props.songId || !isDirty.value || saving.value) return
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const metadataDirty =
|
||||
title.value !== pristine.value.title ||
|
||||
ccliId.value !== pristine.value.ccliId ||
|
||||
copyrightText.value !== pristine.value.copyrightText
|
||||
|
||||
if (metadataDirty) {
|
||||
await performSaveMetadata()
|
||||
}
|
||||
|
||||
for (const group of songData.value?.groups ?? []) {
|
||||
if (group.locked) continue
|
||||
|
||||
const key = sectionKey(group)
|
||||
const before = pristine.value.sections[key] ?? { text: '', translated: '' }
|
||||
const now = sectionDrafts.value[key] ?? { text: '', translated: '' }
|
||||
const labelBefore = pristine.value.labels[key] ?? ''
|
||||
const labelNow = sectionLabels.value[key] ?? ''
|
||||
|
||||
const textChanged =
|
||||
(before.text ?? '') !== (now.text ?? '') || (before.translated ?? '') !== (now.translated ?? '')
|
||||
const labelChanged = labelBefore !== labelNow
|
||||
|
||||
if (textChanged || labelChanged) {
|
||||
await persistSection(key, labelChanged ? labelNow : null)
|
||||
}
|
||||
}
|
||||
|
||||
await fetchSong()
|
||||
finishSaving()
|
||||
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
error.value = 'Speichern fehlgeschlagen.'
|
||||
}
|
||||
}
|
||||
|
||||
const buildPayload = () => ({
|
||||
title: title.value,
|
||||
ccli_id: ccliId.value || null,
|
||||
copyright_text: copyrightText.value || null,
|
||||
async function persistSection(sectionId, labelName) {
|
||||
const body = {
|
||||
slides: buildSectionSlides(sectionId),
|
||||
}
|
||||
|
||||
if (labelName) {
|
||||
body.label_name = labelName
|
||||
}
|
||||
|
||||
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
// 500ms debounce for text inputs
|
||||
const debouncedSave = useDebounceFn((data) => {
|
||||
performSave(data)
|
||||
}, 500)
|
||||
|
||||
const onTextInput = () => {
|
||||
debouncedSave(buildPayload())
|
||||
if (!response.ok) {
|
||||
throw new Error('Sektion konnte nicht gespeichert werden.')
|
||||
}
|
||||
}
|
||||
|
||||
// Immediate save on blur (cancel pending debounce)
|
||||
const onFieldBlur = () => {
|
||||
debouncedSave.cancel?.()
|
||||
performSave(buildPayload())
|
||||
/* ── Close handling (guard unsaved changes) ── */
|
||||
|
||||
function requestClose() {
|
||||
if (isDirty.value && !window.confirm('Es gibt ungespeicherte Änderungen. Wirklich schließen?')) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/* ── Arrangement props ── */
|
||||
|
|
@ -294,54 +404,36 @@ function buildSectionSlides(sectionId) {
|
|||
}))
|
||||
}
|
||||
|
||||
async function saveSection(sectionId) {
|
||||
if (!props.songId || !sectionDrafts.value[sectionId]) return
|
||||
/* ── Section headline combobox ── */
|
||||
|
||||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.update', [props.songId, sectionId]), {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
slides: buildSectionSlides(sectionId),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Sektion konnte nicht gespeichert werden.')
|
||||
function headlineDraftFor(group) {
|
||||
return sectionLabels.value[sectionKey(group)] ?? group.name ?? ''
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
songData.value = json.data
|
||||
setSectionDrafts(json.data)
|
||||
finishSaving()
|
||||
emit('updated')
|
||||
} catch {
|
||||
stopSaving()
|
||||
}
|
||||
function setHeadline(group, name) {
|
||||
sectionLabels.value[sectionKey(group)] = name
|
||||
}
|
||||
|
||||
function onSectionInput(sectionId) {
|
||||
if (!sectionSaveDebouncers.has(sectionId)) {
|
||||
sectionSaveDebouncers.set(sectionId, useDebounceFn(() => {
|
||||
saveSection(sectionId)
|
||||
}, 600))
|
||||
function selectHeadline(group, name) {
|
||||
setHeadline(group, name)
|
||||
headlineDropdownFor.value = null
|
||||
}
|
||||
|
||||
sectionSaveDebouncers.get(sectionId)()
|
||||
function openHeadlineDropdown(group) {
|
||||
headlineDropdownFor.value = sectionKey(group)
|
||||
}
|
||||
|
||||
function onSectionBlur(sectionId) {
|
||||
sectionSaveDebouncers.get(sectionId)?.cancel?.()
|
||||
saveSection(sectionId)
|
||||
function closeHeadlineDropdown() {
|
||||
setTimeout(() => {
|
||||
headlineDropdownFor.value = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
function filteredHeadlineOptions(group) {
|
||||
const term = (sectionLabels.value[sectionKey(group)] ?? '').trim().toLowerCase()
|
||||
if (term === '') return sectionLabelOptions.value
|
||||
|
||||
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
async function deleteSection(sectionId) {
|
||||
|
|
@ -350,13 +442,12 @@ async function deleteSection(sectionId) {
|
|||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.destroy', [props.songId, sectionId]), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
|
@ -368,6 +459,7 @@ async function deleteSection(sectionId) {
|
|||
const json = await response.json()
|
||||
songData.value = json.data
|
||||
setSectionDrafts(json.data)
|
||||
snapshotPristine()
|
||||
finishSaving()
|
||||
emit('updated')
|
||||
} catch {
|
||||
|
|
@ -381,14 +473,13 @@ async function addSection() {
|
|||
startSaving()
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
const response = await fetch(route('songs.sections.store', props.songId), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
|
|
@ -417,13 +508,13 @@ async function addSection() {
|
|||
|
||||
const closeOnEscape = (e) => {
|
||||
if (e.key === 'Escape' && props.show) {
|
||||
emit('close')
|
||||
requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
const closeOnBackdrop = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
emit('close')
|
||||
requestClose()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -543,7 +634,7 @@ onUnmounted(() => {
|
|||
data-testid="song-edit-modal-close-button"
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
@click="emit('close')"
|
||||
@click="requestClose"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
|
|
@ -648,8 +739,6 @@ onUnmounted(() => {
|
|||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="Songtitel eingeben…"
|
||||
@input="onTextInput"
|
||||
@blur="onFieldBlur"
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -667,8 +756,6 @@ onUnmounted(() => {
|
|||
type="text"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="z.B. 123456"
|
||||
@input="onTextInput"
|
||||
@blur="onFieldBlur"
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -715,8 +802,6 @@ onUnmounted(() => {
|
|||
rows="3"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="Copyright-Informationen…"
|
||||
@input="onTextInput"
|
||||
@blur="onFieldBlur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -731,7 +816,7 @@ onUnmounted(() => {
|
|||
</h3>
|
||||
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Leerzeilen trennen einzelne Folien. Änderungen speichern automatisch.
|
||||
Leerzeilen trennen einzelne Folien. Klicke „Speichern", um Deine Änderungen zu sichern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -835,17 +920,60 @@ onUnmounted(() => {
|
|||
v-for="group in songData.groups"
|
||||
:key="group.section_id ?? group.id"
|
||||
data-testid="section-block"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm"
|
||||
:class="[
|
||||
'rounded-lg border p-4 shadow-sm',
|
||||
group.locked ? 'border-gray-100 bg-gray-50' : 'border-gray-200 bg-white',
|
||||
]"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<div class="relative flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white shadow-sm"
|
||||
class="inline-block h-4 w-4 shrink-0 rounded-full shadow-sm"
|
||||
:style="{ backgroundColor: group.color ?? '#6b7280' }"
|
||||
/>
|
||||
<span
|
||||
v-if="group.locked"
|
||||
data-testid="section-headline-locked"
|
||||
class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-sm font-semibold text-gray-500"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
<template v-else>
|
||||
<input
|
||||
data-testid="section-headline-input"
|
||||
:value="headlineDraftFor(group)"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="w-48 rounded-md border-gray-300 text-sm font-semibold shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="Abschnitt wählen…"
|
||||
@input="setHeadline(group, $event.target.value)"
|
||||
@focus="openHeadlineDropdown(group)"
|
||||
@blur="closeHeadlineDropdown"
|
||||
>
|
||||
<div
|
||||
v-if="headlineDropdownFor === (group.section_id ?? group.id) && filteredHeadlineOptions(group).length > 0"
|
||||
data-testid="section-headline-dropdown"
|
||||
class="absolute left-6 top-full z-30 mt-1 max-h-56 w-48 overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="labelName in filteredHeadlineOptions(group)"
|
||||
:key="labelName"
|
||||
type="button"
|
||||
data-testid="section-headline-option"
|
||||
class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
|
||||
@mousedown.prevent="selectHeadline(group, labelName)"
|
||||
>
|
||||
{{ labelName }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!group.locked"
|
||||
data-testid="section-delete-button"
|
||||
type="button"
|
||||
class="rounded-md p-2 text-red-500 hover:bg-red-50 hover:text-red-700"
|
||||
|
|
@ -858,7 +986,20 @@ onUnmounted(() => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''">
|
||||
<div
|
||||
v-if="group.locked"
|
||||
class="flex items-center gap-1.5 text-xs text-gray-400"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Gesperrt – wird automatisch verwaltet
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
:class="songData.has_translation ? 'grid gap-4 lg:grid-cols-2' : ''"
|
||||
>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700">
|
||||
Originaltext
|
||||
|
|
@ -869,8 +1010,6 @@ onUnmounted(() => {
|
|||
rows="6"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="Folientext…"
|
||||
@input="onSectionInput(group.section_id ?? group.id)"
|
||||
@blur="onSectionBlur(group.section_id ?? group.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -884,8 +1023,6 @@ onUnmounted(() => {
|
|||
rows="6"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||
placeholder="Übersetzter Folientext…"
|
||||
@input="onSectionInput(group.section_id ?? group.id)"
|
||||
@blur="onSectionBlur(group.section_id ?? group.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -907,6 +1044,31 @@ onUnmounted(() => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Save / Close -->
|
||||
<div
|
||||
v-if="songData"
|
||||
class="flex items-center justify-end gap-3 rounded-b-xl border-t border-gray-200 bg-gray-50 px-6 py-4"
|
||||
>
|
||||
<button
|
||||
data-testid="song-edit-close"
|
||||
type="button"
|
||||
class="rounded-md px-4 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-200"
|
||||
@click="requestClose"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-testid="song-edit-save"
|
||||
type="button"
|
||||
class="rounded-md bg-amber-500 px-5 py-2 text-sm font-semibold text-white shadow hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDirty || saving"
|
||||
@click="saveAll"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
|||
|
|
@ -315,6 +315,38 @@ async function downloadService() {
|
|||
showToast('Fehler beim Herunterladen.', 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPreview() {
|
||||
try {
|
||||
const response = await fetch(route('services.download-preview', props.service.id), {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
showToast(data.message || 'Fehler beim Herunterladen der Vorschau.', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('content-disposition')
|
||||
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
|
||||
const filename = filenameMatch?.[1] || 'vorschau.proplaylist'
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast('Vorschau wurde heruntergeladen.', 'success')
|
||||
} catch {
|
||||
showToast('Fehler beim Herunterladen der Vorschau.', 'warning')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -587,7 +619,11 @@ async function downloadService() {
|
|||
:service-song-name="(arrangementDialogItem.service_song?.cts_song_name ?? arrangementDialogItem.serviceSong?.cts_song_name) ?? arrangementDialogItem.title ?? ''"
|
||||
:service-song-ccli-id="String((arrangementDialogItem.service_song?.cts_ccli_id ?? arrangementDialogItem.serviceSong?.cts_ccli_id) ?? '')"
|
||||
:songs-catalog="songsCatalog"
|
||||
:worship-leader-name="service.worship_leader_name ?? null"
|
||||
:service-song-arrangement-id="arrangementDialogItem.service_song?.song_arrangement_id ?? arrangementDialogItem.serviceSong?.song_arrangement_id ?? null"
|
||||
:default-arrangement-id="(getArrangements(arrangementDialogItem).find((a) => a.is_default))?.id ?? null"
|
||||
@close="onArrangementDialogClosed"
|
||||
@unassigned="onArrangementDialogClosed"
|
||||
/>
|
||||
|
||||
<!-- Sticky bottom action bar -->
|
||||
|
|
@ -637,6 +673,17 @@ async function downloadService() {
|
|||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
data-testid="download-preview-button"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700"
|
||||
@click="downloadPreview"
|
||||
>
|
||||
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
Vorschau herunterladen
|
||||
</button>
|
||||
<button
|
||||
data-testid="service-edit-finalize-button"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { Head, Link, router } from '@inertiajs/vue3'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
|
@ -187,6 +187,38 @@ async function downloadService(serviceId) {
|
|||
}
|
||||
}
|
||||
|
||||
async function downloadPreview(serviceId) {
|
||||
try {
|
||||
const response = await fetch(route('services.download-preview', serviceId), {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
showToast(data.message || 'Fehler beim Herunterladen der Vorschau.', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('content-disposition')
|
||||
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/)
|
||||
const filename = filenameMatch?.[1] || 'vorschau.proplaylist'
|
||||
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
showToast('Vorschau wurde heruntergeladen.', 'success')
|
||||
} catch {
|
||||
showToast('Fehler beim Herunterladen der Vorschau.', 'warning')
|
||||
}
|
||||
}
|
||||
|
||||
function mappingStatusClass(service) {
|
||||
if (service.songs_total_count === 0) {
|
||||
return 'text-red-600'
|
||||
|
|
@ -298,7 +330,7 @@ function stateIconClass(isDone) {
|
|||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr v-for="service in services" :key="service.id" :data-testid="`service-list-row-${service.id}`" class="align-top hover:bg-gray-50/60">
|
||||
<td class="px-4 py-4">
|
||||
<div class="font-medium text-gray-900" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</div>
|
||||
<Link :href="route('services.edit', service.id)" class="font-medium text-gray-900 hover:underline cursor-pointer" :title="'CTS Event #' + service.cts_event_id">{{ service.title }}</Link>
|
||||
<div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div>
|
||||
</td>
|
||||
|
||||
|
|
@ -422,6 +454,14 @@ function stateIconClass(isDone) {
|
|||
>
|
||||
Abschließen
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-preview-button"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700"
|
||||
@click="downloadPreview(service.id)"
|
||||
>
|
||||
Vorschau herunterladen
|
||||
</button>
|
||||
<button
|
||||
data-testid="service-list-delete-button"
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import MacroPicker from '@/Components/MacroPicker.vue'
|
||||
import LabelPicker from '@/Components/LabelPicker.vue'
|
||||
import AgendaSettings from './Settings/AgendaSettings.vue'
|
||||
import ExportProFiles from './Settings/ExportProFiles.vue'
|
||||
import LabelImport from './Settings/LabelImport.vue'
|
||||
import MacroAssignments from './Settings/MacroAssignments.vue'
|
||||
import MacroImport from './Settings/MacroImport.vue'
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
settings: { type: Object, default: () => ({}) },
|
||||
|
|
@ -15,15 +18,16 @@ const props = defineProps({
|
|||
collections: { type: Array, default: () => [] },
|
||||
last_macros_import: { type: Object, default: () => ({}) },
|
||||
last_labels_import: { type: Object, default: () => ({}) },
|
||||
export_pro_files: { type: Object, default: () => ({ prefix: [], postfix: [] }) },
|
||||
})
|
||||
|
||||
const submenus = [
|
||||
{ key: 'assignments', label: 'Makro-Zuweisungen' },
|
||||
{ key: 'macros', label: 'Makro-Import' },
|
||||
{ key: 'labels', label: 'Label-Import' },
|
||||
{ key: 'macros', label: 'Makro' },
|
||||
{ key: 'labels', label: 'Label' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
{ key: 'ccli', label: 'CCLI Import' },
|
||||
{ key: 'namenseinblender', label: 'Namenseinblender' },
|
||||
{ key: 'export-files', label: 'Export-Dateien' },
|
||||
]
|
||||
|
||||
const activeSubmenu = ref('assignments')
|
||||
|
|
@ -68,6 +72,22 @@ async function updateSetting(key, value) {
|
|||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Task 5: Namenseinblender macro picker (moved into assignments panel)
|
||||
const nametagMacroId = ref(props.settings.namenseinblender_macro_id ? Number(props.settings.namenseinblender_macro_id) : null)
|
||||
watch(nametagMacroId, (value) => {
|
||||
updateSetting('namenseinblender_macro_id', value === null ? '' : String(value))
|
||||
})
|
||||
|
||||
// Task 6: Song prefix/postfix label defaults
|
||||
const songPrefixLabelId = ref(props.settings.song_prefix_label_id ? Number(props.settings.song_prefix_label_id) : null)
|
||||
const songPostfixLabelId = ref(props.settings.song_postfix_label_id ? Number(props.settings.song_postfix_label_id) : null)
|
||||
watch(songPrefixLabelId, (value) => {
|
||||
updateSetting('song_prefix_label_id', value === null ? '' : String(value))
|
||||
})
|
||||
watch(songPostfixLabelId, (value) => {
|
||||
updateSetting('song_postfix_label_id', value === null ? '' : String(value))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -125,15 +145,31 @@ async function updateSetting(key, value) {
|
|||
</div>
|
||||
|
||||
<!-- Content panel -->
|
||||
<div class="flex-1 p-6" data-testid="settings-active-panel">
|
||||
<div class="flex-1 overflow-y-auto p-6 max-h-[calc(100vh-12rem)]" data-testid="settings-active-panel">
|
||||
<!-- Makro-Zuweisungen: includes Namenseinblender at top -->
|
||||
<div v-if="activeSubmenu === 'assignments'" class="space-y-6">
|
||||
<!-- Task 5: Namenseinblender MacroPicker at top of assignments -->
|
||||
<div class="rounded-lg border border-gray-200 p-4">
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Namenseinblender</h3>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
Wähle das ProPresenter-Makro für die Namenseinblendung bei Moderation und Predigt. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
|
||||
</p>
|
||||
<MacroPicker
|
||||
v-model="nametagMacroId"
|
||||
:macros="macros"
|
||||
:collections="collections"
|
||||
data-testid="namenseinblender-macro-picker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MacroAssignments
|
||||
v-if="activeSubmenu === 'assignments'"
|
||||
:assignments="assignments"
|
||||
:macros="macros"
|
||||
:labels="labels"
|
||||
:collections="collections"
|
||||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MacroImport
|
||||
v-if="activeSubmenu === 'macros'"
|
||||
|
|
@ -143,12 +179,40 @@ async function updateSetting(key, value) {
|
|||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
|
||||
<!-- Label panel: import + prefix/postfix defaults -->
|
||||
<div v-if="activeSubmenu === 'labels'" class="space-y-6">
|
||||
<LabelImport
|
||||
v-if="activeSubmenu === 'labels'"
|
||||
:labels="labels"
|
||||
:last_labels_import="last_labels_import"
|
||||
/>
|
||||
|
||||
<!-- Task 6: Song prefix/postfix label defaults -->
|
||||
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Standard-Labels für Songs</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Diese Labels werden bei jedem Song-Export automatisch als Prefix (z. B. COPYRIGHT) und Postfix (z. B. BLANK) angehängt.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Prefix-Label (Standard: COPYRIGHT)</label>
|
||||
<LabelPicker
|
||||
v-model="songPrefixLabelId"
|
||||
:labels="labels"
|
||||
data-testid="song-prefix-label-picker"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Postfix-Label (Standard: BLANK)</label>
|
||||
<LabelPicker
|
||||
v-model="songPostfixLabelId"
|
||||
:labels="labels"
|
||||
data-testid="song-postfix-label-picker"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AgendaSettings
|
||||
v-if="activeSubmenu === 'agenda'"
|
||||
:settings="settings"
|
||||
|
|
@ -224,60 +288,13 @@ async function updateSetting(key, value) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Namenseinblender Macro Settings -->
|
||||
<div v-if="activeSubmenu === 'namenseinblender'" class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900">Namenseinblender-Makro</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Konfiguriere das ProPresenter-Makro, das für die Namenseinblendung bei Moderation und Predigt verwendet wird. Wenn kein Makro konfiguriert ist, werden keine Namensfolien generiert.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 p-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-Name</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="settings.namenseinblender_macro_name || ''"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="z.B. Namenseinblender"
|
||||
data-testid="namenseinblender-macro-name"
|
||||
@change="updateSetting('namenseinblender_macro_name', $event.target.value)"
|
||||
<!-- Task 7: Export-Dateien submenu -->
|
||||
<ExportProFiles
|
||||
v-if="activeSubmenu === 'export-files'"
|
||||
:prefix-files="export_pro_files.prefix"
|
||||
:postfix-files="export_pro_files.postfix"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Makro-UUID</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="settings.namenseinblender_macro_uuid || ''"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
|
||||
placeholder="UUID des Makros"
|
||||
data-testid="namenseinblender-macro"
|
||||
@change="updateSetting('namenseinblender_macro_uuid', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-Name</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="settings.namenseinblender_macro_collection_name || '--MAIN--'"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
placeholder="--MAIN--"
|
||||
@change="updateSetting('namenseinblender_macro_collection_name', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Collection-UUID</label>
|
||||
<input
|
||||
type="text"
|
||||
:value="settings.namenseinblender_macro_collection_uuid || ''"
|
||||
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-blue-500 focus:ring-blue-500 font-mono"
|
||||
placeholder="UUID der Collection"
|
||||
@change="updateSetting('namenseinblender_macro_collection_uuid', $event.target.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
226
resources/js/Pages/Settings/ExportProFiles.vue
Normal file
226
resources/js/Pages/Settings/ExportProFiles.vue
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
prefixFiles: { type: Array, default: () => [] },
|
||||
postfixFiles: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const prefixDragActive = ref(false)
|
||||
const postfixDragActive = ref(false)
|
||||
const uploading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
function getXsrfToken() {
|
||||
return decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '')
|
||||
}
|
||||
|
||||
async function uploadFiles(type, files) {
|
||||
if (!files || files.length === 0) return
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
|
||||
const form = new FormData()
|
||||
form.append('type', type)
|
||||
for (const file of files) {
|
||||
form.append('files[]', file)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(route('settings.export-pro-files.store'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getXsrfToken(),
|
||||
},
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
error.value = data.message || 'Upload fehlgeschlagen'
|
||||
return
|
||||
}
|
||||
window.location.reload()
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler beim Upload'
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrefixChange(event) {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
event.target.value = ''
|
||||
uploadFiles('prefix', files)
|
||||
}
|
||||
|
||||
function handlePostfixChange(event) {
|
||||
const files = event.target.files
|
||||
if (!files || files.length === 0) return
|
||||
event.target.value = ''
|
||||
uploadFiles('postfix', files)
|
||||
}
|
||||
|
||||
function handlePrefixDrop(event) {
|
||||
prefixDragActive.value = false
|
||||
uploadFiles('prefix', event.dataTransfer.files)
|
||||
}
|
||||
|
||||
function handlePostfixDrop(event) {
|
||||
postfixDragActive.value = false
|
||||
uploadFiles('postfix', event.dataTransfer.files)
|
||||
}
|
||||
|
||||
async function deleteFile(id) {
|
||||
try {
|
||||
await fetch(route('settings.export-pro-files.destroy', id), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-XSRF-TOKEN': getXsrfToken(),
|
||||
},
|
||||
})
|
||||
window.location.reload()
|
||||
} catch {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Export-Dateien</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
Diese .pro-Dateien werden bei jedem Export vorne (Prefix) bzw. hinten (Postfix) an den Gottesdienst angehängt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="rounded-lg bg-red-50 p-3 text-sm text-red-700"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Prefix section -->
|
||||
<div>
|
||||
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Prefix-Dateien</h4>
|
||||
<p class="mb-3 text-xs text-gray-400">Werden vor dem Gottesdienst eingefügt.</p>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
|
||||
:class="prefixDragActive
|
||||
? 'border-amber-400 bg-amber-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
|
||||
data-testid="export-prefix-dropzone"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="prefixDragActive = true"
|
||||
@dragleave.prevent="prefixDragActive = false"
|
||||
@drop.prevent="handlePrefixDrop"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".pro"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
data-testid="export-prefix-file-input"
|
||||
@change="handlePrefixChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="prefixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||
<div
|
||||
v-for="file in prefixFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span>
|
||||
<span class="text-xs text-gray-400">#{{ file.order + 1 }}</span>
|
||||
<button
|
||||
class="ml-2 text-gray-400 hover:text-red-500"
|
||||
:data-testid="'export-pro-file-delete-' + file.id"
|
||||
@click="deleteFile(file.id)"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-400">Noch keine Prefix-Dateien hochgeladen.</p>
|
||||
</div>
|
||||
|
||||
<!-- Postfix section -->
|
||||
<div>
|
||||
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Postfix-Dateien</h4>
|
||||
<p class="mb-3 text-xs text-gray-400">Werden nach dem Gottesdienst angehängt.</p>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
|
||||
:class="postfixDragActive
|
||||
? 'border-amber-400 bg-amber-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
|
||||
data-testid="export-postfix-dropzone"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="postfixDragActive = true"
|
||||
@dragleave.prevent="postfixDragActive = false"
|
||||
@drop.prevent="handlePostfixDrop"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ uploading ? 'Wird hochgeladen...' : '.pro-Dateien auswählen oder hierher ziehen' }}
|
||||
</span>
|
||||
<span class="mt-1 text-xs text-gray-400">Mehrere Dateien möglich</span>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".pro"
|
||||
multiple
|
||||
:disabled="uploading"
|
||||
data-testid="export-postfix-file-input"
|
||||
@change="handlePostfixChange"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div v-if="postfixFiles.length > 0" class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||
<div
|
||||
v-for="file in postfixFiles"
|
||||
:key="file.id"
|
||||
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
<span class="flex-1 truncate text-gray-700">{{ file.original_name }}</span>
|
||||
<span class="text-xs text-gray-400">#{{ file.order + 1 }}</span>
|
||||
<button
|
||||
class="ml-2 text-gray-400 hover:text-red-500"
|
||||
:data-testid="'export-pro-file-delete-' + file.id"
|
||||
@click="deleteFile(file.id)"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-gray-400">Noch keine Postfix-Dateien hochgeladen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -10,11 +10,11 @@ const props = defineProps({
|
|||
const uploading = ref(false)
|
||||
const result = ref(null)
|
||||
const error = ref(null)
|
||||
const dragActive = ref(false)
|
||||
|
||||
const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
async function uploadFile(file) {
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
|
|
@ -38,7 +38,6 @@ async function handleFileChange(event) {
|
|||
return
|
||||
}
|
||||
result.value = await res.json()
|
||||
event.target.value = ''
|
||||
window.location.reload()
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler beim Upload'
|
||||
|
|
@ -46,11 +45,24 @@ async function handleFileChange(event) {
|
|||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
event.target.value = ''
|
||||
uploadFile(file)
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
dragActive.value = false
|
||||
const file = event.dataTransfer.files[0]
|
||||
uploadFile(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label</h3>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-500">
|
||||
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||
|
|
@ -71,14 +83,26 @@ async function handleFileChange(event) {
|
|||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Task 4: Hint about importing both Labels and Groups files -->
|
||||
<div class="mb-4 rounded-lg border border-blue-200 bg-blue-50 p-3 text-xs text-blue-800">
|
||||
<strong>Hinweis:</strong> Importiere sowohl die Datei „Labels" als auch die Datei „Groups", damit sowohl die automatischen als auch die manuellen Labels verfügbar sind.
|
||||
</div>
|
||||
|
||||
<div v-if="last_labels_import?.at" class="mb-4 text-xs text-gray-400">
|
||||
Letzter Import: {{ last_labels_import.at }}
|
||||
<span v-if="last_labels_import.filename">({{ last_labels_import.filename }})</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
|
||||
:class="dragActive
|
||||
? 'border-amber-400 bg-amber-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
|
||||
data-testid="labels-upload-area"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const uploading = ref(false)
|
|||
const result = ref(null)
|
||||
const error = ref(null)
|
||||
const selectedCollection = ref(null)
|
||||
const dragActive = ref(false)
|
||||
|
||||
const filteredMacros = computed(() => {
|
||||
if (!selectedCollection.value) return props.macros
|
||||
|
|
@ -23,8 +24,7 @@ const filteredMacros = computed(() => {
|
|||
return props.macros.filter((m) => ids.includes(m.id))
|
||||
})
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
async function uploadFile(file) {
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
error.value = null
|
||||
|
|
@ -48,7 +48,6 @@ async function handleFileChange(event) {
|
|||
return
|
||||
}
|
||||
result.value = await res.json()
|
||||
event.target.value = ''
|
||||
window.location.reload()
|
||||
} catch {
|
||||
error.value = 'Netzwerkfehler beim Upload'
|
||||
|
|
@ -56,11 +55,24 @@ async function handleFileChange(event) {
|
|||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
event.target.value = ''
|
||||
uploadFile(file)
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
dragActive.value = false
|
||||
const file = event.dataTransfer.files[0]
|
||||
uploadFile(file)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3>
|
||||
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro</h3>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-500">
|
||||
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||
|
|
@ -87,8 +99,15 @@ async function handleFileChange(event) {
|
|||
</div>
|
||||
|
||||
<label
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50 p-6 transition-colors hover:border-amber-300 hover:bg-amber-50"
|
||||
class="mb-4 flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors"
|
||||
:class="dragActive
|
||||
? 'border-amber-400 bg-amber-50'
|
||||
: 'border-gray-200 bg-gray-50 hover:border-amber-300 hover:bg-amber-50'"
|
||||
data-testid="macros-upload-area"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<svg class="mb-2 h-8 w-8 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ const props = defineProps({
|
|||
const editSongId = ref(null)
|
||||
|
||||
function handleClose() {
|
||||
// Don't navigate away if an edit is opening (import & edit flow).
|
||||
if (editSongId.value !== null) return
|
||||
|
||||
router.visit(route('songs.index'))
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +25,14 @@ function handleImported(songId, mode) {
|
|||
router.visit(route('songs.index'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditSong(id) {
|
||||
editSongId.value = id
|
||||
}
|
||||
|
||||
function handleEditClose() {
|
||||
router.visit(route('songs.index'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -48,18 +59,19 @@ function handleImported(songId, mode) {
|
|||
|
||||
<!-- Always-open paste dialog on this page -->
|
||||
<CcliPasteDialog
|
||||
:open="true"
|
||||
:open="editSongId === null"
|
||||
mode="songdb"
|
||||
:prefilled-text="prefilledText"
|
||||
:auto-edit="prefilledText !== null"
|
||||
@close="handleClose"
|
||||
@imported="handleImported"
|
||||
@edit-song="(id) => { editSongId = id }"
|
||||
@edit-song="handleEditSong"
|
||||
/>
|
||||
|
||||
<SongEditModal
|
||||
:show="editSongId !== null"
|
||||
:song-id="editSongId"
|
||||
@close="() => router.visit(route('songs.index'))"
|
||||
@close="handleEditClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\BookmarkletController;
|
||||
use App\Http\Controllers\CcliPasteController;
|
||||
use App\Http\Controllers\ExportProFileController;
|
||||
use App\Http\Controllers\LabelImportController;
|
||||
use App\Http\Controllers\MacroAssignmentController;
|
||||
use App\Http\Controllers\MacroImportController;
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
use App\Http\Controllers\SongSectionController;
|
||||
use App\Http\Controllers\SyncController;
|
||||
use App\Http\Controllers\TranslationController;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
|
@ -54,7 +56,13 @@
|
|||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('dashboard');
|
||||
$service = Service::nextUpcoming()->first();
|
||||
|
||||
if ($service) {
|
||||
return redirect()->route('services.edit', $service->id);
|
||||
}
|
||||
|
||||
return redirect()->route('services.index');
|
||||
});
|
||||
|
||||
Route::get('/dashboard', function () {
|
||||
|
|
@ -66,6 +74,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-preview', [ServiceController::class, 'downloadPreview'])->name('services.download-preview');
|
||||
Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle');
|
||||
Route::get('/services/{service}/agenda-items/{agendaItem}/download', [ServiceController::class, 'downloadAgendaItem'])->name('services.agenda-item.download');
|
||||
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
||||
|
|
@ -115,6 +124,8 @@
|
|||
| Makro- und Label-Import (ProPresenter)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::post('/settings/export-pro-files', [ExportProFileController::class, 'store'])->name('settings.export-pro-files.store');
|
||||
Route::delete('/settings/export-pro-files/{exportProFile}', [ExportProFileController::class, 'destroy'])->name('settings.export-pro-files.destroy');
|
||||
Route::post('/settings/macros/import', [MacroImportController::class, 'store'])->name('settings.macros.import');
|
||||
Route::post('/settings/labels/import', [LabelImportController::class, 'store'])->name('settings.labels.import');
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ function ccliFixture(string $name): string
|
|||
|
||||
expect($arrangement)->not->toBeNull()
|
||||
->and($arrangement->is_default)->toBeTrue()
|
||||
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5)
|
||||
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(7)
|
||||
->and(SongSlide::count())->toBe(5);
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ function ccliFixture(string $name): string
|
|||
->and($song->title)->toBe('Heilig ist der Herr')
|
||||
->and($song->author)->toBe('Albert Frey')
|
||||
->and($arrangement)->not->toBeNull()
|
||||
->and($arrangement->arrangementSections)->toHaveCount(2)
|
||||
->and($arrangement->arrangementSections)->toHaveCount(4)
|
||||
->and(SongSlide::count())->toBe(7);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
|
||||
test('home route redirects unauthenticated users to login', function () {
|
||||
$response = $this->get('/');
|
||||
|
|
@ -8,10 +10,23 @@
|
|||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
test('home route redirects authenticated users to dashboard', function () {
|
||||
test('home route redirects authenticated users to next upcoming service edit page', function () {
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create(['date' => now()->addWeek()]);
|
||||
|
||||
$response = $this->actingAs($user)->get('/');
|
||||
|
||||
$response->assertRedirect(route('services.edit', $service->id));
|
||||
});
|
||||
|
||||
test('home route redirects authenticated users to services index when no upcoming service exists', function () {
|
||||
Carbon::setTestNow('2026-06-01 10:00:00');
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/');
|
||||
|
||||
$response->assertRedirect(route('dashboard'));
|
||||
$response->assertRedirect(route('services.index'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ protected function setUp(): void
|
|||
Storage::fake('public');
|
||||
}
|
||||
|
||||
public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisual_fallback(): void
|
||||
public function test_non_song_agenda_item_without_slides_becomes_headline(): void
|
||||
{
|
||||
Storage::disk('public')->put('slides/keyvisual.jpg', 'keyvisual-image');
|
||||
|
||||
|
|
@ -48,18 +48,24 @@ public function test_non_song_agenda_item_without_slides_gets_ephemeral_keyvisua
|
|||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||
$fallbackSong = $playlist->getEmbeddedSong('Begrüßung.pro');
|
||||
|
||||
// Content-less item before any real content → HEADLINE (not keyvisual fallback)
|
||||
$this->assertCount(1, $embeddedProFiles);
|
||||
$this->assertNotNull($fallbackSong);
|
||||
$this->assertSame($slideCountBefore, Slide::count());
|
||||
|
||||
$slides = $this->allParserSlides($fallbackSong);
|
||||
$this->assertCount(1, $slides);
|
||||
$this->assertTrue($slides[0]->hasBackgroundMedia());
|
||||
$this->assertSame('KEY_VISUAL.jpg', $slides[0]->getBackgroundMediaUrl());
|
||||
$this->assertArrayHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
$this->assertSame('keyvisual-image', $playlist->getEmbeddedMediaFiles()['KEY_VISUAL.jpg']);
|
||||
// Headline filename matches pattern: Begrüßung-headline-<uniqid>.pro
|
||||
$headlineKey = null;
|
||||
foreach (array_keys($embeddedProFiles) as $key) {
|
||||
if (str_contains($key, 'Begrüßung') && str_contains($key, '-headline-')) {
|
||||
$headlineKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung');
|
||||
$this->assertMatchesRegularExpression('/^Begr[üu].*-headline-.*\.pro$/', $headlineKey);
|
||||
|
||||
// No KEY_VISUAL.jpg embedded (headline has no background media)
|
||||
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
|
@ -149,7 +155,7 @@ public function test_sermon_agenda_item_with_uploaded_slides_prepends_keyvisual_
|
|||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entry(): void
|
||||
public function test_empty_non_song_item_before_content_becomes_headline(): void
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'title' => 'Ohne Keyvisual',
|
||||
|
|
@ -181,10 +187,25 @@ public function test_without_keyvisual_empty_non_song_item_adds_no_playlist_entr
|
|||
|
||||
$result = app(PlaylistExportService::class)->generatePlaylist($service);
|
||||
$playlist = ProPlaylistReader::read($result['path']);
|
||||
$embeddedProFiles = $playlist->getEmbeddedProFiles();
|
||||
|
||||
$this->assertCount(1, $playlist->getEmbeddedProFiles());
|
||||
// Content-less item before real content → HEADLINE (adds a playlist entry)
|
||||
// So we expect 2 embedded pro files: song + headline
|
||||
$this->assertCount(2, $embeddedProFiles);
|
||||
$this->assertNotNull($playlist->getEmbeddedSong('Vorhandenes Lied.pro'));
|
||||
$this->assertNull($playlist->getEmbeddedSong('Begrüßung ohne Folien.pro'));
|
||||
|
||||
// Headline filename matches pattern: Begrüßung ohne Folien-headline-<uniqid>.pro
|
||||
$headlineKey = null;
|
||||
foreach (array_keys($embeddedProFiles) as $key) {
|
||||
if (str_contains($key, 'Begrüßung ohne Folien') && str_contains($key, '-headline-')) {
|
||||
$headlineKey = $key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$this->assertNotNull($headlineKey, 'Expected a headline .pro file for Begrüßung ohne Folien');
|
||||
|
||||
// No KEY_VISUAL.jpg embedded (no keyvisual configured)
|
||||
$this->assertArrayNotHasKey('KEY_VISUAL.jpg', $playlist->getEmbeddedMediaFiles());
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,3 +138,42 @@
|
|||
|
||||
expect($name)->toBeNull();
|
||||
});
|
||||
|
||||
test('worship leader resolves from lobpreis agenda item responsibles', function () {
|
||||
$service = Service::factory()->create();
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'responsible' => [['name' => 'Moderator Max']],
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Lobpreis',
|
||||
'is_before_event' => false,
|
||||
'responsible' => [['name' => 'Lea Leiter']],
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
|
||||
|
||||
expect($name)->toBe('Lea Leiter');
|
||||
});
|
||||
|
||||
test('worship leader returns null without a lobpreis agenda item', function () {
|
||||
$service = Service::factory()->create();
|
||||
|
||||
ServiceAgendaItem::factory()->create([
|
||||
'service_id' => $service->id,
|
||||
'title' => 'Begrüßung',
|
||||
'is_before_event' => false,
|
||||
'responsible' => [['name' => 'Moderator Max']],
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
|
||||
$name = app(NameTagResolver::class)->worshipLeaderFor($service);
|
||||
|
||||
expect($name)->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,8 +78,7 @@ private function createSlide(array $attributes): Slide
|
|||
|
||||
private function createTestableExportService(): PlaylistExportService
|
||||
{
|
||||
return new class extends PlaylistExportService
|
||||
{
|
||||
return new class () extends PlaylistExportService {
|
||||
protected function writeProFile(string $path, string $name, array $groups, array $arrangements): void
|
||||
{
|
||||
file_put_contents($path, 'mock-pro-file:'.$name);
|
||||
|
|
@ -375,7 +374,7 @@ public function test_agenda_export_informationen_am_anfang_als_fallback(): void
|
|||
$this->cleanupTempDir($result['temp_dir']);
|
||||
}
|
||||
|
||||
public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs(): void
|
||||
public function test_agenda_export_content_less_items_werden_als_headline_oder_uebersprungen(): void
|
||||
{
|
||||
|
||||
$service = Service::factory()->create(['finalized_at' => now(), 'title' => 'Skip Service']);
|
||||
|
|
@ -419,8 +418,11 @@ public function test_agenda_export_ueberspringt_items_ohne_slides_oder_songs():
|
|||
$this->assertEquals(0, $result['skipped']);
|
||||
|
||||
$playlistContent = file_get_contents($result['path']);
|
||||
// Song always appears
|
||||
$this->assertStringContainsString('Einziger Song', $playlistContent);
|
||||
$this->assertStringNotContainsString('Begrüßung', $playlistContent);
|
||||
// Begrüßung (before real content) → HEADLINE → appears in playlist
|
||||
$this->assertStringContainsString('Begrüßung', $playlistContent);
|
||||
// Gebet (first content-less item after real content, no keyvisual) → omitted
|
||||
$this->assertStringNotContainsString('Gebet', $playlistContent);
|
||||
|
||||
$this->cleanupTempDir($result['temp_dir']);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ public function test_import_pro_datei_erstellt_song_mit_gruppen_und_slides(): vo
|
|||
$song = Song::where('title', 'Test')->first();
|
||||
$this->assertNotNull($song);
|
||||
|
||||
$this->assertSame(4, \App\Models\Label::count());
|
||||
$this->assertSame(6, \App\Models\Label::count());
|
||||
$this->assertSame(5, \App\Models\SongSlide::count());
|
||||
|
||||
$this->assertSame(2, $song->arrangements()->count());
|
||||
|
|
@ -127,6 +127,6 @@ public function test_import_pro_erstellt_arrangement_gruppen(): void
|
|||
|
||||
$this->assertNotNull($normalArrangement);
|
||||
$this->assertTrue($normalArrangement->is_default);
|
||||
$this->assertSame(5, $normalArrangement->arrangementLabels()->count());
|
||||
$this->assertSame(7, $normalArrangement->arrangementLabels()->count());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,5 +45,8 @@
|
|||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('Destruktive Migration');
|
||||
|
||||
Artisan::call('migrate:rollback', ['--step' => 1]);
|
||||
// The guarded migration (create_song_sections_and_rescope_slides) is 3rd from the top
|
||||
// of the migration stack (2 newer migrations were added after it).
|
||||
// Rolling back 3 steps reaches the destructive guard and triggers the RuntimeException.
|
||||
Artisan::call('migrate:rollback', ['--step' => 3]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue