feat(settings): add macro configuration infrastructure

- Migration: settings table with key (unique), value (text), timestamps
- Model: Setting with static get/set helpers using DB upsert
- Controller: SettingsController with index (Inertia) and update (JSON)
- Vue: Settings page with 4 macro fields, auto-save on blur
- Navigation: Einstellungen link in desktop + mobile nav after API-Log
- Shared props: macroSettings added to HandleInertiaRequests
- Integration: ProExportService injects macro into COPYRIGHT group slides
- Gracefully skips macro injection when settings empty/null
This commit is contained in:
Thorsten Bus 2026-03-02 22:00:19 +01:00
parent 44d0daf246
commit fefa761748
10 changed files with 306 additions and 6 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View file

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class SettingsController extends Controller
{
private const MACRO_KEYS = [
'macro_name',
'macro_uuid',
'macro_collection_name',
'macro_collection_uuid',
];
public function index(): Response
{
$settings = [];
foreach (self::MACRO_KEYS as $key) {
$settings[$key] = Setting::get($key);
}
return Inertia::render('Settings', [
'settings' => $settings,
]);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'key' => ['required', 'string', 'in:'.implode(',', self::MACRO_KEYS)],
'value' => ['nullable', 'string', 'max:500'],
]);
Setting::set($validated['key'], $validated['value']);
return response()->json(['success' => true]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\CtsSyncLog; use App\Models\CtsSyncLog;
use App\Models\Setting;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Middleware; use Inertia\Middleware;
@ -46,6 +47,12 @@ public function share(Request $request): array
], ],
'last_synced_at' => CtsSyncLog::latest()->first()?->synced_at, 'last_synced_at' => CtsSyncLog::latest()->first()?->synced_at,
'app_name' => config('app.name'), 'app_name' => config('app.name'),
'macroSettings' => [
'name' => Setting::get('macro_name'),
'uuid' => Setting::get('macro_uuid'),
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
],
]; ];
} }
} }

29
app/Models/Setting.php Normal file
View file

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Setting extends Model
{
protected $fillable = [
'key',
'value',
];
public static function get(string $key, ?string $default = null): ?string
{
$setting = DB::table('settings')->where('key', $key)->first();
return $setting?->value ?? $default;
}
public static function set(string $key, ?string $value): void
{
DB::table('settings')->updateOrInsert(
['key' => $key],
['value' => $value, 'updated_at' => now()],
);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Setting;
use App\Models\Song; use App\Models\Song;
use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileGenerator;
@ -15,7 +16,7 @@ public function generateProFile(Song $song): string
$arrangements = $this->buildArrangements($song); $arrangements = $this->buildArrangements($song);
$ccli = $this->buildCcliMetadata($song); $ccli = $this->buildCcliMetadata($song);
$tempPath = sys_get_temp_dir() . '/' . uniqid('pro-export-') . '.pro'; $tempPath = sys_get_temp_dir().'/'.uniqid('pro-export-').'.pro';
ProFileGenerator::generateAndWrite($tempPath, $song->title, $groups, $arrangements, $ccli); ProFileGenerator::generateAndWrite($tempPath, $song->title, $groups, $arrangements, $ccli);
@ -25,9 +26,11 @@ public function generateProFile(Song $song): string
private function buildGroups(Song $song): array private function buildGroups(Song $song): array
{ {
$groups = []; $groups = [];
$macroData = $this->buildMacroData();
foreach ($song->groups->sortBy('order') as $group) { foreach ($song->groups->sortBy('order') as $group) {
$slides = []; $slides = [];
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
foreach ($group->slides->sortBy('order') as $slide) { foreach ($group->slides->sortBy('order') as $slide) {
$slideData = ['text' => $slide->text_content ?? '']; $slideData = ['text' => $slide->text_content ?? ''];
@ -36,6 +39,10 @@ private function buildGroups(Song $song): array
$slideData['translation'] = $slide->text_content_translated; $slideData['translation'] = $slide->text_content_translated;
} }
if ($isCopyrightGroup && $macroData) {
$slideData['macro'] = $macroData;
}
$slides[] = $slideData; $slides[] = $slideData;
} }
@ -49,6 +56,23 @@ private function buildGroups(Song $song): array
return $groups; return $groups;
} }
private function buildMacroData(): ?array
{
$name = Setting::get('macro_name');
$uuid = Setting::get('macro_uuid');
if (! $name || ! $uuid) {
return null;
}
return [
'name' => $name,
'uuid' => $uuid,
'collectionName' => Setting::get('macro_collection_name', '--MAIN--'),
'collectionUuid' => Setting::get('macro_collection_uuid', '8D02FC57-83F8-4042-9B90-81C229728426'),
];
}
private function buildArrangements(Song $song): array private function buildArrangements(Song $song): array
{ {
$arrangements = []; $arrangements = [];

View file

@ -0,0 +1,23 @@
<?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('settings', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->text('value')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('settings');
}
};

View file

@ -115,6 +115,13 @@ function triggerSync() {
> >
API-Log API-Log
</NavLink> </NavLink>
<NavLink
data-testid="auth-layout-nav-settings"
:href="route('settings.index')"
:active="route().current('settings.*')"
>
Einstellungen
</NavLink>
<a <a
v-if="!hasSongsRoute" v-if="!hasSongsRoute"
href="#" href="#"
@ -277,6 +284,13 @@ function triggerSync() {
> >
API-Log API-Log
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-settings"
:href="route('settings.index')"
:active="route().current('settings.*')"
>
Einstellungen
</ResponsiveNavLink>
</div> </div>
<!-- Mobile Sync --> <!-- Mobile Sync -->

View file

@ -0,0 +1,155 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
import { Head } from '@inertiajs/vue3'
import { ref, reactive } from 'vue'
const props = defineProps({
settings: {
type: Object,
default: () => ({}),
},
})
const fields = [
{ key: 'macro_name', label: 'Makro-Name', placeholder: 'z.B. Copyright Makro' },
{ key: 'macro_uuid', label: 'Makro-UUID', placeholder: 'z.B. 11111111-2222-3333-4444-555555555555' },
{ key: 'macro_collection_name', label: 'Collection-Name', defaultValue: '--MAIN--' },
{ key: 'macro_collection_uuid', label: 'Collection-UUID', defaultValue: '8D02FC57-83F8-4042-9B90-81C229728426' },
]
const form = reactive({})
for (const field of fields) {
form[field.key] = props.settings[field.key] ?? field.defaultValue ?? ''
}
const saving = reactive({})
const saved = reactive({})
const errors = reactive({})
async function saveField(key) {
if (saving[key]) return
saving[key] = true
errors[key] = null
saved[key] = false
try {
const response = await fetch(route('settings.update'), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-XSRF-TOKEN': decodeURIComponent(
document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? '',
),
},
body: JSON.stringify({ key, value: form[key] || null }),
})
if (!response.ok) {
const data = await response.json()
errors[key] = data.message || 'Speichern fehlgeschlagen'
return
}
saved[key] = true
setTimeout(() => { saved[key] = false }, 2000)
} catch {
errors[key] = 'Netzwerkfehler beim Speichern'
} finally {
saving[key] = false
}
}
</script>
<template>
<Head title="Einstellungen" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800">
Einstellungen
</h2>
</template>
<div class="py-8">
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm">
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
<h3 class="text-sm font-semibold text-gray-900">
ProPresenter Makro-Konfiguration
</h3>
<p class="mt-1 text-xs text-gray-500">
Diese Einstellungen werden beim Export auf Copyright-Folien als Makro-Aktion angewendet.
</p>
</div>
<div class="divide-y divide-gray-100 px-6">
<div
v-for="field in fields"
:key="field.key"
class="py-5"
>
<label
:for="'setting-' + field.key"
class="block text-sm font-medium text-gray-700"
>
{{ field.label }}
</label>
<div class="relative mt-1.5">
<input
:id="'setting-' + field.key"
:data-testid="'setting-' + field.key"
v-model="form[field.key]"
type="text"
:placeholder="field.placeholder || field.defaultValue || ''"
class="block w-full rounded-lg border-gray-300 text-sm shadow-sm transition-colors focus:border-amber-400 focus:ring-amber-400/40"
:class="{
'border-red-300 focus:border-red-400 focus:ring-red-400/40': errors[field.key],
'border-emerald-300': saved[field.key],
}"
@blur="saveField(field.key)"
/>
<div
v-if="saving[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</div>
<div
v-else-if="saved[field.key]"
class="absolute inset-y-0 right-0 flex items-center pr-3"
>
<svg class="h-4 w-4 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<p
v-if="errors[field.key]"
class="mt-1.5 text-xs text-red-600"
:data-testid="'error-' + field.key"
>
{{ errors[field.key] }}
</p>
<p
v-if="field.defaultValue"
class="mt-1.5 text-xs text-gray-400"
>
Standard: {{ field.defaultValue }}
</p>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View file

@ -1,16 +1,15 @@
<?php <?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ApiLogController; use App\Http\Controllers\ApiLogController;
use App\Http\Controllers\SongPdfController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\ArrangementController;
use App\Http\Controllers\ServiceController; use App\Http\Controllers\ServiceController;
use App\Http\Controllers\SlideController; use App\Http\Controllers\SettingsController;
use App\Http\Controllers\SongPdfController;
use App\Http\Controllers\SyncController; use App\Http\Controllers\SyncController;
use App\Http\Controllers\TranslationController; use App\Http\Controllers\TranslationController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Support\Facades\Auth;
Route::middleware('guest')->group(function () { Route::middleware('guest')->group(function () {
Route::get('/login', [AuthController::class, 'showLogin'])->name('login'); Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
@ -33,6 +32,7 @@
] ]
); );
Auth::login($user); Auth::login($user);
return redirect()->route('dashboard'); return redirect()->route('dashboard');
})->name('dev-login'); })->name('dev-login');
}); });
@ -66,6 +66,9 @@
Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index'); Route::get('/api-logs', [ApiLogController::class, 'index'])->name('api-logs.index');
Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body'); Route::get('/api-logs/{log}/response-body', [ApiLogController::class, 'responseBody'])->name('api-logs.response-body');
Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index');
Route::patch('/settings', [SettingsController::class, 'update'])->name('settings.update');
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store'); Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone'); Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update'); Route::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
@ -81,6 +84,8 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
Route::post('/slides', '\\App\\Http\\Controllers\\SlideController@store')->name('slides.store'); Route::post('/slides', '\\App\\Http\\Controllers\\SlideController@store')->name('slides.store');
Route::delete('/slides/bulk', '\\App\\Http\\Controllers\\SlideController@destroyBulk')->name('slides.bulk-destroy');
Route::post('/slides/reorder', '\\App\\Http\\Controllers\\SlideController@reorder')->name('slides.reorder');
Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy'); Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy');
Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date'); Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
}); });