pp-planer/resources/js/Components/ArrangementConfigurator.vue

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) => ({
label_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>