feat(settings): add LabelImport, MacroImport, MacroAssignments, MacroPicker, LabelPicker components
This commit is contained in:
parent
c1cb9bf820
commit
f494a8a0d7
90
resources/js/Components/LabelPicker.vue
Normal file
90
resources/js/Components/LabelPicker.vue
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = defineModel({ type: Number, default: null })
|
||||||
|
const search = ref('')
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
const filtered = computed(() =>
|
||||||
|
props.labels.filter((l) => l.name.toLowerCase().includes(search.value.toLowerCase())),
|
||||||
|
)
|
||||||
|
|
||||||
|
const selected = computed(() => props.labels.find((l) => l.id === model.value))
|
||||||
|
|
||||||
|
function select(label) {
|
||||||
|
model.value = label.id
|
||||||
|
search.value = ''
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!props.disabled) isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setTimeout(() => {
|
||||||
|
isOpen.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': disabled }"
|
||||||
|
@click="open"
|
||||||
|
data-testid="label-picker-trigger"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selected?.color"
|
||||||
|
class="h-4 w-4 shrink-0 rounded"
|
||||||
|
:style="{ backgroundColor: selected.color }"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-gray-700">
|
||||||
|
{{ selected ? selected.name : 'Label auswählen...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||||
|
data-testid="label-picker-dropdown"
|
||||||
|
>
|
||||||
|
<div class="border-b border-gray-100 p-2">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Label suchen..."
|
||||||
|
class="w-full rounded border-gray-300 text-sm"
|
||||||
|
autofocus
|
||||||
|
@blur="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
<button
|
||||||
|
v-for="label in filtered"
|
||||||
|
:key="label.id"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
|
||||||
|
:class="label.hidden_at ? 'text-gray-400' : 'text-gray-700'"
|
||||||
|
:data-testid="'label-option-' + label.id"
|
||||||
|
@click="select(label)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 shrink-0 rounded"
|
||||||
|
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#ccc' }"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ label.name }}{{ label.hidden_at ? ' (deaktiviert)' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="filtered.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
|
||||||
|
Kein Label gefunden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
116
resources/js/Components/MacroPicker.vue
Normal file
116
resources/js/Components/MacroPicker.vue
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
macros: { type: Array, default: () => [] },
|
||||||
|
collections: { type: Array, default: () => [] },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = defineModel({ type: Number, default: null })
|
||||||
|
const search = ref('')
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
const filteredMacros = computed(() => {
|
||||||
|
const q = search.value.toLowerCase()
|
||||||
|
return props.macros.filter((m) => m.name.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedMacros = computed(() => {
|
||||||
|
const groups = {}
|
||||||
|
props.collections.forEach((c) => {
|
||||||
|
groups[c.name] = []
|
||||||
|
})
|
||||||
|
groups['Ohne Sammlung'] = []
|
||||||
|
filteredMacros.value.forEach((m) => {
|
||||||
|
const coll = props.collections.find((c) => c.macros?.some((cm) => cm.id === m.id))
|
||||||
|
const key = coll?.name ?? 'Ohne Sammlung'
|
||||||
|
if (!groups[key]) groups[key] = []
|
||||||
|
groups[key].push(m)
|
||||||
|
})
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMacro = computed(() => props.macros.find((m) => m.id === model.value))
|
||||||
|
|
||||||
|
function select(macro) {
|
||||||
|
model.value = macro.id
|
||||||
|
search.value = ''
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (!props.disabled) isOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
setTimeout(() => {
|
||||||
|
isOpen.value = false
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm"
|
||||||
|
:class="{ 'cursor-not-allowed opacity-50': disabled }"
|
||||||
|
@click="open"
|
||||||
|
data-testid="macro-picker-trigger"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="selectedMacro?.color"
|
||||||
|
class="h-4 w-4 shrink-0 rounded"
|
||||||
|
:style="{ backgroundColor: selectedMacro.color }"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-gray-700">
|
||||||
|
{{ selectedMacro ? selectedMacro.name : 'Makro auswählen...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||||
|
data-testid="macro-picker-dropdown"
|
||||||
|
>
|
||||||
|
<div class="border-b border-gray-100 p-2">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Makro suchen..."
|
||||||
|
class="w-full rounded border-gray-300 text-sm"
|
||||||
|
data-testid="macro-picker-search"
|
||||||
|
autofocus
|
||||||
|
@blur="close"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-64 overflow-y-auto">
|
||||||
|
<template v-for="(group, name) in groupedMacros" :key="name">
|
||||||
|
<div
|
||||||
|
v-if="group.length > 0"
|
||||||
|
class="bg-gray-50 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-gray-400"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="macro in group"
|
||||||
|
:key="macro.id"
|
||||||
|
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-amber-50"
|
||||||
|
:class="macro.hidden_at ? 'text-gray-400' : 'text-gray-700'"
|
||||||
|
:data-testid="'macro-option-' + macro.id"
|
||||||
|
@click="select(macro)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-3 w-3 shrink-0 rounded"
|
||||||
|
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#ccc' }"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<div v-if="filteredMacros.length === 0" class="px-3 py-4 text-center text-sm text-gray-400">
|
||||||
|
Kein Makro gefunden
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||||
import AgendaSettings from './Settings/AgendaSettings.vue'
|
import AgendaSettings from './Settings/AgendaSettings.vue'
|
||||||
|
import LabelImport from './Settings/LabelImport.vue'
|
||||||
|
import MacroAssignments from './Settings/MacroAssignments.vue'
|
||||||
|
import MacroImport from './Settings/MacroImport.vue'
|
||||||
import { Head } from '@inertiajs/vue3'
|
import { Head } from '@inertiajs/vue3'
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
|
@ -92,37 +95,29 @@ function switchSubmenu(key) {
|
||||||
|
|
||||||
<!-- Content panel -->
|
<!-- Content panel -->
|
||||||
<div class="flex-1 p-6" data-testid="settings-active-panel">
|
<div class="flex-1 p-6" data-testid="settings-active-panel">
|
||||||
<!-- Makro-Zuweisungen — filled in by T3.5 -->
|
<MacroAssignments
|
||||||
<div v-if="activeSubmenu === 'assignments'">
|
v-if="activeSubmenu === 'assignments'"
|
||||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
:assignments="assignments"
|
||||||
Globale Makro-Zuweisungen
|
:macros="macros"
|
||||||
</h3>
|
:labels="labels"
|
||||||
<p class="text-sm text-gray-500">
|
:collections="collections"
|
||||||
Hier werden die globalen Makro-Zuweisungen konfiguriert.
|
@switch-submenu="switchSubmenu"
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Makro-Import — filled in by T3.4 -->
|
<MacroImport
|
||||||
<div v-if="activeSubmenu === 'macros'">
|
v-if="activeSubmenu === 'macros'"
|
||||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
:macros="macros"
|
||||||
Makro-Import
|
:collections="collections"
|
||||||
</h3>
|
:last_macros_import="last_macros_import"
|
||||||
<p class="text-sm text-gray-500">
|
@switch-submenu="switchSubmenu"
|
||||||
Importiere eine ProPresenter Makro-Datei.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Label-Import — filled in by T3.3 -->
|
<LabelImport
|
||||||
<div v-if="activeSubmenu === 'labels'">
|
v-if="activeSubmenu === 'labels'"
|
||||||
<h3 class="mb-4 text-sm font-semibold text-gray-900">
|
:labels="labels"
|
||||||
Label-Import
|
:last_labels_import="last_labels_import"
|
||||||
</h3>
|
/>
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Importiere eine ProPresenter Labels-Datei.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Agenda -->
|
|
||||||
<AgendaSettings
|
<AgendaSettings
|
||||||
v-if="activeSubmenu === 'agenda'"
|
v-if="activeSubmenu === 'agenda'"
|
||||||
:settings="settings"
|
:settings="settings"
|
||||||
|
|
|
||||||
138
resources/js/Pages/Settings/LabelImport.vue
Normal file
138
resources/js/Pages/Settings/LabelImport.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { route } from 'ziggy-js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
last_labels_import: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const uploading = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const sortedLabels = computed(() => [...props.labels].sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
|
||||||
|
async function handleFileChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(route('settings.labels.import'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = data.message || 'Import fehlgeschlagen'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.value = await res.json()
|
||||||
|
event.target.value = ''
|
||||||
|
window.location.reload()
|
||||||
|
} catch {
|
||||||
|
error.value = 'Netzwerkfehler beim Upload'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-sm font-semibold text-gray-900">Label-Import</h3>
|
||||||
|
|
||||||
|
<p class="mb-1 text-xs text-gray-500">
|
||||||
|
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||||
|
<span class="group relative inline-block">
|
||||||
|
<svg
|
||||||
|
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
|
||||||
|
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Labels<br><br>
|
||||||
|
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Labels
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
data-testid="labels-upload-area"
|
||||||
|
>
|
||||||
|
<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 importiert...' : 'Labels-Datei auswählen oder hierher ziehen' }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="uploading"
|
||||||
|
data-testid="labels-file-input"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
|
||||||
|
data-testid="labels-import-error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="result"
|
||||||
|
class="mb-4 rounded-lg bg-green-50 p-3 text-sm text-green-700"
|
||||||
|
data-testid="labels-import-summary"
|
||||||
|
>
|
||||||
|
<strong>Import abgeschlossen:</strong>
|
||||||
|
{{ result.new }} neue Labels importiert, {{ result.updated }} bestehende Labels aktualisiert.
|
||||||
|
Insgesamt {{ result.total }} Labels in Datei.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="sortedLabels.length > 0">
|
||||||
|
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Label-Bibliothek ({{ sortedLabels.length }})
|
||||||
|
</h4>
|
||||||
|
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="label in sortedLabels"
|
||||||
|
:key="label.id"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
:data-testid="'labels-registry-row-' + label.name"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-4 w-4 shrink-0 rounded border border-gray-200"
|
||||||
|
:style="label.color ? { backgroundColor: label.color } : { backgroundColor: '#e5e7eb' }"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 text-gray-700">{{ label.name }}</span>
|
||||||
|
<span v-if="label.color" class="font-mono text-xs text-gray-400">{{ label.color }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-gray-400">Noch keine Labels importiert.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
203
resources/js/Pages/Settings/MacroAssignments.vue
Normal file
203
resources/js/Pages/Settings/MacroAssignments.vue
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<script setup>
|
||||||
|
import LabelPicker from '@/Components/LabelPicker.vue'
|
||||||
|
import MacroPicker from '@/Components/MacroPicker.vue'
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { route } from 'ziggy-js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
assignments: { type: Array, default: () => [] },
|
||||||
|
macros: { type: Array, default: () => [] },
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
collections: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
{ key: 'information', label: 'Informationen' },
|
||||||
|
{ key: 'moderation', label: 'Moderation' },
|
||||||
|
{ key: 'sermon', label: 'Predigt' },
|
||||||
|
{ key: 'song', label: 'Lieder' },
|
||||||
|
{ key: 'agenda_item', label: 'Agenda-Items' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const positions = [
|
||||||
|
{ key: 'all_slides', label: 'Alle Folien' },
|
||||||
|
{ key: 'first_slide', label: 'Erste Folie' },
|
||||||
|
{ key: 'last_slide', label: 'Letzte Folie' },
|
||||||
|
{ key: 'by_label', label: 'Nach Label' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function assignmentsForPart(partKey) {
|
||||||
|
return props.assignments.filter((a) => a.part_type === partKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionsForPart(partKey) {
|
||||||
|
if (partKey === 'song') return positions
|
||||||
|
return positions.filter((p) => p.key !== 'by_label')
|
||||||
|
}
|
||||||
|
|
||||||
|
const adding = reactive({})
|
||||||
|
const newAssignment = reactive({})
|
||||||
|
|
||||||
|
function startAdd(partKey) {
|
||||||
|
adding[partKey] = true
|
||||||
|
newAssignment[partKey] = { macro_id: null, position: 'all_slides', label_id: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAdd(partKey) {
|
||||||
|
adding[partKey] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignment(partKey) {
|
||||||
|
const data = newAssignment[partKey]
|
||||||
|
if (!data.macro_id) return
|
||||||
|
|
||||||
|
await fetch(route('settings.macro-assignments.store'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
part_type: partKey,
|
||||||
|
macro_id: data.macro_id,
|
||||||
|
position: data.position,
|
||||||
|
label_id: data.position === 'by_label' ? data.label_id : null,
|
||||||
|
order: assignmentsForPart(partKey).length,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
adding[partKey] = false
|
||||||
|
router.reload({ preserveScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAssignment(id) {
|
||||||
|
await fetch(route('settings.macro-assignments.destroy', id), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
router.reload({ preserveScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionLabel(pos) {
|
||||||
|
return positions.find((p) => p.key === pos)?.label ?? pos
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-sm font-semibold text-gray-900">Globale Makro-Zuweisungen</h3>
|
||||||
|
<p class="mb-4 text-xs text-gray-500">
|
||||||
|
Diese Zuweisungen gelten für alle Gottesdienste. Pro Gottesdienst können sie überschrieben werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-for="part in parts" :key="part.key">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700">{{ part.label }}</h4>
|
||||||
|
<span class="text-xs text-gray-400">{{ assignmentsForPart(part.key).length }} Zuweisungen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="a in assignmentsForPart(part.key)"
|
||||||
|
:key="a.id"
|
||||||
|
class="flex items-center gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-sm"
|
||||||
|
:data-testid="'assignment-card-' + a.id"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="a.macro?.color"
|
||||||
|
class="h-3 w-3 shrink-0 rounded"
|
||||||
|
:style="{ backgroundColor: a.macro.color }"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 text-gray-700">{{ a.macro?.name }}</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ positionLabel(a.position) }}</span>
|
||||||
|
<span
|
||||||
|
v-if="a.position === 'by_label' && a.label"
|
||||||
|
class="text-xs text-gray-500"
|
||||||
|
>
|
||||||
|
→ {{ a.label.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="a.macro?.hidden_at"
|
||||||
|
class="rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700"
|
||||||
|
data-testid="warning-hidden-macro"
|
||||||
|
>
|
||||||
|
⚠ Makro deaktiviert
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="ml-2 text-gray-400 hover:text-red-500"
|
||||||
|
:data-testid="'delete-assignment-' + a.id"
|
||||||
|
@click="deleteAssignment(a.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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="adding[part.key]"
|
||||||
|
class="mt-2 space-y-2 rounded-lg border border-amber-200 bg-amber-50 p-3"
|
||||||
|
>
|
||||||
|
<MacroPicker
|
||||||
|
v-model="newAssignment[part.key].macro_id"
|
||||||
|
:macros="macros"
|
||||||
|
:collections="collections"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<label
|
||||||
|
v-for="pos in positionsForPart(part.key)"
|
||||||
|
:key="pos.key"
|
||||||
|
class="flex items-center gap-1.5 text-xs text-gray-700"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="pos.key"
|
||||||
|
v-model="newAssignment[part.key].position"
|
||||||
|
class="text-amber-500"
|
||||||
|
/>
|
||||||
|
{{ pos.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<LabelPicker
|
||||||
|
v-if="newAssignment[part.key]?.position === 'by_label'"
|
||||||
|
v-model="newAssignment[part.key].label_id"
|
||||||
|
:labels="labels"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-600"
|
||||||
|
:data-testid="'save-assignment-' + part.key"
|
||||||
|
@click="saveAssignment(part.key)"
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-medium text-gray-600 hover:bg-gray-50"
|
||||||
|
@click="cancelAdd(part.key)"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="mt-1 flex items-center gap-1 rounded-lg border border-dashed border-gray-200 px-3 py-1.5 text-xs text-gray-500 transition-colors hover:border-amber-300 hover:text-amber-600"
|
||||||
|
:data-testid="'add-assignment-' + part.key"
|
||||||
|
@click="startAdd(part.key)"
|
||||||
|
>
|
||||||
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Zuweisung hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
181
resources/js/Pages/Settings/MacroImport.vue
Normal file
181
resources/js/Pages/Settings/MacroImport.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { route } from 'ziggy-js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
macros: { type: Array, default: () => [] },
|
||||||
|
collections: { type: Array, default: () => [] },
|
||||||
|
last_macros_import: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['switch-submenu'])
|
||||||
|
|
||||||
|
const uploading = ref(false)
|
||||||
|
const result = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
const selectedCollection = ref(null)
|
||||||
|
|
||||||
|
const filteredMacros = computed(() => {
|
||||||
|
if (!selectedCollection.value) return props.macros
|
||||||
|
const coll = props.collections.find((c) => c.id === selectedCollection.value)
|
||||||
|
if (!coll) return props.macros
|
||||||
|
const ids = coll.macros?.map((m) => m.id) ?? []
|
||||||
|
return props.macros.filter((m) => ids.includes(m.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleFileChange(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
|
error.value = null
|
||||||
|
result.value = null
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(route('settings.macros.import'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-XSRF-TOKEN': decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] ?? ''),
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
error.value = data.message || 'Import fehlgeschlagen'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result.value = await res.json()
|
||||||
|
event.target.value = ''
|
||||||
|
window.location.reload()
|
||||||
|
} catch {
|
||||||
|
error.value = 'Netzwerkfehler beim Upload'
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-1 text-sm font-semibold text-gray-900">Makro-Import</h3>
|
||||||
|
|
||||||
|
<p class="mb-1 text-xs text-gray-500">
|
||||||
|
Diese Datei findest du im ProPresenter-Ordner unter <strong>Configuration</strong>.
|
||||||
|
<span class="group relative inline-block">
|
||||||
|
<svg
|
||||||
|
class="ml-1 inline h-3.5 w-3.5 cursor-help text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="absolute bottom-full left-0 z-10 mb-2 hidden w-72 rounded-lg border border-gray-200 bg-white p-3 text-xs text-gray-600 shadow-lg group-hover:block">
|
||||||
|
<strong>macOS:</strong> ~/Library/Application Support/RenewedVision/ProPresenter/Configuration/Macros<br><br>
|
||||||
|
<strong>Windows:</strong> %APPDATA%\RenewedVision\ProPresenter\Configuration\Macros
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="last_macros_import?.at" class="mb-4 text-xs text-gray-400">
|
||||||
|
Letzter Import: {{ last_macros_import.at }}
|
||||||
|
<span v-if="last_macros_import.filename">({{ last_macros_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"
|
||||||
|
data-testid="macros-upload-area"
|
||||||
|
>
|
||||||
|
<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 importiert...' : 'Makro-Datei auswählen oder hierher ziehen' }}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="uploading"
|
||||||
|
data-testid="macros-file-input"
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="error"
|
||||||
|
class="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-700"
|
||||||
|
data-testid="macros-import-error"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="result"
|
||||||
|
class="mb-4 space-y-1 rounded-lg bg-green-50 p-3 text-sm text-green-700"
|
||||||
|
data-testid="macros-import-summary"
|
||||||
|
>
|
||||||
|
<p><strong>Import abgeschlossen:</strong></p>
|
||||||
|
<p>{{ result.stats.new }} neue Makros importiert</p>
|
||||||
|
<p>{{ result.stats.updated }} bestehende Makros aktualisiert</p>
|
||||||
|
<p>{{ result.stats.disabled }} Makros deaktiviert (nicht mehr in Datei vorhanden)</p>
|
||||||
|
<p v-if="result.stats.re_enabled > 0">{{ result.stats.re_enabled }} Makros wieder aktiviert</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="result?.warnings?.length > 0"
|
||||||
|
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800"
|
||||||
|
data-testid="macros-import-warnings"
|
||||||
|
>
|
||||||
|
<p class="mb-2 font-semibold">⚠ Achtung: Folgende deaktivierte Makros sind noch zugewiesen:</p>
|
||||||
|
<ul class="space-y-1 text-xs">
|
||||||
|
<li v-for="w in result.warnings" :key="w.macro_uuid">
|
||||||
|
{{ w.macro_name }} (Bereich: {{ w.part_type }})
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-xs text-amber-700 underline"
|
||||||
|
@click="emit('switch-submenu', 'assignments')"
|
||||||
|
>
|
||||||
|
Zu den Makro-Zuweisungen →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="macros.length > 0">
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Makro-Bibliothek ({{ macros.length }})
|
||||||
|
</h4>
|
||||||
|
<select
|
||||||
|
v-model="selectedCollection"
|
||||||
|
class="ml-auto rounded border-gray-300 text-xs"
|
||||||
|
data-testid="macros-collection-filter"
|
||||||
|
>
|
||||||
|
<option :value="null">Alle Sammlungen</option>
|
||||||
|
<option v-for="c in collections" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-100 rounded-lg border border-gray-100">
|
||||||
|
<div
|
||||||
|
v-for="macro in filteredMacros"
|
||||||
|
:key="macro.id"
|
||||||
|
class="flex items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
:class="macro.hidden_at ? 'opacity-50' : ''"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-4 w-4 shrink-0 rounded border border-gray-200"
|
||||||
|
:style="macro.color ? { backgroundColor: macro.color } : { backgroundColor: '#e5e7eb' }"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 text-gray-700">
|
||||||
|
{{ macro.name }}{{ macro.hidden_at ? ' (deaktiviert)' : '' }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-400">{{ macro.action_count }} Aktionen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-gray-400">Noch keine Makros importiert.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Reference in a new issue