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([
|
$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,
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue