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:
parent
ae42b48753
commit
ff3484466b
|
|
@ -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([
|
||||
'serviceSongs' => fn ($query) => $query->orderBy('order'),
|
||||
|
|
@ -142,6 +142,7 @@ public function edit(Service $service): Response
|
|||
]);
|
||||
|
||||
$songsCatalog = Song::query()
|
||||
->withCount(['sections as content_slides_count' => fn ($q) => $q->has('slides')])
|
||||
->orderBy('title')
|
||||
->get(['id', 'title', 'ccli_id', 'has_translation'])
|
||||
->map(fn (Song $song) => [
|
||||
|
|
@ -149,6 +150,7 @@ public function edit(Service $service): Response
|
|||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'has_translation' => $song->has_translation,
|
||||
'has_content' => (int) $song->content_slides_count > 0,
|
||||
])
|
||||
->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', [
|
||||
'service' => [
|
||||
'id' => $service->id,
|
||||
|
|
@ -266,10 +275,12 @@ public function edit(Service $service): Response
|
|||
'finalized_at' => $service->finalized_at?->toJSON(),
|
||||
'last_synced_at' => $service->last_synced_at?->toJSON(),
|
||||
'has_agenda' => $service->has_agenda,
|
||||
'key_visual_filename' => $service->key_visual_filename,
|
||||
'background_filename' => $service->background_filename,
|
||||
'key_visual_url' => $service->key_visual_filename ? '/storage/'.$service->key_visual_filename : null,
|
||||
'background_url' => $service->background_filename ? '/storage/'.$service->background_filename : null,
|
||||
'key_visual_filename' => $resolvedKeyVisual,
|
||||
'background_filename' => $resolvedBackground,
|
||||
'key_visual_url' => $resolvedKeyVisual ? '/storage/'.$resolvedKeyVisual : null,
|
||||
'background_url' => $resolvedBackground ? '/storage/'.$resolvedBackground : null,
|
||||
'key_visual_is_own' => $keyVisualIsOwn,
|
||||
'background_is_own' => $backgroundIsOwn,
|
||||
'moderator_name' => $service->moderator_name,
|
||||
'preacher_name_override' => $service->preacher_name_override,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SongRequest;
|
||||
use App\Models\Label;
|
||||
use App\Models\Song;
|
||||
use App\Services\SongService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -17,7 +18,8 @@ public function __construct(
|
|||
|
||||
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')) {
|
||||
$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')
|
||||
->paginate($request->input('per_page', 20));
|
||||
|
||||
|
|
@ -36,6 +44,7 @@ public function index(Request $request): JsonResponse
|
|||
'ccli_id' => $song->ccli_id,
|
||||
'author' => $song->author,
|
||||
'has_translation' => $song->has_translation,
|
||||
'has_content' => (int) $song->content_slides_count > 0,
|
||||
'last_used_at' => $song->last_used_at?->toDateString(),
|
||||
'last_used_in_service' => $song->last_used_in_service,
|
||||
'created_at' => $song->created_at->toDateTimeString(),
|
||||
|
|
@ -155,6 +164,15 @@ public function formatSongDetail(Song $song): array
|
|||
'created_at' => $song->created_at->toDateTimeString(),
|
||||
'updated_at' => $song->updated_at->toDateTimeString(),
|
||||
'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) => [
|
||||
'id' => $arr->id,
|
||||
'name' => $arr->name,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@
|
|||
|
||||
final class CcliImportService
|
||||
{
|
||||
/**
|
||||
* Number of lyric lines grouped into a single projection slide.
|
||||
*/
|
||||
private const LINES_PER_SLIDE = 2;
|
||||
|
||||
private const LABEL_KIND_COLORS = [
|
||||
'Verse' => '#3B82F6',
|
||||
'Chorus' => '#10B981',
|
||||
|
|
@ -83,13 +88,20 @@ public function import(string $rawText, ?string $sourceUrl = null): array
|
|||
|
||||
$section->slides()->delete();
|
||||
|
||||
foreach ($parsedSection->lines as $slideOrder => $line) {
|
||||
$translatedLine = $parsedSection->linesTranslated[$slideOrder] ?? null;
|
||||
// Group lines into pairs: each slide carries up to two lines.
|
||||
$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) !== '');
|
||||
|
||||
$section->slides()->create([
|
||||
'order' => $slideOrder + 1,
|
||||
'text_content' => $line,
|
||||
'text_content' => implode("\n", $chunk),
|
||||
'text_content_translated' => $translatedLine,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,14 @@ const props = defineProps({
|
|||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
serviceSongName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
serviceSongCcliId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
songsCatalog: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
|
|
@ -40,7 +48,7 @@ const emit = defineEmits(['close', 'arrangement-selected'])
|
|||
|
||||
const isUnmatched = computed(() => !props.songId)
|
||||
|
||||
const searchQuery = ref('')
|
||||
const searchQuery = ref(props.serviceSongName ?? '')
|
||||
const selectedSongId = ref('')
|
||||
const dropdownOpen = ref(false)
|
||||
const assignError = ref('')
|
||||
|
|
@ -59,6 +67,23 @@ const filteredCatalog = computed(() => {
|
|||
.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() {
|
||||
dropdownOpen.value = true
|
||||
}
|
||||
|
|
@ -250,6 +275,11 @@ function closeOnEscape(e) {
|
|||
onMounted(() => {
|
||||
document.addEventListener('keydown', closeOnEscape)
|
||||
document.addEventListener('click', onBodyClick)
|
||||
|
||||
// For unmatched songs: show search results immediately (prefilled with the song name).
|
||||
if (isUnmatched.value) {
|
||||
dropdownOpen.value = true
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -532,10 +562,19 @@ function closeOnBackdrop(e) {
|
|||
v-for="song in filteredCatalog"
|
||||
:key="song.id"
|
||||
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)"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -552,16 +591,14 @@ function closeOnBackdrop(e) {
|
|||
>
|
||||
Zuordnen
|
||||
</button>
|
||||
<a
|
||||
v-if="searchQuery"
|
||||
:href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(searchQuery)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<button
|
||||
type="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"
|
||||
>
|
||||
Auf SongSelect suchen ↗
|
||||
</a>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-ccli-paste-dialog-button"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const copyrightText = ref('')
|
|||
const showAddSectionForm = ref(false)
|
||||
const newSectionLabel = ref('')
|
||||
const newSectionText = ref('')
|
||||
const sectionLabelDropdownOpen = ref(false)
|
||||
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
|
|
@ -235,11 +236,41 @@ const availableGroups = 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 ── */
|
||||
|
||||
function splitSectionText(value) {
|
||||
|
|
@ -720,7 +751,7 @@ onUnmounted(() => {
|
|||
@submit.prevent="addSection"
|
||||
>
|
||||
<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">
|
||||
Name
|
||||
</label>
|
||||
|
|
@ -728,19 +759,41 @@ onUnmounted(() => {
|
|||
data-testid="section-add-label-input"
|
||||
id="section-add-label"
|
||||
v-model="newSectionLabel"
|
||||
list="section-label-options"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
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
|
||||
@focus="openSectionLabelDropdown"
|
||||
@input="openSectionLabelDropdown"
|
||||
@blur="closeSectionLabelDropdown"
|
||||
>
|
||||
<datalist id="section-label-options">
|
||||
<option
|
||||
v-for="labelName in sectionLabelOptions"
|
||||
<!-- Combobox dropdown -->
|
||||
<div
|
||||
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"
|
||||
:value="labelName"
|
||||
/>
|
||||
</datalist>
|
||||
type="button"
|
||||
data-testid="section-label-option"
|
||||
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 class="sm:col-span-2">
|
||||
|
|
|
|||
|
|
@ -397,7 +397,7 @@ async function downloadService() {
|
|||
:current-url="service.key_visual_url"
|
||||
upload-route="services.key-visual.store"
|
||||
: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"
|
||||
/>
|
||||
<ServiceImagePanel
|
||||
|
|
@ -405,7 +405,7 @@ async function downloadService() {
|
|||
:current-url="service.background_url"
|
||||
upload-route="services.background.store"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -584,6 +584,8 @@ async function downloadService() {
|
|||
:available-groups="getAvailableGroups(arrangementDialogItem)"
|
||||
:selected-arrangement-id="getSelectedArrangementId(arrangementDialogItem)"
|
||||
: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"
|
||||
@close="onArrangementDialogClosed"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { ref, watch, onMounted } from 'vue'
|
|||
const songs = ref([])
|
||||
const meta = ref({ current_page: 1, last_page: 1, per_page: 20, total: 0 })
|
||||
const search = ref('')
|
||||
const onlyWithContent = ref(true)
|
||||
const loading = ref(false)
|
||||
const deleting = ref(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
|
|
@ -38,6 +39,7 @@ async function fetchSongs(page = 1) {
|
|||
if (search.value.trim()) {
|
||||
params.set('search', search.value.trim())
|
||||
}
|
||||
params.set('with_content', onlyWithContent.value ? '1' : '0')
|
||||
const response = await fetch(`/api/songs?${params}`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
|
|
@ -61,6 +63,8 @@ watch(search, () => {
|
|||
debounceTimer = setTimeout(() => fetchSongs(1), 500)
|
||||
})
|
||||
|
||||
watch(onlyWithContent, () => fetchSongs(1))
|
||||
|
||||
onMounted(() => fetchSongs())
|
||||
|
||||
function goToPage(page) {
|
||||
|
|
@ -68,6 +72,19 @@ function goToPage(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) {
|
||||
if (!value) return '–'
|
||||
return new Date(value).toLocaleDateString('de-DE', {
|
||||
|
|
@ -391,16 +408,15 @@ function pageRange() {
|
|||
|
||||
<!-- CCLI Import Buttons -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<a
|
||||
<button
|
||||
v-if="search"
|
||||
:href="'https://songselect.ccli.com/search/results?search=' + encodeURIComponent(search)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
Auf SongSelect suchen ↗
|
||||
</a>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="open-ccli-paste-dialog-button-songdb"
|
||||
|
|
@ -409,6 +425,22 @@ function pageRange() {
|
|||
>
|
||||
Aus CCLI importieren
|
||||
</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>
|
||||
|
||||
<!-- Song Count + Loading -->
|
||||
|
|
@ -461,12 +493,26 @@ function pageRange() {
|
|||
<tr
|
||||
v-for="song in songs"
|
||||
: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 -->
|
||||
<td class="px-4 py-3.5">
|
||||
<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.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>
|
||||
|
||||
<!-- CCLI-ID -->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
const translatedLines = normalizeNewlines(text).split('\n')
|
||||
const translatedLines = stripSectionLabelLines(normalizeNewlines(text).split('\n'))
|
||||
let offset = 0
|
||||
|
||||
orderedSlides().forEach((slide) => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ function ccliFixture(string $name): string
|
|||
expect($arrangement)->not->toBeNull()
|
||||
->and($arrangement->is_default)->toBeTrue()
|
||||
->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 () {
|
||||
|
|
@ -56,8 +56,8 @@ function ccliFixture(string $name): string
|
|||
|
||||
expect($result['status'])->toBe('created')
|
||||
->and($song->has_translation)->toBeTrue()
|
||||
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(4)
|
||||
->and(SongSlide::where('text_content_translated', 'Deutsche Liedzeile 1 zum gleichen Gedanken')->exists())->toBeTrue();
|
||||
->and(SongSlide::whereNotNull('text_content_translated')->count())->toBe(2)
|
||||
->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 () {
|
||||
|
|
@ -94,7 +94,7 @@ function ccliFixture(string $name): string
|
|||
->and($song->author)->toBe('Albert Frey')
|
||||
->and($arrangement)->not->toBeNull()
|
||||
->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 () {
|
||||
|
|
|
|||
|
|
@ -44,12 +44,41 @@
|
|||
|
||||
$response->assertOk()
|
||||
->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'],
|
||||
]);
|
||||
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 () {
|
||||
Song::factory()->create(['title' => 'Großer Gott wir loben dich']);
|
||||
Song::factory()->create(['title' => 'Amazing Grace']);
|
||||
|
|
|
|||
Loading…
Reference in a new issue