+
@@ -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"
>
-
diff --git a/resources/js/Pages/Services/Edit.vue b/resources/js/Pages/Services/Edit.vue
index 8e1210a..4e2ff5b 100644
--- a/resources/js/Pages/Services/Edit.vue
+++ b/resources/js/Pages/Services/Edit.vue
@@ -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"
/>
@@ -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"
/>
diff --git a/resources/js/Pages/Songs/Index.vue b/resources/js/Pages/Songs/Index.vue
index fb78136..6d6a379 100644
--- a/resources/js/Pages/Songs/Index.vue
+++ b/resources/js/Pages/Songs/Index.vue
@@ -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() {
@@ -461,12 +493,26 @@ function pageRange() {
|
{{ song.title }}
{{ song.author }}
+
|
diff --git a/resources/js/Pages/Songs/Translate.vue b/resources/js/Pages/Songs/Translate.vue
index 2d30f39..ca5066a 100644
--- a/resources/js/Pages/Songs/Translate.vue
+++ b/resources/js/Pages/Songs/Translate.vue
@@ -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) => {
diff --git a/tests/Feature/CcliImportServiceTest.php b/tests/Feature/CcliImportServiceTest.php
index 1fc087c..c7150eb 100644
--- a/tests/Feature/CcliImportServiceTest.php
+++ b/tests/Feature/CcliImportServiceTest.php
@@ -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 () {
diff --git a/tests/Feature/SongIndexTest.php b/tests/Feature/SongIndexTest.php
index fb1eaec..54d10e9 100644
--- a/tests/Feature/SongIndexTest.php
+++ b/tests/Feature/SongIndexTest.php
@@ -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']);