pp-planer/resources/js/Components/ArrangementConfigurator.vue
Thorsten Bus ae42b48753 feat(songs): per-song sections + section editing; fix CCLI import bugs
Refactor lyric storage so each song owns its sections instead of sharing
global labels. Adds song_sections (per song+label) owning song_slides;
labels stay global ProPresenter group tags (name/color/macro). Arrangements
now reference sections, so editing/importing one song no longer corrupts
others that share a label name.

- New: song_sections table + migration with safe backfill; SongSection,
  SongArrangementSection models; SongSectionController (edit/add/delete
  sections, immediate persistence) wired into SongEditModal.
- Refactor writers/readers: CcliImport, ProImport, SongService,
  ArrangementController, SongController, ProExport, PDF, Translation
  (translation reset now section-scoped), CCLI pairing.
- CCLI import fixes: parse SongSelect copy-icon format (German "Vers"
  abbrev + trailing author), fill empty CTS-synced songs instead of
  blocking as duplicate, distinct label colors per section kind,
  import&edit/existing-song open the edit modal (no 404/405), teleport
  paste dialog above assign dialog, preview shows section content,
  correct SongSelect search URL, copy-icon instructions.
- Bookmarklet clicks #generalCopyLyricsButton and captures clipboard;
  serves correct host from request.
- Export: embed key-visual/background under fixed bundle-relative names.
- Tests updated for the section model; new section + isolation coverage.
2026-05-31 14:45:47 +02:00

340 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, ref, watch } from 'vue'
import { router } from '@inertiajs/vue3'
import { VueDraggable } from 'vue-draggable-plus'
const MASTER_ID = 'master'
const props = defineProps({
songId: {
type: Number,
required: true,
},
arrangements: {
type: Array,
required: true,
},
availableGroups: {
type: Array,
required: true,
},
selectedArrangementId: {
type: [Number, String],
default: null,
},
})
const emit = defineEmits(['arrangement-selected', 'arrangements-changed'])
// Virtual MASTER arrangement — always first, computed from availableGroups
const masterArrangement = computed(() => ({
id: MASTER_ID,
name: 'MASTER',
is_default: false,
is_master: true,
groups: props.availableGroups.map((g) => ({ ...g })),
}))
// All arrangements with MASTER prepended
const allArrangements = computed(() => [
masterArrangement.value,
...props.arrangements,
])
const selectedId = ref(
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? MASTER_ID,
)
const isMasterSelected = computed(() => selectedId.value === MASTER_ID)
const arrangementGroups = ref([])
const poolGroups = ref([])
const selectedArrangement = computed(() =>
allArrangements.value.find((arrangement) => arrangement.id === selectedId.value)
?? allArrangements.value.find((arrangement) => arrangement.id === Number(selectedId.value))
?? null,
)
watch(
() => props.availableGroups,
(groups) => {
poolGroups.value = groups.map((group) => ({ ...group }))
},
{ immediate: true, deep: true },
)
watch(
selectedArrangement,
(arrangement) => {
if (arrangement?.groups) {
arrangementGroups.value = arrangement.groups.map((group) => ({ ...group }))
} else if (arrangement?.arrangement_groups) {
arrangementGroups.value = arrangement.arrangement_groups
.map((ag) => props.availableGroups.find((g) => g.id === ag.label_id))
.filter(Boolean)
.map((g) => ({ ...g }))
} else {
arrangementGroups.value = []
}
},
{ immediate: true },
)
watch(
selectedId,
(arrangementId) => {
emit('arrangement-selected', arrangementId === MASTER_ID ? null : Number(arrangementId))
},
)
const groupPillStyle = (group) => ({
backgroundColor: group.color,
})
const pendingAutoSelect = ref(false)
watch(
() => props.arrangements.length,
(newLen, oldLen) => {
if (pendingAutoSelect.value && newLen > oldLen) {
const newest = props.arrangements.reduce((a, b) => a.id > b.id ? a : b)
selectedId.value = newest.id
pendingAutoSelect.value = false
}
},
)
function addArrangement() {
const name = window.prompt('Name des neuen Arrangements')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
function cloneArrangement() {
if (!selectedArrangement.value) return
// Cloning from MASTER = creating a new arrangement (store() already uses all groups in master order)
if (isMasterSelected.value) {
const name = window.prompt('Name des neuen Arrangements', 'MASTER Kopie')
if (!name) return
pendingAutoSelect.value = true
router.post(`/songs/${props.songId}/arrangements`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
return
}
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
if (!name) return
pendingAutoSelect.value = true
router.post(`/arrangements/${selectedArrangement.value.id}/clone`, { name }, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
function addGroupFromPool(group) {
if (isMasterSelected.value) return
arrangementGroups.value.push({ ...group })
saveArrangement()
}
function removeGroupAt(index) {
if (isMasterSelected.value) return
arrangementGroups.value.splice(index, 1)
saveArrangement()
}
function saveArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
router.put(
`/arrangements/${selectedArrangement.value.id}`,
{
groups: arrangementGroups.value.map((group, index) => ({
section_id: group.section_id ?? group.id,
order: index + 1,
})),
},
{
preserveScroll: true,
preserveState: true,
},
)
}
function deleteArrangement() {
if (!selectedArrangement.value || isMasterSelected.value) {
return
}
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
preserveScroll: true,
onSuccess: () => {
emit('arrangements-changed')
},
})
}
</script>
<template>
<div data-testid="arrangement-configurator" class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-64 flex-1">
<label
for="arrangement-select"
class="mb-1 block text-sm font-medium text-gray-700"
>
Arrangement
</label>
<select
data-testid="arrangement-select"
id="arrangement-select"
v-model="selectedId"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
>
<option
v-for="arrangement in allArrangements"
:key="arrangement.id"
:value="arrangement.id"
>
{{ arrangement.name }}{{ arrangement.is_default ? ' (Standard)' : '' }}
</option>
</select>
</div>
<button
data-testid="arrangement-add-button"
type="button"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
@click="addArrangement"
>
Hinzufügen
</button>
<button
data-testid="arrangement-clone-button"
type="button"
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
@click="cloneArrangement"
>
Klonen
</button>
<button
data-testid="arrangement-delete-button"
type="button"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-40"
:disabled="isMasterSelected"
@click="deleteArrangement"
>
Löschen
</button>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="space-y-1">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Verfügbare Gruppen
</h4>
<VueDraggable
v-model="poolGroups"
:group="{ name: 'song-groups', pull: isMasterSelected ? false : 'clone', put: false }"
:sort="false"
:disabled="isMasterSelected"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex flex-wrap gap-1.5 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-2"
:class="{ 'opacity-50': isMasterSelected }"
>
<span
v-for="group in poolGroups"
:key="group.id"
class="inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold text-white transition-opacity hover:opacity-80"
:class="isMasterSelected ? 'cursor-default' : 'cursor-grab'"
:style="groupPillStyle(group)"
@click="addGroupFromPool(group)"
>
{{ group.name }}
</span>
</VueDraggable>
</div>
<div class="space-y-1">
<h4 class="text-xs font-semibold uppercase tracking-wide text-gray-500">
Gruppenfolge
<span v-if="isMasterSelected" class="ml-1 text-[10px] font-normal normal-case text-gray-400">(nicht editierbar)</span>
</h4>
<VueDraggable
v-model="arrangementGroups"
:group="{ name: 'song-groups', pull: true, put: !isMasterSelected }"
:disabled="isMasterSelected"
ghost-class="drag-ghost"
chosen-class="drag-chosen"
drag-class="drag-active"
class="flex min-h-10 flex-wrap gap-1.5 rounded-lg border border-gray-200 bg-gray-50 p-2"
:class="{ 'opacity-60': isMasterSelected }"
@end="saveArrangement"
>
<span
v-for="(group, index) in arrangementGroups"
:key="`${group.id}-${index}`"
class="inline-flex items-center gap-1 rounded-full py-0.5 pl-2.5 text-xs font-semibold text-white"
:class="isMasterSelected ? 'cursor-default pr-2.5' : 'cursor-grab pr-1'"
:style="groupPillStyle(group)"
>
{{ group.name }}
<button
v-if="!isMasterSelected"
data-testid="arrangement-remove-button"
type="button"
class="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full text-white/70 transition-colors hover:bg-white/20 hover:text-white"
@click.stop="removeGroupAt(index)"
>
×
</button>
</span>
</VueDraggable>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.drag-ghost) {
opacity: 0.4;
ring: 2px solid white;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.8), 0 0 0 4px rgba(99, 102, 241, 0.6);
border-radius: 9999px;
}
:deep(.drag-chosen) {
opacity: 0.7;
}
:deep(.drag-active) {
opacity: 1 !important;
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3), 0 0 0 2px rgba(255, 255, 255, 0.8);
border-radius: 9999px;
z-index: 50;
}
</style>