feat(settings): restructure Settings.vue into sidebar layout with 4 submenus + AgendaSettings.vue
This commit is contained in:
parent
6ce5b6e018
commit
c1cb9bf820
|
|
@ -1,70 +1,38 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import AgendaSettings from './Settings/AgendaSettings.vue'
|
||||
import { Head } from '@inertiajs/vue3'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
settings: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
settings: { type: Object, default: () => ({}) },
|
||||
assignments: { type: Array, default: () => [] },
|
||||
macros: { type: Array, default: () => [] },
|
||||
labels: { type: Array, default: () => [] },
|
||||
collections: { type: Array, default: () => [] },
|
||||
last_macros_import: { type: Object, default: () => ({}) },
|
||||
last_labels_import: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const fields = [
|
||||
// Macro configuration fields
|
||||
{ key: 'macro_name', label: 'Makro-Name', placeholder: 'z.B. Copyright Makro', section: 'macro' },
|
||||
{ key: 'macro_uuid', label: 'Makro-UUID', placeholder: 'z.B. 11111111-2222-3333-4444-555555555555', section: 'macro' },
|
||||
{ key: 'macro_collection_name', label: 'Collection-Name', defaultValue: '--MAIN--', section: 'macro' },
|
||||
{ key: 'macro_collection_uuid', label: 'Collection-UUID', defaultValue: '8D02FC57-83F8-4042-9B90-81C229728426', section: 'macro' },
|
||||
// Agenda configuration fields
|
||||
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*', section: 'agenda' },
|
||||
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*', section: 'agenda' },
|
||||
{ key: 'agenda_announcement_position', label: 'Ankündigungen-Position', placeholder: 'z.B. Informationen*,Hinweise*', helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.', section: 'agenda' },
|
||||
{ key: 'agenda_sermon_matching', label: 'Predigt-Erkennung', placeholder: 'z.B. Predigt*,Sermon*', helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.', section: 'agenda' },
|
||||
const submenus = [
|
||||
{ key: 'assignments', label: 'Makro-Zuweisungen' },
|
||||
{ key: 'macros', label: 'Makro-Import' },
|
||||
{ key: 'labels', label: 'Label-Import' },
|
||||
{ key: 'agenda', label: 'Agenda' },
|
||||
]
|
||||
|
||||
const form = reactive({})
|
||||
for (const field of fields) {
|
||||
form[field.key] = props.settings[field.key] ?? field.defaultValue ?? ''
|
||||
}
|
||||
const activeSubmenu = ref('assignments')
|
||||
|
||||
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
|
||||
onMounted(() => {
|
||||
const hash = window.location.hash.replace('#', '')
|
||||
if (submenus.some((s) => s.key === hash)) {
|
||||
activeSubmenu.value = hash
|
||||
}
|
||||
})
|
||||
|
||||
function switchSubmenu(key) {
|
||||
activeSubmenu.value = key
|
||||
window.location.hash = key
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -79,148 +47,86 @@ async function saveField(key) {
|
|||
</template>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-5xl 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.filter(f => f.section === 'macro')"
|
||||
: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"
|
||||
<div class="flex min-h-[400px]">
|
||||
<!-- Sidebar (desktop) -->
|
||||
<div class="hidden w-48 shrink-0 border-r border-gray-100 sm:block">
|
||||
<nav class="flex flex-col gap-1 p-2">
|
||||
<button
|
||||
v-for="item in submenus"
|
||||
:key="item.key"
|
||||
:data-testid="'settings-submenu-' + item.key"
|
||||
@click="switchSubmenu(item.key)"
|
||||
:class="[
|
||||
'w-full rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors',
|
||||
activeSubmenu === item.key
|
||||
? 'border-l-2 border-amber-500 bg-amber-50 text-amber-700'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm mt-6">
|
||||
<div class="border-b border-gray-100 bg-gray-50/50 px-6 py-4">
|
||||
<h3 class="text-sm font-semibold text-gray-900">Agenda-Konfiguration</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 px-6">
|
||||
<div v-for="field in fields.filter(f => f.section === 'agenda')" :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"
|
||||
<!-- Mobile tab bar -->
|
||||
<div class="w-full border-b border-gray-100 sm:hidden">
|
||||
<nav class="flex overflow-x-auto">
|
||||
<button
|
||||
v-for="item in submenus"
|
||||
:key="item.key"
|
||||
:data-testid="'settings-submenu-' + item.key"
|
||||
@click="switchSubmenu(item.key)"
|
||||
:class="[
|
||||
'shrink-0 whitespace-nowrap border-b-2 px-4 py-3 text-sm font-medium transition-colors',
|
||||
activeSubmenu === item.key
|
||||
? 'border-amber-500 text-amber-700'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900',
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</nav>
|
||||
</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>
|
||||
<!-- Content panel -->
|
||||
<div class="flex-1 p-6" data-testid="settings-active-panel">
|
||||
<!-- Makro-Zuweisungen — filled in by T3.5 -->
|
||||
<div v-if="activeSubmenu === 'assignments'">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
||||
Globale Makro-Zuweisungen
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Hier werden die globalen Makro-Zuweisungen konfiguriert.
|
||||
</p>
|
||||
</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>
|
||||
<!-- Makro-Import — filled in by T3.4 -->
|
||||
<div v-if="activeSubmenu === 'macros'">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
||||
Makro-Import
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Importiere eine ProPresenter Makro-Datei.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="field.helpText" class="mt-1.5 text-xs text-gray-400">{{ field.helpText }}</p>
|
||||
<!-- Label-Import — filled in by T3.3 -->
|
||||
<div v-if="activeSubmenu === 'labels'">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
||||
Label-Import
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
Importiere eine ProPresenter Labels-Datei.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="field.defaultValue"
|
||||
class="mt-1.5 text-xs text-gray-400"
|
||||
>
|
||||
Standard: {{ field.defaultValue }}
|
||||
</p>
|
||||
<!-- Agenda -->
|
||||
<AgendaSettings
|
||||
v-if="activeSubmenu === 'agenda'"
|
||||
:settings="settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
139
resources/js/Pages/Settings/AgendaSettings.vue
Normal file
139
resources/js/Pages/Settings/AgendaSettings.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
import { route } from 'ziggy-js'
|
||||
|
||||
const props = defineProps({
|
||||
settings: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const fields = [
|
||||
{ key: 'agenda_start_title', label: 'Ablauf-Start', placeholder: 'z.B. Ablauf* oder Beginn*' },
|
||||
{ key: 'agenda_end_title', label: 'Ablauf-Ende', placeholder: 'z.B. Ende* oder Schluss*' },
|
||||
{
|
||||
key: 'agenda_announcement_position',
|
||||
label: 'Ankündigungen-Position',
|
||||
placeholder: 'z.B. Informationen*,Hinweise*',
|
||||
helpText: 'Komma-getrennte Liste. Das erste passende Element im Ablauf bestimmt, wo die Ankündigungsfolien eingefügt werden. * als Platzhalter.',
|
||||
},
|
||||
{
|
||||
key: 'agenda_sermon_matching',
|
||||
label: 'Predigt-Erkennung',
|
||||
placeholder: 'z.B. Predigt*,Sermon*',
|
||||
helpText: 'Komma-getrennte Liste. Erkannte Elemente bekommen einen Predigt-Upload-Bereich. * als Platzhalter.',
|
||||
},
|
||||
]
|
||||
|
||||
const form = reactive({})
|
||||
for (const field of fields) {
|
||||
form[field.key] = props.settings[field.key] ?? ''
|
||||
}
|
||||
|
||||
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>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900 mb-1">
|
||||
Agenda-Konfiguration
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500 mb-4">
|
||||
Diese Einstellungen steuern, wie der Gottesdienst-Ablauf angezeigt und exportiert wird.
|
||||
</p>
|
||||
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="py-4"
|
||||
>
|
||||
<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 || ''"
|
||||
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.helpText"
|
||||
class="mt-1.5 text-xs text-gray-400"
|
||||
>
|
||||
{{ field.helpText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Loading…
Reference in a new issue