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>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.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 { onMounted, ref } from 'vue'
|
||||
|
||||
|
|
@ -92,37 +95,29 @@ function switchSubmenu(key) {
|
|||
|
||||
<!-- 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>
|
||||
<MacroAssignments
|
||||
v-if="activeSubmenu === 'assignments'"
|
||||
:assignments="assignments"
|
||||
:macros="macros"
|
||||
:labels="labels"
|
||||
:collections="collections"
|
||||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
|
||||
<!-- 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>
|
||||
<MacroImport
|
||||
v-if="activeSubmenu === 'macros'"
|
||||
:macros="macros"
|
||||
:collections="collections"
|
||||
:last_macros_import="last_macros_import"
|
||||
@switch-submenu="switchSubmenu"
|
||||
/>
|
||||
|
||||
<!-- 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>
|
||||
<LabelImport
|
||||
v-if="activeSubmenu === 'labels'"
|
||||
:labels="labels"
|
||||
:last_labels_import="last_labels_import"
|
||||
/>
|
||||
|
||||
<!-- Agenda -->
|
||||
<AgendaSettings
|
||||
v-if="activeSubmenu === 'agenda'"
|
||||
: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