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:
parent
44d0daf246
commit
fefa761748
BIN
.sisyphus/evidence/task-7-nav-link.png
Normal file
BIN
.sisyphus/evidence/task-7-nav-link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
.sisyphus/evidence/task-7-settings-save.png
Normal file
BIN
.sisyphus/evidence/task-7-settings-save.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
43
app/Http/Controllers/SettingsController.php
Normal file
43
app/Http/Controllers/SettingsController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\CtsSyncLog;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
|
|
@ -46,6 +47,12 @@ public function share(Request $request): array
|
|||
],
|
||||
'last_synced_at' => CtsSyncLog::latest()->first()?->synced_at,
|
||||
'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
29
app/Models/Setting.php
Normal 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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use ProPresenter\Parser\ProFileGenerator;
|
||||
|
||||
|
|
@ -25,9 +26,11 @@ public function generateProFile(Song $song): string
|
|||
private function buildGroups(Song $song): array
|
||||
{
|
||||
$groups = [];
|
||||
$macroData = $this->buildMacroData();
|
||||
|
||||
foreach ($song->groups->sortBy('order') as $group) {
|
||||
$slides = [];
|
||||
$isCopyrightGroup = strcasecmp($group->name, 'COPYRIGHT') === 0;
|
||||
|
||||
foreach ($group->slides->sortBy('order') as $slide) {
|
||||
$slideData = ['text' => $slide->text_content ?? ''];
|
||||
|
|
@ -36,6 +39,10 @@ private function buildGroups(Song $song): array
|
|||
$slideData['translation'] = $slide->text_content_translated;
|
||||
}
|
||||
|
||||
if ($isCopyrightGroup && $macroData) {
|
||||
$slideData['macro'] = $macroData;
|
||||
}
|
||||
|
||||
$slides[] = $slideData;
|
||||
}
|
||||
|
||||
|
|
@ -49,6 +56,23 @@ private function buildGroups(Song $song): array
|
|||
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
|
||||
{
|
||||
$arrangements = [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -115,6 +115,13 @@ function triggerSync() {
|
|||
>
|
||||
API-Log
|
||||
</NavLink>
|
||||
<NavLink
|
||||
data-testid="auth-layout-nav-settings"
|
||||
:href="route('settings.index')"
|
||||
:active="route().current('settings.*')"
|
||||
>
|
||||
Einstellungen
|
||||
</NavLink>
|
||||
<a
|
||||
v-if="!hasSongsRoute"
|
||||
href="#"
|
||||
|
|
@ -277,6 +284,13 @@ function triggerSync() {
|
|||
>
|
||||
API-Log
|
||||
</ResponsiveNavLink>
|
||||
<ResponsiveNavLink
|
||||
data-testid="auth-layout-mobile-nav-settings"
|
||||
:href="route('settings.index')"
|
||||
:active="route().current('settings.*')"
|
||||
>
|
||||
Einstellungen
|
||||
</ResponsiveNavLink>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Sync -->
|
||||
|
|
|
|||
155
resources/js/Pages/Settings.vue
Normal file
155
resources/js/Pages/Settings.vue
Normal 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>
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\SongPdfController;
|
||||
use App\Http\Controllers\ArrangementController;
|
||||
use App\Http\Controllers\AuthController;
|
||||
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\TranslationController;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
|
||||
|
|
@ -33,6 +32,7 @@
|
|||
]
|
||||
);
|
||||
Auth::login($user);
|
||||
|
||||
return redirect()->route('dashboard');
|
||||
})->name('dev-login');
|
||||
});
|
||||
|
|
@ -66,6 +66,9 @@
|
|||
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('/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('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||
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::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::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue