fix(songs): resolve seven song/service editing bugs

- CCLI import: group lyrics into 2-line slides (no blank line per line)
- Add-section: searchable label combobox with create-new option
- Service edit: show current global key-visual/background default live
- Assign dialog: prefill+open search, SongSelect link by CCLI nr/name
- "Auf SongSelect suchen" now also opens the CCLI import dialog
- SongDB: mark empty songs "Ohne Inhalt", default-on content filter
- Translation paste: strip section-mark lines so line mapping holds
This commit is contained in:
Thorsten Bus 2026-05-31 21:39:44 +02:00
parent ae42b48753
commit ff3484466b
10 changed files with 260 additions and 42 deletions

View file

@ -126,7 +126,7 @@ public function index(): Response
]); ]);
} }
public function edit(Service $service): Response public function edit(Service $service, \App\Services\ServiceImageResolver $imageResolver): Response
{ {
$service->load([ $service->load([
'serviceSongs' => fn ($query) => $query->orderBy('order'), 'serviceSongs' => fn ($query) => $query->orderBy('order'),
@ -142,6 +142,7 @@ public function edit(Service $service): Response
]); ]);
$songsCatalog = Song::query() $songsCatalog = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')])
->orderBy('title') ->orderBy('title')
->get(['id', 'title', 'ccli_id', 'has_translation']) ->get(['id', 'title', 'ccli_id', 'has_translation'])
->map(fn (Song $song) => [ ->map(fn (Song $song) => [
@ -149,6 +150,7 @@ public function edit(Service $service): Response
'title' => $song->title, 'title' => $song->title,
'ccli_id' => $song->ccli_id, 'ccli_id' => $song->ccli_id,
'has_translation' => $song->has_translation, 'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
]) ])
->values(); ->values();
@ -256,6 +258,13 @@ public function edit(Service $service): Response
]; ];
} }
// Resolve key-visual/background live (per-service override → current global default → none),
// so the panels always reflect the CURRENT default even if it changed after creation/sync.
$resolvedKeyVisual = $imageResolver->keyVisualFor($service);
$resolvedBackground = $imageResolver->backgroundFor($service);
$keyVisualIsOwn = $service->key_visual_filename !== null && $resolvedKeyVisual === $service->key_visual_filename;
$backgroundIsOwn = $service->background_filename !== null && $resolvedBackground === $service->background_filename;
return Inertia::render('Services/Edit', [ return Inertia::render('Services/Edit', [
'service' => [ 'service' => [
'id' => $service->id, 'id' => $service->id,
@ -266,10 +275,12 @@ public function edit(Service $service): Response
'finalized_at' => $service->finalized_at?->toJSON(), 'finalized_at' => $service->finalized_at?->toJSON(),
'last_synced_at' => $service->last_synced_at?->toJSON(), 'last_synced_at' => $service->last_synced_at?->toJSON(),
'has_agenda' => $service->has_agenda, 'has_agenda' => $service->has_agenda,
'key_visual_filename' => $service->key_visual_filename, 'key_visual_filename' => $resolvedKeyVisual,
'background_filename' => $service->background_filename, 'background_filename' => $resolvedBackground,
'key_visual_url' => $service->key_visual_filename ? '/storage/'.$service->key_visual_filename : null, 'key_visual_url' => $resolvedKeyVisual ? '/storage/'.$resolvedKeyVisual : null,
'background_url' => $service->background_filename ? '/storage/'.$service->background_filename : null, 'background_url' => $resolvedBackground ? '/storage/'.$resolvedBackground : null,
'key_visual_is_own' => $keyVisualIsOwn,
'background_is_own' => $backgroundIsOwn,
'moderator_name' => $service->moderator_name, 'moderator_name' => $service->moderator_name,
'preacher_name_override' => $service->preacher_name_override, 'preacher_name_override' => $service->preacher_name_override,
], ],

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\SongRequest; use App\Http\Requests\SongRequest;
use App\Models\Label;
use App\Models\Song; use App\Models\Song;
use App\Services\SongService; use App\Services\SongService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@ -17,7 +18,8 @@ public function __construct(
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
$query = Song::query(); $query = Song::query()
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')]);
if ($search = $request->input('search')) { if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
@ -26,6 +28,12 @@ public function index(Request $request): JsonResponse
}); });
} }
// The SongDB UI sends with_content=1 by default to hide songs without content;
// pass with_content=0 (or omit) to include empty songs.
if ($request->boolean('with_content')) {
$query->whereHas('sections', fn ($q) => $q->has('slides'));
}
$songs = $query->orderBy('title') $songs = $query->orderBy('title')
->paginate($request->input('per_page', 20)); ->paginate($request->input('per_page', 20));
@ -36,6 +44,7 @@ public function index(Request $request): JsonResponse
'ccli_id' => $song->ccli_id, 'ccli_id' => $song->ccli_id,
'author' => $song->author, 'author' => $song->author,
'has_translation' => $song->has_translation, 'has_translation' => $song->has_translation,
'has_content' => (int) $song->content_slides_count > 0,
'last_used_at' => $song->last_used_at?->toDateString(), 'last_used_at' => $song->last_used_at?->toDateString(),
'last_used_in_service' => $song->last_used_in_service, 'last_used_in_service' => $song->last_used_in_service,
'created_at' => $song->created_at->toDateTimeString(), 'created_at' => $song->created_at->toDateTimeString(),
@ -155,6 +164,15 @@ public function formatSongDetail(Song $song): array
'created_at' => $song->created_at->toDateTimeString(), 'created_at' => $song->created_at->toDateTimeString(),
'updated_at' => $song->updated_at->toDateTimeString(), 'updated_at' => $song->updated_at->toDateTimeString(),
'groups' => $groupsPayload, 'groups' => $groupsPayload,
'available_labels' => Label::query()
->whereNull('hidden_at')
->orderBy('name')
->get(['id', 'name', 'color'])
->map(fn (Label $label) => [
'id' => $label->id,
'name' => $label->name,
'color' => $label->color,
])->toArray(),
'arrangements' => $song->arrangements->map(fn ($arr) => [ 'arrangements' => $song->arrangements->map(fn ($arr) => [
'id' => $arr->id, 'id' => $arr->id,
'name' => $arr->name, 'name' => $arr->name,

View file

@ -18,6 +18,11 @@
final class CcliImportService final class CcliImportService
{ {
/**
* Number of lyric lines grouped into a single projection slide.
*/
private const LINES_PER_SLIDE = 2;
private const LABEL_KIND_COLORS = [ private const LABEL_KIND_COLORS = [
'Verse' => '#3B82F6', 'Verse' => '#3B82F6',
'Chorus' => '#10B981', 'Chorus' => '#10B981',
@ -83,13 +88,20 @@ public function import(string $rawText, ?string $sourceUrl = null): array
$section->slides()->delete(); $section->slides()->delete();
foreach ($parsedSection->lines as $slideOrder => $line) { // Group lines into pairs: each slide carries up to two lines.
$translatedLine = $parsedSection->linesTranslated[$slideOrder] ?? null; $lineChunks = array_chunk($parsedSection->lines, self::LINES_PER_SLIDE);
$translatedChunks = $parsedSection->linesTranslated !== null
? array_chunk($parsedSection->linesTranslated, self::LINES_PER_SLIDE)
: [];
foreach ($lineChunks as $slideOrder => $chunk) {
$translatedChunk = $translatedChunks[$slideOrder] ?? null;
$translatedLine = $translatedChunk !== null ? implode("\n", $translatedChunk) : null;
$hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== ''); $hasTranslation = $hasTranslation || ($translatedLine !== null && trim($translatedLine) !== '');
$section->slides()->create([ $section->slides()->create([
'order' => $slideOrder + 1, 'order' => $slideOrder + 1,
'text_content' => $line, 'text_content' => implode("\n", $chunk),
'text_content_translated' => $translatedLine, 'text_content_translated' => $translatedLine,
]); ]);
} }

View file

@ -28,6 +28,14 @@ const props = defineProps({
type: Number, type: Number,
default: null, default: null,
}, },
serviceSongName: {
type: String,
default: '',
},
serviceSongCcliId: {
type: String,
default: '',
},
songsCatalog: { songsCatalog: {
type: Array, type: Array,
default: () => [], default: () => [],
@ -40,7 +48,7 @@ const emit = defineEmits(['close', 'arrangement-selected'])
const isUnmatched = computed(() => !props.songId) const isUnmatched = computed(() => !props.songId)
const searchQuery = ref('') const searchQuery = ref(props.serviceSongName ?? '')
const selectedSongId = ref('') const selectedSongId = ref('')
const dropdownOpen = ref(false) const dropdownOpen = ref(false)
const assignError = ref('') const assignError = ref('')
@ -59,6 +67,23 @@ const filteredCatalog = computed(() => {
.slice(0, 100) .slice(0, 100)
}) })
// Build the SongSelect URL: prefer the CCLI number (direct song page), else search by name.
const songSelectUrl = computed(() => {
const ccli = (props.serviceSongCcliId ?? '').toString().trim()
if (ccli !== '') {
return `https://songselect.ccli.com/songs/${encodeURIComponent(ccli)}`
}
const query = (searchQuery.value || props.serviceSongName || '').trim()
return `https://songselect.ccli.com/search/results?search=${encodeURIComponent(query)}`
})
// Open SongSelect in a new tab AND open the CCLI import dialog so the user can paste.
function openSongSelect() {
window.open(songSelectUrl.value, '_blank', 'noopener,noreferrer')
ccliDialogOpen.value = true
}
function openSearchDropdown() { function openSearchDropdown() {
dropdownOpen.value = true dropdownOpen.value = true
} }
@ -250,6 +275,11 @@ function closeOnEscape(e) {
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', closeOnEscape) document.addEventListener('keydown', closeOnEscape)
document.addEventListener('click', onBodyClick) document.addEventListener('click', onBodyClick)
// For unmatched songs: show search results immediately (prefilled with the song name).
if (isUnmatched.value) {
dropdownOpen.value = true
}
}) })
onUnmounted(() => { onUnmounted(() => {
@ -532,10 +562,19 @@ function closeOnBackdrop(e) {
v-for="song in filteredCatalog" v-for="song in filteredCatalog"
:key="song.id" :key="song.id"
type="button" type="button"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-emerald-50" data-testid="song-search-option"
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
:class="song.has_content === false ? 'bg-orange-50 hover:bg-orange-100' : 'hover:bg-emerald-50'"
@mousedown.prevent="selectSong(song)" @mousedown.prevent="selectSong(song)"
> >
<span class="font-medium text-gray-900">{{ song.title }}</span> <span class="font-medium text-gray-900">{{ song.title }}</span>
<span
v-if="song.has_content === false"
data-testid="song-search-no-content"
class="inline-flex items-center rounded-full bg-orange-100 px-1.5 py-0.5 text-[10px] font-semibold text-orange-700"
>
Ohne Inhalt
</span>
<span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span> <span class="ml-auto text-xs text-gray-400">CCLI: {{ song.ccli_id || '' }}</span>
</button> </button>
</div> </div>
@ -552,16 +591,14 @@ function closeOnBackdrop(e) {
> >
Zuordnen Zuordnen
</button> </button>
<a <button
v-if="searchQuery" type="button"
:href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(searchQuery)"
target="_blank"
rel="noopener noreferrer"
data-testid="songselect-search-button" data-testid="songselect-search-button"
@click="openSongSelect"
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50" class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
> >
Auf SongSelect suchen Auf SongSelect suchen
</a> </button>
<button <button
type="button" type="button"
data-testid="open-ccli-paste-dialog-button" data-testid="open-ccli-paste-dialog-button"

View file

@ -29,6 +29,7 @@ const copyrightText = ref('')
const showAddSectionForm = ref(false) const showAddSectionForm = ref(false)
const newSectionLabel = ref('') const newSectionLabel = ref('')
const newSectionText = ref('') const newSectionText = ref('')
const sectionLabelDropdownOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const saved = ref(false) const saved = ref(false)
@ -235,11 +236,41 @@ const availableGroups = computed(() => {
}) })
const sectionLabelOptions = computed(() => { const sectionLabelOptions = computed(() => {
if (!songData.value?.groups) return [] const fromLabels = (songData.value?.available_labels ?? []).map((label) => label.name)
const fromGroups = (songData.value?.groups ?? []).map((group) => group.name)
return [...new Set(songData.value.groups.map((group) => group.name).filter(Boolean))] return [...new Set([...fromLabels, ...fromGroups].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de'))
}) })
const filteredSectionLabelOptions = computed(() => {
const term = newSectionLabel.value.trim().toLowerCase()
if (term === '') return sectionLabelOptions.value
return sectionLabelOptions.value.filter((name) => name.toLowerCase().includes(term))
})
const canCreateNewLabel = computed(() => {
const term = newSectionLabel.value.trim()
if (term === '') return false
return !sectionLabelOptions.value.some((name) => name.toLowerCase() === term.toLowerCase())
})
function selectSectionLabel(name) {
newSectionLabel.value = name
sectionLabelDropdownOpen.value = false
}
function openSectionLabelDropdown() {
sectionLabelDropdownOpen.value = true
}
function closeSectionLabelDropdown() {
setTimeout(() => {
sectionLabelDropdownOpen.value = false
}, 150)
}
/* ── Section editing ── */ /* ── Section editing ── */
function splitSectionText(value) { function splitSectionText(value) {
@ -720,7 +751,7 @@ onUnmounted(() => {
@submit.prevent="addSection" @submit.prevent="addSection"
> >
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div> <div class="relative">
<label for="section-add-label" class="mb-1 block text-sm font-medium text-gray-700"> <label for="section-add-label" class="mb-1 block text-sm font-medium text-gray-700">
Name Name
</label> </label>
@ -728,19 +759,41 @@ onUnmounted(() => {
data-testid="section-add-label-input" data-testid="section-add-label-input"
id="section-add-label" id="section-add-label"
v-model="newSectionLabel" v-model="newSectionLabel"
list="section-label-options"
type="text" type="text"
autocomplete="off"
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500" class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
placeholder="z.B. Strophe 3" placeholder="Abschnitt wählen oder neuen erstellen…"
required required
@focus="openSectionLabelDropdown"
@input="openSectionLabelDropdown"
@blur="closeSectionLabelDropdown"
> >
<datalist id="section-label-options"> <!-- Combobox dropdown -->
<option <div
v-for="labelName in sectionLabelOptions" v-if="sectionLabelDropdownOpen && (filteredSectionLabelOptions.length > 0 || canCreateNewLabel)"
data-testid="section-label-dropdown"
class="absolute z-30 mt-1 max-h-56 w-full overflow-auto rounded-md border border-gray-200 bg-white shadow-lg"
>
<button
v-for="labelName in filteredSectionLabelOptions"
:key="labelName" :key="labelName"
:value="labelName" type="button"
/> data-testid="section-label-option"
</datalist> class="block w-full px-3 py-2 text-left text-sm hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(labelName)"
>
{{ labelName }}
</button>
<button
v-if="canCreateNewLabel"
type="button"
data-testid="section-label-create-option"
class="block w-full border-t border-gray-100 px-3 py-2 text-left text-sm font-medium text-amber-700 hover:bg-amber-50"
@mousedown.prevent="selectSectionLabel(newSectionLabel.trim())"
>
Neu erstellen: {{ newSectionLabel.trim() }}"
</button>
</div>
</div> </div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">

View file

@ -397,7 +397,7 @@ async function downloadService() {
:current-url="service.key_visual_url" :current-url="service.key_visual_url"
upload-route="services.key-visual.store" upload-route="services.key-visual.store"
:service-id="service.id" :service-id="service.id"
:source-name="service.key_visual_filename ? 'Eigenes Bild' : ($page.props.currentKeyVisual ? 'Standard' : null)" :source-name="service.key_visual_is_own ? 'Eigenes Bild' : (service.key_visual_filename ? 'Standard' : null)"
testid="keyvisual-panel" testid="keyvisual-panel"
/> />
<ServiceImagePanel <ServiceImagePanel
@ -405,7 +405,7 @@ async function downloadService() {
:current-url="service.background_url" :current-url="service.background_url"
upload-route="services.background.store" upload-route="services.background.store"
:service-id="service.id" :service-id="service.id"
:source-name="service.background_filename ? 'Eigenes Bild' : ($page.props.currentBackground ? 'Standard' : null)" :source-name="service.background_is_own ? 'Eigenes Bild' : (service.background_filename ? 'Standard' : null)"
testid="background-panel" testid="background-panel"
/> />
</div> </div>
@ -584,6 +584,8 @@ async function downloadService() {
:available-groups="getAvailableGroups(arrangementDialogItem)" :available-groups="getAvailableGroups(arrangementDialogItem)"
:selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)" :selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)"
:service-song-id="arrangementDialogItem.service_song?.id ?? arrangementDialogItem.serviceSong?.id" :service-song-id="arrangementDialogItem.service_song?.id ?? arrangementDialogItem.serviceSong?.id"
:service-song-name="(arrangementDialogItem.service_song?.cts_song_name ?? arrangementDialogItem.serviceSong?.cts_song_name) ?? arrangementDialogItem.title ?? ''"
:service-song-ccli-id="String((arrangementDialogItem.service_song?.cts_ccli_id ?? arrangementDialogItem.serviceSong?.cts_ccli_id) ?? '')"
:songs-catalog="songsCatalog" :songs-catalog="songsCatalog"
@close="onArrangementDialogClosed" @close="onArrangementDialogClosed"
/> />

View file

@ -9,6 +9,7 @@ import { ref, watch, onMounted } from 'vue'
const songs = ref([]) const songs = ref([])
const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 }) const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 })
const search = ref('') const search = ref('')
const onlyWithContent = ref(true)
const loading = ref(false) const loading = ref(false)
const deleting = ref(null) const deleting = ref(null)
const showDeleteConfirm = ref(false) const showDeleteConfirm = ref(false)
@ -38,6 +39,7 @@ async function fetchSongs(page = 1) {
if (search.value.trim()) { if (search.value.trim()) {
params.set('search', search.value.trim()) params.set('search', search.value.trim())
} }
params.set('with_content', onlyWithContent.value ? '1' : '0')
const response = await fetch(`/api/songs?${params}`, { const response = await fetch(`/api/songs?${params}`, {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
@ -61,6 +63,8 @@ watch(search, () => {
debounceTimer = setTimeout(() => fetchSongs(1), 500) debounceTimer = setTimeout(() => fetchSongs(1), 500)
}) })
watch(onlyWithContent, () => fetchSongs(1))
onMounted(() => fetchSongs()) onMounted(() => fetchSongs())
function goToPage(page) { function goToPage(page) {
@ -68,6 +72,19 @@ function goToPage(page) {
fetchSongs(page) fetchSongs(page)
} }
// Open SongSelect search in a new tab AND open the CCLI import dialog so the user can paste.
function openSongSelectSearch() {
const query = search.value.trim()
if (query) {
window.open(
`https://songselect.ccli.com/search/results?search=${encodeURIComponent(query)}`,
'_blank',
'noopener,noreferrer',
)
}
ccliDialogOpen.value = true
}
function formatDate(value) { function formatDate(value) {
if (!value) return '' if (!value) return ''
return new Date(value).toLocaleDateString('de-DE', { return new Date(value).toLocaleDateString('de-DE', {
@ -391,16 +408,15 @@ function pageRange() {
<!-- CCLI Import Buttons --> <!-- CCLI Import Buttons -->
<div class="mb-4 flex items-center gap-2"> <div class="mb-4 flex items-center gap-2">
<a <button
v-if="search" v-if="search"
:href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(search)" type="button"
target="_blank"
rel="noopener noreferrer"
data-testid="songselect-search-button-songdb" data-testid="songselect-search-button-songdb"
@click="openSongSelectSearch"
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50" class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
> >
Auf SongSelect suchen Auf SongSelect suchen
</a> </button>
<button <button
type="button" type="button"
data-testid="open-ccli-paste-dialog-button-songdb" data-testid="open-ccli-paste-dialog-button-songdb"
@ -409,6 +425,22 @@ function pageRange() {
> >
Aus CCLI importieren Aus CCLI importieren
</button> </button>
<button
type="button"
data-testid="song-list-content-filter"
@click="onlyWithContent = !onlyWithContent"
class="ml-auto inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm font-medium shadow-sm transition"
:class="onlyWithContent
? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'"
:title="onlyWithContent ? 'Zeige nur Songs mit Inhalt' : 'Zeige auch Songs ohne Inhalt'"
>
<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 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
{{ onlyWithContent ? 'Nur mit Inhalt' : 'Alle Songs' }}
</button>
</div> </div>
<!-- Song Count + Loading --> <!-- Song Count + Loading -->
@ -461,12 +493,26 @@ function pageRange() {
<tr <tr
v-for="song in songs" v-for="song in songs"
:key="song.id" :key="song.id"
class="group transition-colors hover:bg-amber-50/30" data-testid="song-list-row"
class="group transition-colors"
:class="song.has_content === false
? 'bg-orange-50 hover:bg-orange-100/60'
: 'hover:bg-amber-50/30'"
> >
<!-- Titel --> <!-- Titel -->
<td class="px-4 py-3.5"> <td class="px-4 py-3.5">
<div class="font-medium text-gray-900">{{ song.title }}</div> <div class="font-medium text-gray-900">{{ song.title }}</div>
<div v-if="song.author" class="mt-0.5 text-xs text-gray-400">{{ song.author }}</div> <div v-if="song.author" class="mt-0.5 text-xs text-gray-400">{{ song.author }}</div>
<div
v-if="song.has_content === false"
data-testid="song-list-no-content-note"
class="mt-1 inline-flex items-center gap-1 rounded-full bg-orange-100 px-2 py-0.5 text-[11px] font-semibold text-orange-700"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
Ohne Inhalt
</div>
</td> </td>
<!-- CCLI-ID --> <!-- CCLI-ID -->

View file

@ -121,8 +121,18 @@ async function fetchTextFromUrl() {
} }
} }
// Mirrors App\Support\CcliLabels::SECTION_LABEL_PATTERN section marks in pasted
// translation text (e.g. "Strophe 1", "Refrain", "Chorus 2") must be ignored so they
// don't shift the line-by-line mapping onto the original slides.
const SECTION_LABEL_PATTERN =
/^(Verse|Vers|Chorus|Bridge|Pre-Chorus|Tag|Ending|Intro|Interlude|Outro|Misc|Strophe|Refrain|Brücke|Vorrefrain|Schluss|Zwischenspiel)\s*(\d+[a-z]?)?\s*(?:\((?:Repeat|Wdh\.?)\))?\s*(?:[xX]\s*\d+)?$/iu
function stripSectionLabelLines(lines) {
return lines.filter((line) => !SECTION_LABEL_PATTERN.test(line.trim()))
}
function distributeTextToSlides(text) { function distributeTextToSlides(text) {
const translatedLines = normalizeNewlines(text).split('\n') const translatedLines = stripSectionLabelLines(normalizeNewlines(text).split('\n'))
let offset = 0 let offset = 0
orderedSlides().forEach((slide) => { orderedSlides().forEach((slide) => {

View file

@ -45,7 +45,7 @@ function ccliFixture(string $name): string
expect($arrangement)->not->toBeNull() expect($arrangement)->not->toBeNull()
->and($arrangement->is_default)->toBeTrue() ->and($arrangement->is_default)->toBeTrue()
->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5) ->and(SongArrangementLabel::where('song_arrangement_id', $arrangement->id)->count())->toBe(5)
->and(SongSlide::count())->toBe(9); ->and(SongSlide::count())->toBe(5);
}); });
test('imports english and german fixture and stores translated slide text', function () { test('imports english and german fixture and stores translated slide text', function () {
@ -56,8 +56,8 @@ function ccliFixture(string $name): string
expect($result['status'])->toBe('created') expect($result['status'])->toBe('created')
->and($song->has_translation)->toBeTrue() ->and($song->has_translation)->toBeTrue()
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4) ->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(2)
->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue(); ->and(SongSlide::where('text_content_translated', "Deutsche Liedzeile 1 zum gleichen Gedanken\nDeutsche Liedzeile 2 trägt den Refrain vor")->exists())->toBeTrue();
}); });
test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () { test('blocks active duplicate ccli_id with DuplicateCcliSongException', function () {
@ -94,7 +94,7 @@ function ccliFixture(string $name): string
->and($song->author)->toBe('Albert Frey') ->and($song->author)->toBe('Albert Frey')
->and($arrangement)->not->toBeNull() ->and($arrangement)->not->toBeNull()
->and($arrangement->arrangementSections)->toHaveCount(2) ->and($arrangement->arrangementSections)->toHaveCount(2)
->and(SongSlide::count())->toBe(12); ->and(SongSlide::count())->toBe(7);
}); });
test('uses distinct label colors for imported section kinds', function () { test('uses distinct label colors for imported section kinds', function () {

View file

@ -44,12 +44,41 @@
$response->assertOk() $response->assertOk()
->assertJsonStructure([ ->assertJsonStructure([
'data' => [['id', 'title', 'ccli_id', 'has_translation', 'created_at', 'updated_at']], 'data' => [['id', 'title', 'ccli_id', 'has_translation', 'has_content', 'created_at', 'updated_at']],
'meta' => ['current_page', 'last_page', 'per_page', 'total'], 'meta' => ['current_page', 'last_page', 'per_page', 'total'],
]); ]);
expect($response->json('meta.total'))->toBe(3); expect($response->json('meta.total'))->toBe(3);
}); });
test('songs api marks songs without slides as no content', function () {
$song = Song::factory()->create();
$response = $this->actingAs($this->user)
->getJson('/api/songs');
$response->assertOk();
expect($response->json('data.0.has_content'))->toBeFalse();
});
test('songs api with_content filter hides songs without content', function () {
$withContent = Song::factory()->create(['title' => 'Mit Inhalt']);
$section = $withContent->sections()->create([
'label_id' => \App\Models\Label::factory()->create()->id,
'order' => 1,
]);
$section->slides()->create(['order' => 1, 'text_content' => 'Zeile']);
Song::factory()->create(['title' => 'Ohne Inhalt']);
$response = $this->actingAs($this->user)
->getJson('/api/songs?with_content=1');
$response->assertOk();
expect($response->json('meta.total'))->toBe(1);
expect($response->json('data.0.title'))->toBe('Mit Inhalt');
expect($response->json('data.0.has_content'))->toBeTrue();
});
test('songs api search filters by title', function () { test('songs api search filters by title', function () {
Song::factory()->create(['title' => 'Großer Gott wir loben dich']); Song::factory()->create(['title' => 'Großer Gott wir loben dich']);
Song::factory()->create(['title' => 'Amazing Grace']); Song::factory()->create(['title' => 'Amazing Grace']);