diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index 6c3a404..c160c51 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -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, ], diff --git a/app/Http/Controllers/SongController.php b/app/Http/Controllers/SongController.php index 5ab4f8e..32772c3 100644 --- a/app/Http/Controllers/SongController.php +++ b/app/Http/Controllers/SongController.php @@ -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, diff --git a/app/Services/CcliImportService.php b/app/Services/CcliImportService.php index e2b94ab..b4dadc1 100644 --- a/app/Services/CcliImportService.php +++ b/app/Services/CcliImportService.php @@ -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, ]); } diff --git a/resources/js/Components/ArrangementDialog.vue b/resources/js/Components/ArrangementDialog.vue index de7d31f..b5a5201 100644 --- a/resources/js/Components/ArrangementDialog.vue +++ b/resources/js/Components/ArrangementDialog.vue @@ -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)" > {{ song.title }} + + Ohne Inhalt + CCLI: {{ song.ccli_id || '–' }} @@ -552,16 +591,14 @@ function closeOnBackdrop(e) { > Zuordnen - Auf SongSelect suchen ↗ - + + +
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() {
- Auf SongSelect suchen ↗ - + + +
@@ -461,12 +493,26 @@ function pageRange() {
{{ song.title }}
{{ song.author }}
+
+ + + + Ohne Inhalt +
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']);