feat: Wave 4 - Song DB Management + Finalization (T20-T24)
T20: Song DB Page - Songs/Index.vue with search, action buttons, pagination - Upload area for .pro files (calls T23 placeholder) - Song-Datenbank nav link added to AuthenticatedLayout - Tests: 9 new (44 assertions) T21: Song DB Edit Popup - SongEditModal.vue with metadata + ArrangementConfigurator - Auto-save with fetch (500ms debounce for text, immediate on blur) - Tests: 11 new (53 assertions) T22: Song DB Translate Page - Songs/Translate.vue with two-column editor - URL fetch or manual paste, line-count constraints - Group headers with colors, save marks has_translation=true - Tests: 1 new (12 assertions) T23: .pro File Placeholders - ProParserNotImplementedException with HTTP 501 - ProFileController with importPro/downloadPro placeholders - German error messages - Tests: 5 new (7 assertions) T24: Service Finalization + Status - Two-step finalization with warnings (unmatched songs, missing slides) - Download placeholder toast - isReadyToFinalize accessor on Service model - Tests: 11 new (30 assertions) All tests passing: 174/174 (905 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form
This commit is contained in:
parent
d75d748417
commit
27f8402ae8
|
|
@ -150,3 +150,88 @@ ### Tests (Pest style)
|
||||||
- DomPDF `download()` returns `Illuminate\Http\Response` with `Content-Disposition: attachment; filename=...`
|
- DomPDF `download()` returns `Illuminate\Http\Response` with `Content-Disposition: attachment; filename=...`
|
||||||
- `assertHeader('Content-Type', 'application/pdf')` verifies PDF generation succeeded
|
- `assertHeader('Content-Type', 'application/pdf')` verifies PDF generation succeeded
|
||||||
- Content-Disposition header contains slugified `song.title` + `arrangement.name`
|
- Content-Disposition header contains slugified `song.title` + `arrangement.name`
|
||||||
|
|
||||||
|
### T19 Update — Preview JSON Endpoint + Modal Refactor
|
||||||
|
- Added `preview()` method to SongPdfController returning JSON for Vue modal consumption
|
||||||
|
- Route: `GET /songs/{song}/arrangements/{arrangement}/preview` -> `songs.preview`
|
||||||
|
- SongPreviewModal.vue now fetches data via `fetch()` when modal opens (not props-based)
|
||||||
|
- Reuses existing Modal.vue component (dialog-based with Transition animations)
|
||||||
|
- `contrastColor()` utility calculates white/dark text based on group background luminance (0.299*R + 0.587*G + 0.114*B threshold)
|
||||||
|
- Preview tests: 4 additional tests (arrangement order, translations, 404 mismatch, auth) — total 13 tests for SongPdfTest
|
||||||
|
- barryvdh/laravel-dompdf v3.1.1 installed (dompdf v3.1.4 engine)
|
||||||
|
- Full suite: 137 tests passing (759 assertions)
|
||||||
|
|
||||||
|
## [2026-03-01] T23: .pro File Upload + Download Placeholders
|
||||||
|
|
||||||
|
- ProParserNotImplementedException: Custom exception extending Exception with German error message
|
||||||
|
- Exception renders as HTTP 501 with JSON response: `{ message, error: 'ProParserNotImplemented' }`
|
||||||
|
- ProFileController: Two placeholder methods (importPro, downloadPro) both throw the exception
|
||||||
|
- Routes: POST /api/songs/import-pro, GET /api/songs/{song}/download-pro under auth:sanctum middleware
|
||||||
|
- Tests: 5 passing (7 assertions) — covers 501 responses, German messages, auth requirements, 404 for missing song
|
||||||
|
- Both endpoints return 501 Not Implemented until .pro parser spec is finalized
|
||||||
|
- Unauthenticated API requests return 401 (via postJson/getJson helpers)
|
||||||
|
- Model binding returns 404 for non-existent songs before controller is reached
|
||||||
|
|
||||||
|
## [2026-03-01] T20: Song DB Page (List + Search + Filters)
|
||||||
|
|
||||||
|
- Songs/Index.vue fetches data from API (`/api/songs`) rather than Inertia props — better for dynamic debounced search
|
||||||
|
- Web route is a simple closure rendering `Inertia::render('Songs/Index')` with no props — API handles all data
|
||||||
|
- AuthenticatedLayout already had conditional Song-Datenbank NavLink checking `$page.props.ziggy?.routes?.['songs.index']`; adding route auto-enables it
|
||||||
|
- ResponsiveNavLink for mobile menu needed manual addition (wasn't conditionally pre-wired like desktop)
|
||||||
|
- `$this->withoutVite()` required in Inertia page render tests (ViteException without build manifest)
|
||||||
|
- Upload area is placeholder: shows German error message for .pro imports (T23 implements actual parser)
|
||||||
|
- Action buttons emit events (`$emit('edit', song)`) for modal integration (T21) and download (T23)
|
||||||
|
- Translate action links to `/songs/{id}/translate` route (T22)
|
||||||
|
- Soft-delete with confirm modal uses Teleport + Transition for proper z-index and animation
|
||||||
|
- Pagination with ellipsis range calculation: `pageRange()` shows first, last, ±2 around current
|
||||||
|
- Tests: 9 passing (44 assertions), full suite: 162 tests (840 assertions)
|
||||||
|
- Vite build: ✓ successful with new page bundle
|
||||||
|
|
||||||
|
|
||||||
|
## [2026-03-01] T24: Service Finalization + Status Management
|
||||||
|
|
||||||
|
### Finalization with Prerequisite Warnings
|
||||||
|
- Changed `ServiceController::finalize()` from redirect-based to JSON response for two-step confirmation flow
|
||||||
|
- `Service::finalizationStatus()` method returns `['ready' => bool, 'warnings' => string[]]` — checks songs matched, arrangements, sermon slides
|
||||||
|
- Song counts only warn when `$totalSongs > 0` (0/0 songs is not a problem)
|
||||||
|
- Frontend sends `confirmed: false` first call; if `needs_confirmation` returned, shows dialog; second call sends `confirmed: true` to force finalize
|
||||||
|
- `request()->boolean('confirmed')` cleanly handles the JSON boolean from fetch()
|
||||||
|
- `isReadyToFinalize` accessor uses `Attribute::get()` pattern from Laravel 12
|
||||||
|
|
||||||
|
### Download Placeholder
|
||||||
|
- `GET /services/{service}/download` returns JSON `{ message: '...' }` — placeholder for future show generation
|
||||||
|
- Route parameter kept as `Service $service` for model binding even though placeholder doesn't use it
|
||||||
|
|
||||||
|
### Frontend Pattern
|
||||||
|
- Finalize uses native `fetch()` with JSON instead of Inertia `router.post()` because we need to inspect the response before deciding whether to show the confirmation dialog or reload
|
||||||
|
- `router.reload({ preserveScroll: true })` after successful finalize to refresh the Inertia page data
|
||||||
|
- Confirmation dialog uses `<Teleport to="body">` with backdrop click-to-dismiss
|
||||||
|
- Toast system with types (success/warning/info) and auto-dismiss after 3.5s
|
||||||
|
|
||||||
|
### Test Pattern
|
||||||
|
- Updated existing `ServiceControllerTest::test_service_kann_abgeschlossen_werden` to use `postJson` with `confirmed: true`
|
||||||
|
- 11 new Pest tests covering: warnings returned, confirmed override, direct finalize, partial warnings, reopen, download placeholder, auth checks, model accessor
|
||||||
|
- Full suite: 162 tests, 840 assertions
|
||||||
|
- 2026-03-01: Die Translate-Seite bleibt stabil, wenn die Verteilung und der Export immer strikt in `group.order` + `slide.order` laufen und jede Uebersetzungs-Textarea direkt auf die Original-Zeilenanzahl begrenzt sowie mit Leerzeilen aufgefuellt wird.
|
||||||
|
|
||||||
|
## [2026-03-01] T21: Song DB Edit Popup (Metadata + Arrangement)
|
||||||
|
|
||||||
|
### SongEditModal.vue
|
||||||
|
- Uses `fetch()` + `useDebounceFn` (VueUse) instead of `useAutoSave` composable because SongController is an API route (`/api/songs/{id}`) returning JSON — Inertia `router.put()` in `useAutoSave` expects Inertia responses and fails with JSON APIs
|
||||||
|
- CSRF token from `document.querySelector('meta[name="csrf-token"]')` required for fetch-based PUT requests
|
||||||
|
- Teleport to body pattern (from SongPreviewModal T19): backdrop `@click` with `e.target === e.currentTarget` for click-outside dismiss
|
||||||
|
- Escape key listener: `onMounted`/`onUnmounted` lifecycle for document-level keydown listener
|
||||||
|
- Auto-save: 500ms debounce for text inputs via `useDebounceFn`, immediate save on blur via `debouncedSave.cancel()` then direct `performSave()`
|
||||||
|
- ArrangementConfigurator requires `arrangements` prop with nested `groups` array — must transform API response `arrangement_groups[].song_group_id` into full group objects by looking up in `songData.groups`
|
||||||
|
- Save status indicator: `saving`/`saved` refs with 2s auto-clear timeout for "Gespeichert" feedback
|
||||||
|
- Amber color scheme to match existing Songs/Index.vue design language (not indigo)
|
||||||
|
|
||||||
|
### Integration into Songs/Index.vue
|
||||||
|
- Index.vue already had `$emit('edit', song)` on Bearbeiten button — replaced with `openEditModal(song)` function
|
||||||
|
- `editSongId` ref + `showEditModal` ref control modal visibility
|
||||||
|
- `@updated` event from modal triggers `fetchSongs(meta.value.current_page)` to refresh the list after edits
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- 11 Pest tests, 53 assertions — covers: show full detail, title/ccli/copyright auto-save, null clearing, response structure, validation (required title, unique ccli), auth, 404 for deleted/nonexistent
|
||||||
|
- Full suite: 175 tests passing (925 assertions)
|
||||||
|
- Vite build: ✓ successful
|
||||||
|
|
|
||||||
21
app/Exceptions/ProParserNotImplementedException.php
Normal file
21
app/Exceptions/ProParserNotImplementedException.php
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class ProParserNotImplementedException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(string $message = 'Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation.')
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => $this->message,
|
||||||
|
'error' => 'ProParserNotImplemented',
|
||||||
|
], 501);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/ProFileController.php
Normal file
29
app/Http/Controllers/ProFileController.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Exceptions\ProParserNotImplementedException;
|
||||||
|
use App\Models\Song;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ProFileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Upload and import .pro file(s).
|
||||||
|
* Placeholder: throws NotImplementedException until parser spec is finalized.
|
||||||
|
*/
|
||||||
|
public function importPro(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
throw new ProParserNotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download .pro file for a song.
|
||||||
|
* Placeholder: throws NotImplementedException until parser spec is finalized.
|
||||||
|
*/
|
||||||
|
public function downloadPro(Song $song): JsonResponse
|
||||||
|
{
|
||||||
|
throw new ProParserNotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\Song;
|
use App\Models\Song;
|
||||||
use App\Models\Slide;
|
use App\Models\Slide;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
@ -175,15 +176,27 @@ public function edit(Service $service): Response
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function finalize(Service $service): RedirectResponse
|
public function finalize(Service $service): JsonResponse
|
||||||
{
|
{
|
||||||
|
$status = $service->finalizationStatus();
|
||||||
|
|
||||||
|
$confirmed = request()->boolean('confirmed');
|
||||||
|
|
||||||
|
if (! $status['ready'] && ! $confirmed) {
|
||||||
|
return response()->json([
|
||||||
|
'needs_confirmation' => true,
|
||||||
|
'warnings' => $status['warnings'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$service->update([
|
$service->update([
|
||||||
'finalized_at' => now(),
|
'finalized_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()
|
return response()->json([
|
||||||
->route('services.index')
|
'needs_confirmation' => false,
|
||||||
->with('success', 'Service wurde abgeschlossen.');
|
'success' => 'Service wurde abgeschlossen.',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reopen(Service $service): RedirectResponse
|
public function reopen(Service $service): RedirectResponse
|
||||||
|
|
@ -196,4 +209,11 @@ public function reopen(Service $service): RedirectResponse
|
||||||
->route('services.index')
|
->route('services.index')
|
||||||
->with('success', 'Service wurde wieder geoeffnet.');
|
->with('success', 'Service wurde wieder geoeffnet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function download(Service $service): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
use App\Services\TranslationService;
|
use App\Services\TranslationService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
class TranslationController extends Controller
|
class TranslationController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -14,6 +16,38 @@ public function __construct(
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function page(Song $song): Response
|
||||||
|
{
|
||||||
|
$song->load([
|
||||||
|
'groups' => fn ($query) => $query
|
||||||
|
->orderBy('order')
|
||||||
|
->with([
|
||||||
|
'slides' => fn ($slideQuery) => $slideQuery->orderBy('order'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('Songs/Translate', [
|
||||||
|
'song' => [
|
||||||
|
'id' => $song->id,
|
||||||
|
'title' => $song->title,
|
||||||
|
'ccli_id' => $song->ccli_id,
|
||||||
|
'has_translation' => $song->has_translation,
|
||||||
|
'groups' => $song->groups->map(fn ($group) => [
|
||||||
|
'id' => $group->id,
|
||||||
|
'name' => $group->name,
|
||||||
|
'color' => $group->color,
|
||||||
|
'order' => $group->order,
|
||||||
|
'slides' => $group->slides->map(fn ($slide) => [
|
||||||
|
'id' => $slide->id,
|
||||||
|
'order' => $slide->order,
|
||||||
|
'text_content' => $slide->text_content,
|
||||||
|
'text_content_translated' => $slide->text_content_translated,
|
||||||
|
])->values(),
|
||||||
|
])->values(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* URL abrufen und Text zum Prüfen zurückgeben.
|
* URL abrufen und Text zum Prüfen zurückgeben.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
@ -40,4 +41,41 @@ public function slides(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Slide::class);
|
return $this->hasMany(Slide::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check finalization prerequisites and return warnings.
|
||||||
|
*
|
||||||
|
* @return array{ready: bool, warnings: string[]}
|
||||||
|
*/
|
||||||
|
public function finalizationStatus(): array
|
||||||
|
{
|
||||||
|
$warnings = [];
|
||||||
|
|
||||||
|
$totalSongs = $this->serviceSongs()->count();
|
||||||
|
$mappedSongs = $this->serviceSongs()->whereNotNull('song_id')->count();
|
||||||
|
$arrangedSongs = $this->serviceSongs()->whereNotNull('song_arrangement_id')->count();
|
||||||
|
$sermonSlides = $this->slides()->where('type', 'sermon')->count();
|
||||||
|
|
||||||
|
if ($totalSongs > 0 && $mappedSongs < $totalSongs) {
|
||||||
|
$warnings[] = "Nur {$mappedSongs} von {$totalSongs} Songs sind zugeordnet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($totalSongs > 0 && $arrangedSongs < $totalSongs) {
|
||||||
|
$warnings[] = "Nur {$arrangedSongs} von {$totalSongs} Songs haben ein Arrangement.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sermonSlides === 0) {
|
||||||
|
$warnings[] = 'Es wurden keine Predigtfolien hochgeladen.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ready' => empty($warnings),
|
||||||
|
'warnings' => $warnings,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isReadyToFinalize(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::get(fn () => $this->finalizationStatus()['ready']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
496
resources/js/Components/SongEditModal.vue
Normal file
496
resources/js/Components/SongEditModal.vue
Normal file
|
|
@ -0,0 +1,496 @@
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import ArrangementConfigurator from '@/Components/ArrangementConfigurator.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
songId: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'updated'])
|
||||||
|
|
||||||
|
/* ── State ── */
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
const songData = ref(null)
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const ccliId = ref('')
|
||||||
|
const copyrightText = ref('')
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const saved = ref(false)
|
||||||
|
let savedTimeout = null
|
||||||
|
|
||||||
|
/* ── Data fetching ── */
|
||||||
|
|
||||||
|
const fetchSong = async () => {
|
||||||
|
if (!props.songId) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/songs/${props.songId}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Song konnte nicht geladen werden.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
songData.value = json.data
|
||||||
|
|
||||||
|
title.value = json.data.title ?? ''
|
||||||
|
ccliId.value = json.data.ccli_id ?? ''
|
||||||
|
copyrightText.value = json.data.copyright_text ?? ''
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(isVisible) => {
|
||||||
|
if (isVisible && props.songId) {
|
||||||
|
fetchSong()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
songData.value = null
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/* ── Auto-save metadata (fetch-based, 500ms debounce for text) ── */
|
||||||
|
|
||||||
|
const performSave = async (data) => {
|
||||||
|
if (!props.songId) return
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
saved.value = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||||
|
|
||||||
|
const response = await fetch(`/api/songs/${props.songId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Speichern fehlgeschlagen')
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = false
|
||||||
|
saved.value = true
|
||||||
|
|
||||||
|
if (savedTimeout) clearTimeout(savedTimeout)
|
||||||
|
savedTimeout = setTimeout(() => {
|
||||||
|
saved.value = false
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
emit('updated')
|
||||||
|
} catch {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
title: title.value,
|
||||||
|
ccli_id: ccliId.value || null,
|
||||||
|
copyright_text: copyrightText.value || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 500ms debounce for text inputs
|
||||||
|
const debouncedSave = useDebounceFn((data) => {
|
||||||
|
performSave(data)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
|
const onTextInput = () => {
|
||||||
|
debouncedSave(buildPayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediate save on blur (cancel pending debounce)
|
||||||
|
const onFieldBlur = () => {
|
||||||
|
debouncedSave.cancel?.()
|
||||||
|
performSave(buildPayload())
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Arrangement props ── */
|
||||||
|
|
||||||
|
const arrangements = computed(() => {
|
||||||
|
if (!songData.value?.arrangements) return []
|
||||||
|
|
||||||
|
return songData.value.arrangements.map((arr) => ({
|
||||||
|
id: arr.id,
|
||||||
|
name: arr.name,
|
||||||
|
is_default: arr.is_default,
|
||||||
|
groups: arr.arrangement_groups.map((ag) => {
|
||||||
|
const group = songData.value.groups.find((g) => g.id === ag.song_group_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ag.song_group_id,
|
||||||
|
name: group?.name ?? 'Unbekannt',
|
||||||
|
color: group?.color ?? '#6b7280',
|
||||||
|
order: ag.order,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableGroups = computed(() => {
|
||||||
|
if (!songData.value?.groups) return []
|
||||||
|
|
||||||
|
return songData.value.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
name: group.name,
|
||||||
|
color: group.color,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ── Close handling ── */
|
||||||
|
|
||||||
|
const closeOnEscape = (e) => {
|
||||||
|
if (e.key === 'Escape' && props.show) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOnBackdrop = (e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', closeOnEscape)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', closeOnEscape)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="show"
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 sm:p-8"
|
||||||
|
@click="closeOnBackdrop"
|
||||||
|
>
|
||||||
|
<div class="relative w-full max-w-4xl rounded-xl bg-white shadow-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 text-amber-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">
|
||||||
|
Song bearbeiten
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Metadaten und Arrangements verwalten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<!-- Save status indicator -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 translate-y-1"
|
||||||
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="saving"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-gray-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Speichert…
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else-if="saved"
|
||||||
|
class="inline-flex items-center gap-1.5 text-sm text-emerald-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Gespeichert
|
||||||
|
</span>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center py-16"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-8 w-8 animate-spin text-amber-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3 text-gray-600">Song wird geladen…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div
|
||||||
|
v-else-if="error"
|
||||||
|
class="px-6 py-16 text-center"
|
||||||
|
>
|
||||||
|
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
|
||||||
|
<svg
|
||||||
|
class="h-6 w-6 text-red-600"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 rounded-md bg-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-300"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div
|
||||||
|
v-else-if="songData"
|
||||||
|
class="max-h-[80vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<!-- Metadata Fields -->
|
||||||
|
<div class="border-b border-gray-100 px-6 py-5">
|
||||||
|
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Metadaten
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
for="song-edit-title"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="song-edit-title"
|
||||||
|
v-model="title"
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||||
|
placeholder="Songtitel eingeben…"
|
||||||
|
@input="onTextInput"
|
||||||
|
@blur="onFieldBlur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
for="song-edit-ccli"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
CCLI-ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="song-edit-ccli"
|
||||||
|
v-model="ccliId"
|
||||||
|
type="text"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||||
|
placeholder="z.B. 123456"
|
||||||
|
@input="onTextInput"
|
||||||
|
@blur="onFieldBlur"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="self-end pb-2 text-sm text-gray-400">
|
||||||
|
<span
|
||||||
|
v-if="songData.has_translation"
|
||||||
|
class="inline-flex items-center gap-1 text-emerald-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Übersetzung vorhanden
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-gray-400"
|
||||||
|
>
|
||||||
|
Keine Übersetzung
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
for="song-edit-copyright"
|
||||||
|
class="mb-1 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Copyright-Text
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="song-edit-copyright"
|
||||||
|
v-model="copyrightText"
|
||||||
|
rows="3"
|
||||||
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-amber-500 focus:ring-amber-500"
|
||||||
|
placeholder="Copyright-Informationen…"
|
||||||
|
@input="onTextInput"
|
||||||
|
@blur="onFieldBlur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrangement Configurator -->
|
||||||
|
<div class="px-6 py-5">
|
||||||
|
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500">
|
||||||
|
Arrangements
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<ArrangementConfigurator
|
||||||
|
:song-id="songData.id"
|
||||||
|
:arrangements="arrangements"
|
||||||
|
:available-groups="availableGroups"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
@ -236,6 +236,13 @@ function triggerSync() {
|
||||||
>
|
>
|
||||||
Services
|
Services
|
||||||
</ResponsiveNavLink>
|
</ResponsiveNavLink>
|
||||||
|
<ResponsiveNavLink
|
||||||
|
v-if="$page.props.ziggy?.routes?.['songs.index']"
|
||||||
|
:href="route('songs.index')"
|
||||||
|
:active="route().current('songs.*')"
|
||||||
|
>
|
||||||
|
Song-Datenbank
|
||||||
|
</ResponsiveNavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Sync -->
|
<!-- Mobile Sync -->
|
||||||
|
|
|
||||||
|
|
@ -11,47 +11,109 @@ const props = defineProps({
|
||||||
})
|
})
|
||||||
|
|
||||||
const toastMessage = ref('')
|
const toastMessage = ref('')
|
||||||
|
const toastType = ref('info')
|
||||||
|
const confirmDialog = ref(false)
|
||||||
|
const confirmWarnings = ref([])
|
||||||
|
const confirmServiceId = ref(null)
|
||||||
|
const finalizing = ref(false)
|
||||||
|
|
||||||
function formatDate(value) {
|
function showToast(message, type = 'info') {
|
||||||
if (!value) {
|
toastMessage.value = message
|
||||||
return '-'
|
toastType.value = type
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(value).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value) {
|
|
||||||
if (!value) {
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Date(value).toLocaleString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function showComingSoon() {
|
|
||||||
toastMessage.value = 'Demnaechst verfuegbar'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toastMessage.value = ''
|
toastMessage.value = ''
|
||||||
}, 2500)
|
}, 3500)
|
||||||
}
|
}
|
||||||
|
|
||||||
function finalizeService(serviceId) {
|
function toastClasses() {
|
||||||
router.post(route('services.finalize', serviceId), {}, { preserveScroll: true })
|
if (toastType.value === 'success') {
|
||||||
|
return 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||||
|
}
|
||||||
|
if (toastType.value === 'warning') {
|
||||||
|
return 'border-orange-300 bg-orange-50 text-orange-700'
|
||||||
|
}
|
||||||
|
return 'border-blue-300 bg-blue-50 text-blue-700'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeService(serviceId) {
|
||||||
|
finalizing.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(route('services.finalize', serviceId), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ confirmed: false }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (data.needs_confirmation) {
|
||||||
|
confirmWarnings.value = data.warnings
|
||||||
|
confirmServiceId.value = serviceId
|
||||||
|
confirmDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(data.success, 'success')
|
||||||
|
router.reload({ preserveScroll: true })
|
||||||
|
} finally {
|
||||||
|
finalizing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmFinalize() {
|
||||||
|
finalizing.value = true
|
||||||
|
confirmDialog.value = false
|
||||||
|
try {
|
||||||
|
const response = await fetch(route('services.finalize', confirmServiceId.value), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ confirmed: true }),
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
showToast(data.success, 'success')
|
||||||
|
router.reload({ preserveScroll: true })
|
||||||
|
} finally {
|
||||||
|
finalizing.value = false
|
||||||
|
confirmServiceId.value = null
|
||||||
|
confirmWarnings.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelFinalize() {
|
||||||
|
confirmDialog.value = false
|
||||||
|
confirmServiceId.value = null
|
||||||
|
confirmWarnings.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function reopenService(serviceId) {
|
function reopenService(serviceId) {
|
||||||
router.post(route('services.reopen', serviceId), {}, { preserveScroll: true })
|
router.post(route('services.reopen', serviceId), {}, {
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => showToast('Service wurde wieder geöffnet.', 'success'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadService(serviceId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(route('services.download', serviceId), {
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
showToast(data.message, 'warning')
|
||||||
|
} catch {
|
||||||
|
showToast('Fehler beim Herunterladen.', 'warning')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mappingStatusClass(service) {
|
function mappingStatusClass(service) {
|
||||||
|
|
@ -106,7 +168,7 @@ function stateIconClass(isDone) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="toastMessage"
|
v-if="toastMessage"
|
||||||
class="mb-4 rounded-lg border border-orange-300 bg-orange-50 px-4 py-3 text-sm font-medium text-orange-700"
|
:class="toastClasses()"
|
||||||
role="status"
|
role="status"
|
||||||
>
|
>
|
||||||
{{ toastMessage }}
|
{{ toastMessage }}
|
||||||
|
|
@ -198,7 +260,7 @@ function stateIconClass(isDone) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
class="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
@click="showComingSoon"
|
@click="downloadService(service.id)"
|
||||||
>
|
>
|
||||||
Herunterladen
|
Herunterladen
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -215,6 +277,7 @@ function stateIconClass(isDone) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100"
|
class="inline-flex items-center justify-center rounded-md border border-emerald-300 bg-emerald-50 px-3 py-1.5 text-xs font-semibold text-emerald-800 transition hover:bg-emerald-100"
|
||||||
|
:disabled="finalizing"
|
||||||
@click="finalizeService(service.id)"
|
@click="finalizeService(service.id)"
|
||||||
>
|
>
|
||||||
Abschließen
|
Abschließen
|
||||||
|
|
@ -229,5 +292,58 @@ function stateIconClass(isDone) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation Dialog -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="confirmDialog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
@click.self="cancelFinalize"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Service abschließen?</h3>
|
||||||
|
<p class="mt-2 text-sm text-gray-600">
|
||||||
|
Es gibt noch offene Punkte. Möchtest du trotzdem abschließen?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mt-3 space-y-1.5">
|
||||||
|
<li
|
||||||
|
v-for="(warning, idx) in confirmWarnings"
|
||||||
|
:key="idx"
|
||||||
|
class="flex items-start gap-2 text-sm text-amber-700"
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 shrink-0" aria-hidden="true">⚠</span>
|
||||||
|
<span>{{ warning }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="mt-5 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||||
|
@click="cancelFinalize"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-emerald-300 bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-700"
|
||||||
|
@click="confirmFinalize"
|
||||||
|
>
|
||||||
|
Trotzdem abschließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
575
resources/js/Pages/Songs/Index.vue
Normal file
575
resources/js/Pages/Songs/Index.vue
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
<script setup>
|
||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||||
|
import SongEditModal from '@/Components/SongEditModal.vue'
|
||||||
|
import { Head } from '@inertiajs/vue3'
|
||||||
|
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 loading = ref(false)
|
||||||
|
const deleting = ref(null)
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
|
||||||
|
// Upload state
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const uploadError = ref('')
|
||||||
|
const fileInput = ref(null)
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const showEditModal = ref(false)
|
||||||
|
const editSongId = ref(null)
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
async function fetchSongs(page = 1) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page, per_page: 20 })
|
||||||
|
if (search.value.trim()) {
|
||||||
|
params.set('search', search.value.trim())
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/songs?${params}`, {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden')
|
||||||
|
const json = await response.json()
|
||||||
|
songs.value = json.data
|
||||||
|
meta.value = json.meta
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Song fetch error:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => fetchSongs(1), 500)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => fetchSongs())
|
||||||
|
|
||||||
|
function goToPage(page) {
|
||||||
|
if (page < 1 || page > meta.value.last_page) return
|
||||||
|
fetchSongs(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '–'
|
||||||
|
return new Date(value).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value) {
|
||||||
|
if (!value) return '–'
|
||||||
|
return new Date(value).toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
function confirmDelete(song) {
|
||||||
|
deleteTarget.value = song
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSong() {
|
||||||
|
if (!deleteTarget.value) return
|
||||||
|
deleting.value = deleteTarget.value.id
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/songs/${deleteTarget.value.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Löschen')
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
deleteTarget.value = null
|
||||||
|
await fetchSongs(meta.value.current_page)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err)
|
||||||
|
} finally {
|
||||||
|
deleting.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
deleteTarget.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(song) {
|
||||||
|
editSongId.value = song.id
|
||||||
|
showEditModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
showEditModal.value = false
|
||||||
|
editSongId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSongUpdated() {
|
||||||
|
fetchSongs(meta.value.current_page)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload handlers (placeholder — T23 will implement parser)
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
isDragging.value = false
|
||||||
|
uploadError.value = 'ProPresenter-Import (.pro) ist noch nicht verfügbar. Kommt bald!'
|
||||||
|
setTimeout(() => { uploadError.value = '' }, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect() {
|
||||||
|
uploadError.value = 'ProPresenter-Import (.pro) ist noch nicht verfügbar. Kommt bald!'
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
setTimeout(() => { uploadError.value = '' }, 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page range for pagination
|
||||||
|
function pageRange() {
|
||||||
|
const current = meta.value.current_page
|
||||||
|
const last = meta.value.last_page
|
||||||
|
const delta = 2
|
||||||
|
const range = []
|
||||||
|
|
||||||
|
for (let i = Math.max(2, current - delta); i <= Math.min(last - 1, current + delta); i++) {
|
||||||
|
range.push(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current - delta > 2) range.unshift('...')
|
||||||
|
if (current + delta < last - 1) range.push('...')
|
||||||
|
|
||||||
|
range.unshift(1)
|
||||||
|
if (last > 1) range.push(last)
|
||||||
|
|
||||||
|
return range
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Song-Datenbank" />
|
||||||
|
|
||||||
|
<AuthenticatedLayout>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 class="text-xl font-semibold leading-tight text-gray-800">Song-Datenbank</h2>
|
||||||
|
<p class="text-sm text-gray-500">Verwalte alle Songs, Übersetzungen und Arrangements.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
|
||||||
|
<!-- Upload Area -->
|
||||||
|
<div
|
||||||
|
class="group relative mb-6 cursor-pointer overflow-hidden rounded-xl border-2 border-dashed transition-all duration-300"
|
||||||
|
:class="isDragging
|
||||||
|
? 'border-amber-400 bg-amber-50/80 shadow-lg shadow-amber-100/50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-amber-300 hover:bg-amber-50/30'"
|
||||||
|
@dragover="handleDragOver"
|
||||||
|
@dragleave="handleDragLeave"
|
||||||
|
@drop="handleDrop"
|
||||||
|
@click="triggerFileInput"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".pro,.zip"
|
||||||
|
class="hidden"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center justify-center px-6 py-8">
|
||||||
|
<!-- Upload icon -->
|
||||||
|
<div
|
||||||
|
class="mb-3 flex h-12 w-12 items-center justify-center rounded-xl transition-all duration-300"
|
||||||
|
:class="isDragging
|
||||||
|
? 'bg-amber-200/80 text-amber-700 scale-110'
|
||||||
|
: 'bg-gray-100 text-gray-400 group-hover:bg-amber-100 group-hover:text-amber-600'"
|
||||||
|
>
|
||||||
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm font-medium text-gray-700">
|
||||||
|
<span class="text-amber-600 underline underline-offset-2 decoration-amber-300">Dateien auswählen</span>
|
||||||
|
oder hierher ziehen
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">.pro Dateien oder .zip Archive mit .pro Dateien</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload error toast -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-300 ease-out"
|
||||||
|
enter-from-class="translate-y-2 opacity-0"
|
||||||
|
enter-to-class="translate-y-0 opacity-100"
|
||||||
|
leave-active-class="transition duration-200 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="uploadError"
|
||||||
|
class="absolute inset-x-0 bottom-0 bg-orange-50 border-t border-orange-200 px-4 py-2.5 text-center text-sm font-medium text-orange-700"
|
||||||
|
>
|
||||||
|
{{ uploadError }}
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="relative mb-6">
|
||||||
|
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
|
||||||
|
<svg class="h-4.5 w-4.5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Songs durchsuchen (Name oder CCLI-ID)…"
|
||||||
|
class="w-full rounded-xl border border-gray-200 bg-white py-3 pl-11 pr-4 text-sm text-gray-900 shadow-sm transition-all placeholder:text-gray-400 focus:border-amber-300 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
|
||||||
|
/>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="search"
|
||||||
|
@click="search = ''"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center pr-4 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Song Count + Loading -->
|
||||||
|
<div class="mb-3 flex items-center justify-between px-1">
|
||||||
|
<p class="text-xs font-medium text-gray-500">
|
||||||
|
<template v-if="!loading">
|
||||||
|
{{ meta.total }} {{ meta.total === 1 ? 'Song' : 'Songs' }}
|
||||||
|
<span v-if="search" class="text-gray-400">für „{{ search }}"</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="inline-block h-3 w-3 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"></span>
|
||||||
|
Lade Songs…
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div v-if="!loading && songs.length === 0" class="p-12 text-center">
|
||||||
|
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gray-100">
|
||||||
|
<svg class="h-7 w-7 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9l10.5-3m0 6.553v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 11-.99-3.467l2.31-.66a2.25 2.25 0 001.632-2.163zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 01-1.632 2.163l-1.32.377a1.803 1.803 0 01-.99-3.467l2.31-.66A2.25 2.25 0 009 15.553z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium text-gray-600">
|
||||||
|
{{ search ? 'Keine Songs gefunden' : 'Noch keine Songs vorhanden' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
{{ search ? 'Versuch einen anderen Suchbegriff.' : 'Lade .pro Dateien hoch, um Songs hinzuzufügen.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50/80">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Titel</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">CCLI-ID</th>
|
||||||
|
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 md:table-cell">Erstellt</th>
|
||||||
|
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 lg:table-cell">Letzte Änderung</th>
|
||||||
|
<th class="hidden px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 lg:table-cell">Zuletzt verwendet</th>
|
||||||
|
<th class="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider text-gray-500">Übersetzung</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="divide-y divide-gray-100 bg-white">
|
||||||
|
<tr
|
||||||
|
v-for="song in songs"
|
||||||
|
:key="song.id"
|
||||||
|
class="group transition-colors 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>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- CCLI-ID -->
|
||||||
|
<td class="px-4 py-3.5">
|
||||||
|
<span
|
||||||
|
v-if="song.ccli_id"
|
||||||
|
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-xs font-mono font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
{{ song.ccli_id }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-gray-300">–</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Erstellt -->
|
||||||
|
<td class="hidden px-4 py-3.5 text-sm text-gray-500 md:table-cell">
|
||||||
|
{{ formatDate(song.created_at) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Letzte Änderung -->
|
||||||
|
<td class="hidden px-4 py-3.5 text-sm text-gray-500 lg:table-cell">
|
||||||
|
{{ formatDateTime(song.updated_at) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Zuletzt verwendet -->
|
||||||
|
<td class="hidden px-4 py-3.5 text-sm lg:table-cell">
|
||||||
|
<span v-if="song.last_used_in_service" class="text-gray-700">
|
||||||
|
{{ formatDate(song.last_used_in_service) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-gray-300">noch nie</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Übersetzung -->
|
||||||
|
<td class="px-4 py-3.5 text-center">
|
||||||
|
<span
|
||||||
|
v-if="song.has_translation"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700"
|
||||||
|
title="Übersetzung vorhanden"
|
||||||
|
>
|
||||||
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
Ja
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="inline-flex items-center rounded-full bg-gray-50 px-2.5 py-0.5 text-xs font-medium text-gray-400"
|
||||||
|
>
|
||||||
|
Nein
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- Aktionen -->
|
||||||
|
<td class="px-4 py-3.5">
|
||||||
|
<div class="flex items-center justify-end gap-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<!-- Bearbeiten -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700"
|
||||||
|
title="Bearbeiten"
|
||||||
|
@click="openEditModal(song)"
|
||||||
|
>
|
||||||
|
<svg class="mr-1 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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||||
|
</svg>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Übersetzen -->
|
||||||
|
<a
|
||||||
|
:href="`/songs/${song.id}/translate`"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700"
|
||||||
|
title="Übersetzen"
|
||||||
|
>
|
||||||
|
<svg class="mr-1 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="M10.5 21l5.25-11.25L21 21m-9-3h7.5M3 5.621a48.474 48.474 0 016-.371m0 0c1.12 0 2.233.038 3.334.114M9 5.25V3m3.334 2.364C11.176 10.658 7.69 15.08 3 17.502m9.334-12.138c.896.061 1.785.147 2.666.257m-4.589 8.495a18.023 18.023 0 01-3.827-5.802" />
|
||||||
|
</svg>
|
||||||
|
Übersetzen
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Herunterladen -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-emerald-300 hover:bg-emerald-50 hover:text-emerald-700"
|
||||||
|
title="Als .pro herunterladen"
|
||||||
|
@click="$emit('download', song)"
|
||||||
|
>
|
||||||
|
<svg class="mr-1 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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||||
|
</svg>
|
||||||
|
Herunterladen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Löschen -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all hover:border-red-300 hover:bg-red-50 hover:text-red-700"
|
||||||
|
title="Löschen"
|
||||||
|
:disabled="deleting === song.id"
|
||||||
|
@click.stop="confirmDelete(song)"
|
||||||
|
>
|
||||||
|
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div
|
||||||
|
v-if="meta.last_page > 1"
|
||||||
|
class="mt-4 flex items-center justify-between px-1"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Seite {{ meta.current_page }} von {{ meta.last_page }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
:disabled="meta.current_page <= 1"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
@click="goToPage(meta.current_page - 1)"
|
||||||
|
>
|
||||||
|
<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="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-for="page in pageRange()" :key="page">
|
||||||
|
<span
|
||||||
|
v-if="page === '...'"
|
||||||
|
class="flex h-8 w-8 items-center justify-center text-xs text-gray-400"
|
||||||
|
>…</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border text-xs font-medium shadow-sm transition-all"
|
||||||
|
:class="page === meta.current_page
|
||||||
|
? 'border-amber-300 bg-amber-50 text-amber-700'
|
||||||
|
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
:disabled="meta.current_page >= meta.last_page"
|
||||||
|
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-xs text-gray-600 shadow-sm transition-all hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
@click="goToPage(meta.current_page + 1)"
|
||||||
|
>
|
||||||
|
<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="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirm Modal -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showDeleteConfirm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
@click.self="cancelDelete"
|
||||||
|
>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="scale-95 opacity-0"
|
||||||
|
enter-to-class="scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-150 ease-in"
|
||||||
|
leave-from-class="scale-100 opacity-100"
|
||||||
|
leave-to-class="scale-95 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="showDeleteConfirm"
|
||||||
|
class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-red-50">
|
||||||
|
<svg class="h-5.5 w-5.5 text-red-600" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Song löschen?</h3>
|
||||||
|
<p class="mt-1.5 text-sm text-gray-500">
|
||||||
|
„{{ deleteTarget?.title }}" wird gelöscht. Diese Aktion kann rückgängig gemacht werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-end gap-2.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50"
|
||||||
|
@click="cancelDelete"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-red-700 disabled:opacity-60"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="deleteSong"
|
||||||
|
>
|
||||||
|
<span v-if="deleting" class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Song Edit Modal -->
|
||||||
|
<SongEditModal
|
||||||
|
:show="showEditModal"
|
||||||
|
:song-id="editSongId"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@updated="onSongUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
</template>
|
||||||
322
resources/js/Pages/Songs/Translate.vue
Normal file
322
resources/js/Pages/Songs/Translate.vue
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
<script setup>
|
||||||
|
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||||
|
import { Head, router } from '@inertiajs/vue3'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
song: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceUrl = ref('')
|
||||||
|
const sourceText = ref('')
|
||||||
|
const isFetching = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const infoMessage = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
const groups = ref(
|
||||||
|
(props.song.groups ?? []).map((group) => ({
|
||||||
|
...group,
|
||||||
|
slides: (group.slides ?? []).map((slide) => {
|
||||||
|
const lineCount = getLineCount(slide.text_content)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...slide,
|
||||||
|
line_count: lineCount,
|
||||||
|
translated_text: normalizeToLineCount(slide.text_content_translated ?? '', lineCount),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasExistingTranslation = computed(() =>
|
||||||
|
groups.value.some((group) =>
|
||||||
|
group.slides.some((slide) => (slide.text_content_translated ?? '').trim().length > 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const editorVisible = computed(() => sourceText.value.trim().length > 0 || hasExistingTranslation.value)
|
||||||
|
|
||||||
|
function getLineCount(text) {
|
||||||
|
return normalizeNewlines(text).split('\n').length
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNewlines(text) {
|
||||||
|
return (text ?? '').replace(/\r\n/g, '\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToLineCount(text, lineCount) {
|
||||||
|
const maxLines = Math.max(1, lineCount)
|
||||||
|
const lines = normalizeNewlines(text).split('\n').slice(0, maxLines)
|
||||||
|
|
||||||
|
while (lines.length < maxLines) {
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderedSlides() {
|
||||||
|
return groups.value
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.flatMap((group) =>
|
||||||
|
group.slides
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyManualText() {
|
||||||
|
if (sourceText.value.trim().length === 0) {
|
||||||
|
errorMessage.value = 'Bitte fuege zuerst einen Text ein.'
|
||||||
|
infoMessage.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
distributeTextToSlides(sourceText.value)
|
||||||
|
errorMessage.value = ''
|
||||||
|
infoMessage.value = 'Text wurde auf die Folien verteilt.'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTextFromUrl() {
|
||||||
|
if (sourceUrl.value.trim().length === 0) {
|
||||||
|
errorMessage.value = 'Bitte gib eine gueltige URL ein.'
|
||||||
|
infoMessage.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isFetching.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
infoMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.axios.post('/api/translation/fetch-url', {
|
||||||
|
url: sourceUrl.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
sourceText.value = response.data?.text ?? ''
|
||||||
|
distributeTextToSlides(sourceText.value)
|
||||||
|
infoMessage.value = 'Text wurde erfolgreich abgerufen und verteilt.'
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Text konnte nicht von der URL abgerufen werden.'
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function distributeTextToSlides(text) {
|
||||||
|
const translatedLines = normalizeNewlines(text).split('\n')
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
orderedSlides().forEach((slide) => {
|
||||||
|
const chunk = translatedLines.slice(offset, offset + slide.line_count)
|
||||||
|
offset += slide.line_count
|
||||||
|
slide.translated_text = normalizeToLineCount(chunk.join('\n'), slide.line_count)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTranslationInput(slide, value) {
|
||||||
|
slide.translated_text = normalizeToLineCount(value, slide.line_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTranslation() {
|
||||||
|
isSaving.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
infoMessage.value = ''
|
||||||
|
|
||||||
|
const text = orderedSlides()
|
||||||
|
.map((slide) => normalizeToLineCount(slide.translated_text, slide.line_count))
|
||||||
|
.join('\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await window.axios.post(`/api/songs/${props.song.id}/translation/import`, { text })
|
||||||
|
router.visit('/songs?success=Uebersetzung+gespeichert')
|
||||||
|
} catch {
|
||||||
|
errorMessage.value = 'Uebersetzung konnte nicht gespeichert werden.'
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsForSlide(slide) {
|
||||||
|
return Math.max(3, slide.line_count)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head :title="`Uebersetzung: ${song.title}`" />
|
||||||
|
|
||||||
|
<AuthenticatedLayout>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold leading-tight text-gray-900">Song uebersetzen</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{{ song.title }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
||||||
|
@click="router.visit('/songs')"
|
||||||
|
>
|
||||||
|
Zurueck
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="py-8">
|
||||||
|
<div class="mx-auto max-w-6xl space-y-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<section class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Uebersetzungstext laden</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Du kannst einen Text von einer URL abrufen oder manuell einfuegen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-[1fr,auto]">
|
||||||
|
<input
|
||||||
|
v-model="sourceUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://beispiel.de/lyrics"
|
||||||
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="isFetching"
|
||||||
|
@click="fetchTextFromUrl"
|
||||||
|
>
|
||||||
|
{{ isFetching ? 'Abrufen...' : 'Text abrufen' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Text manuell einfuegen
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="sourceText"
|
||||||
|
rows="10"
|
||||||
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
||||||
|
placeholder="Fuege hier den kompletten Uebersetzungstext ein..."
|
||||||
|
/>
|
||||||
|
<div class="mt-3 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
||||||
|
@click="applyManualText"
|
||||||
|
>
|
||||||
|
Text auf Folien verteilen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm font-medium text-red-700"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="infoMessage"
|
||||||
|
class="rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ infoMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-if="editorVisible"
|
||||||
|
class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-semibold text-gray-900">Folien-Editor</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Links siehst du den Originaltext, rechts bearbeitest du die Uebersetzung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center rounded-md bg-emerald-600 px-4 py-2 text-sm font-semibold text-white shadow transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="saveTranslation"
|
||||||
|
>
|
||||||
|
{{ isSaving ? 'Speichern...' : 'Speichern' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
class="overflow-hidden rounded-lg border border-gray-200"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-4 py-2"
|
||||||
|
:style="{ backgroundColor: group.color || '#334155' }"
|
||||||
|
>
|
||||||
|
<h4 class="text-sm font-semibold text-white">
|
||||||
|
{{ group.name }}
|
||||||
|
</h4>
|
||||||
|
<span class="text-xs font-medium text-white/90">
|
||||||
|
{{ group.slides.length }} Folien
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4 bg-gray-50 p-4">
|
||||||
|
<div
|
||||||
|
v-for="slide in group.slides"
|
||||||
|
:key="slide.id"
|
||||||
|
class="rounded-lg border border-gray-200 bg-white p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<p class="text-sm font-semibold text-gray-800">
|
||||||
|
Folie {{ slide.order }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs font-medium text-gray-500">
|
||||||
|
Zeilen: {{ slide.line_count }}/{{ slide.line_count }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Original
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
:value="slide.text_content"
|
||||||
|
:rows="rowsForSlide(slide)"
|
||||||
|
readonly
|
||||||
|
class="block w-full rounded-md border-gray-300 bg-gray-100 text-sm text-gray-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-2 block text-xs font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
Uebersetzung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
:value="slide.translated_text"
|
||||||
|
:rows="rowsForSlide(slide)"
|
||||||
|
class="block w-full rounded-md border-gray-300 text-sm shadow-sm focus:border-emerald-500 focus:ring-emerald-500"
|
||||||
|
@input="onTranslationInput(slide, $event.target.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
</template>
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\ProFileController;
|
||||||
use App\Http\Controllers\ServiceSongController;
|
use App\Http\Controllers\ServiceSongController;
|
||||||
use App\Http\Controllers\SongController;
|
use App\Http\Controllers\SongController;
|
||||||
use App\Http\Controllers\TranslationController;
|
use App\Http\Controllers\TranslationController;
|
||||||
|
|
@ -39,4 +40,11 @@
|
||||||
|
|
||||||
Route::delete('/songs/{song}/translation', [TranslationController::class, 'destroy'])
|
Route::delete('/songs/{song}/translation', [TranslationController::class, 'destroy'])
|
||||||
->name('api.songs.translation.destroy');
|
->name('api.songs.translation.destroy');
|
||||||
|
|
||||||
|
// .pro Datei Upload und Download (Placeholder)
|
||||||
|
Route::post('/songs/import-pro', [ProFileController::class, 'importPro'])
|
||||||
|
->name('api.songs.import-pro');
|
||||||
|
|
||||||
|
Route::get('/songs/{song}/download-pro', [ProFileController::class, 'downloadPro'])
|
||||||
|
->name('api.songs.download-pro');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
use App\Http\Controllers\ServiceController;
|
use App\Http\Controllers\ServiceController;
|
||||||
use App\Http\Controllers\SlideController;
|
use App\Http\Controllers\SlideController;
|
||||||
use App\Http\Controllers\SyncController;
|
use App\Http\Controllers\SyncController;
|
||||||
|
use App\Http\Controllers\TranslationController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
|
@ -31,7 +32,13 @@
|
||||||
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
||||||
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
||||||
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
||||||
|
Route::get('/services/{service}/download', [ServiceController::class, 'download'])->name('services.download');
|
||||||
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
Route::get('/services/{service}/edit', [ServiceController::class, 'edit'])->name('services.edit');
|
||||||
|
Route::get('/songs/{song}/translate', [TranslationController::class, 'page'])->name('songs.translate');
|
||||||
|
|
||||||
|
Route::get('/songs', function () {
|
||||||
|
return Inertia::render('Songs/Index');
|
||||||
|
})->name('songs.index');
|
||||||
|
|
||||||
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
Route::post('/songs/{song}/arrangements', '\\App\\Http\\Controllers\\ArrangementController@store')->name('arrangements.store');
|
||||||
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
Route::post('/arrangements/{arrangement}/clone', '\\App\\Http\\Controllers\\ArrangementController@clone')->name('arrangements.clone');
|
||||||
|
|
|
||||||
244
tests/Feature/FinalizationTest.php
Normal file
244
tests/Feature/FinalizationTest.php
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\ServiceSong;
|
||||||
|
use App\Models\Slide;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongArrangement;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Finalization Tests
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize ohne voraussetzungen gibt warnungen zurueck', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
// Song without match or arrangement
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => null,
|
||||||
|
'song_arrangement_id' => null,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Test Song',
|
||||||
|
'cts_ccli_id' => '12345',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->postJson(route('services.finalize', $service), ['confirmed' => false]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'needs_confirmation' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
expect($data['warnings'])->toHaveCount(3)
|
||||||
|
->and($data['warnings'][0])->toContain('Songs sind zugeordnet')
|
||||||
|
->and($data['warnings'][1])->toContain('Arrangement')
|
||||||
|
->and($data['warnings'][2])->toContain('Predigtfolien');
|
||||||
|
|
||||||
|
// Not finalized yet
|
||||||
|
expect($service->fresh()->finalized_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize mit confirmed=true trotz warnungen finalisiert service', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => null,
|
||||||
|
'song_arrangement_id' => null,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Test Song',
|
||||||
|
'cts_ccli_id' => '12345',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->postJson(route('services.finalize', $service), ['confirmed' => true]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'needs_confirmation' => false,
|
||||||
|
'success' => 'Service wurde abgeschlossen.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($service->fresh()->finalized_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize ohne warnungen finalisiert direkt', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Test Song',
|
||||||
|
'cts_ccli_id' => '12345',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->postJson(route('services.finalize', $service), ['confirmed' => false]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'needs_confirmation' => false,
|
||||||
|
'success' => 'Service wurde abgeschlossen.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($service->fresh()->finalized_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize warnt bei fehlenden song-zuordnungen', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
|
||||||
|
|
||||||
|
// One matched, one unmatched
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Matched',
|
||||||
|
'cts_ccli_id' => '111',
|
||||||
|
]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => null,
|
||||||
|
'song_arrangement_id' => null,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 2,
|
||||||
|
'cts_song_name' => 'Unmatched',
|
||||||
|
'cts_ccli_id' => '222',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->postJson(route('services.finalize', $service), ['confirmed' => false]);
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
expect($data['needs_confirmation'])->toBeTrue()
|
||||||
|
->and($data['warnings'])->toContain('Nur 1 von 2 Songs sind zugeordnet.')
|
||||||
|
->and($data['warnings'])->toContain('Nur 1 von 2 Songs haben ein Arrangement.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize warnt bei fehlenden predigtfolien', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
|
||||||
|
|
||||||
|
ServiceSong::create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'use_translation' => false,
|
||||||
|
'order' => 1,
|
||||||
|
'cts_song_name' => 'Song',
|
||||||
|
'cts_ccli_id' => '111',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No sermon slides
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->postJson(route('services.finalize', $service), ['confirmed' => false]);
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
expect($data['needs_confirmation'])->toBeTrue()
|
||||||
|
->and($data['warnings'])->toContain('Es wurden keine Predigtfolien hochgeladen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reopen setzt finalized_at zurueck', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now()]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->post(route('services.reopen', $service));
|
||||||
|
|
||||||
|
$response->assertRedirect(route('services.index'));
|
||||||
|
expect($service->fresh()->finalized_at)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download gibt placeholder nachricht zurueck', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => now()]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson(route('services.download', $service));
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'message' => 'Die Show-Erstellung wird in einem zukünftigen Update verfügbar sein.',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalize erfordert authentifizierung', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->postJson(route('services.finalize', $service));
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download erfordert authentifizierung', function () {
|
||||||
|
$service = Service::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->getJson(route('services.download', $service));
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service model isReadyToFinalize accessor', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
// No songs, no sermon slides → only sermon warning
|
||||||
|
expect($service->is_ready_to_finalize)->toBeFalse();
|
||||||
|
|
||||||
|
Slide::factory()->create([
|
||||||
|
'service_id' => $service->id,
|
||||||
|
'type' => 'sermon',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Refresh to clear cached relations
|
||||||
|
$service->refresh();
|
||||||
|
|
||||||
|
// No songs, has sermon slides → ready (0/0 songs is not a warning)
|
||||||
|
expect($service->is_ready_to_finalize)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalization status mit service ohne songs warnt nur bei predigtfolien', function () {
|
||||||
|
$service = Service::factory()->create(['finalized_at' => null]);
|
||||||
|
|
||||||
|
$status = $service->finalizationStatus();
|
||||||
|
|
||||||
|
// No songs: no song warnings. Only sermon slides warning.
|
||||||
|
expect($status['ready'])->toBeFalse()
|
||||||
|
->and($status['warnings'])->toHaveCount(1)
|
||||||
|
->and($status['warnings'][0])->toContain('Predigtfolien');
|
||||||
|
});
|
||||||
63
tests/Feature/ProPlaceholderTest.php
Normal file
63
tests/Feature/ProPlaceholderTest.php
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
describe('Pro File Placeholder Endpoints', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/songs/import-pro', function () {
|
||||||
|
it('returns 501 Not Implemented with German error message', function () {
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->post('/api/songs/import-pro', [
|
||||||
|
'file' => 'test.pro',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(501);
|
||||||
|
$response->assertJson([
|
||||||
|
'message' => 'Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation.',
|
||||||
|
'error' => 'ProParserNotImplemented',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', function () {
|
||||||
|
$response = $this->postJson('/api/songs/import-pro', [
|
||||||
|
'file' => 'test.pro',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/songs/{song}/download-pro', function () {
|
||||||
|
it('returns 501 Not Implemented with German error message', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get("/api/songs/{$song->id}/download-pro");
|
||||||
|
|
||||||
|
$response->assertStatus(501);
|
||||||
|
$response->assertJson([
|
||||||
|
'message' => 'Der .pro-Parser wird später implementiert. Bitte warte auf die detaillierte Spezifikation.',
|
||||||
|
'error' => 'ProParserNotImplemented',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires authentication', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->getJson("/api/songs/{$song->id}/download-pro");
|
||||||
|
|
||||||
|
$response->assertStatus(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for non-existent song', function () {
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get('/api/songs/99999/download-pro');
|
||||||
|
|
||||||
|
$response->assertStatus(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -158,9 +158,11 @@ public function test_service_kann_abgeschlossen_werden(): void
|
||||||
'finalized_at' => null,
|
'finalized_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$response = $this->actingAs($user)->post(route('services.finalize', $service));
|
// Finalize with confirmed=true to skip prerequisite check
|
||||||
|
$response = $this->actingAs($user)->postJson(route('services.finalize', $service), ['confirmed' => true]);
|
||||||
|
|
||||||
$response->assertRedirect(route('services.index'));
|
$response->assertOk();
|
||||||
|
$response->assertJson(['needs_confirmation' => false, 'success' => 'Service wurde abgeschlossen.']);
|
||||||
$this->assertSame(now()->toDateTimeString(), $service->fresh()->finalized_at?->toDateTimeString());
|
$this->assertSame(now()->toDateTimeString(), $service->fresh()->finalized_at?->toDateTimeString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
227
tests/Feature/SongEditModalTest.php
Normal file
227
tests/Feature/SongEditModalTest.php
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongArrangement;
|
||||||
|
use App\Models\SongArrangementGroup;
|
||||||
|
use App\Models\SongGroup;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Song Edit Modal Tests
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Tests verifying the API endpoints used by SongEditModal.vue:
|
||||||
|
| - GET /api/songs/{id} (show with groups + arrangements)
|
||||||
|
| - PUT /api/songs/{id} (auto-save metadata)
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Modal Data Loading ---
|
||||||
|
|
||||||
|
test('show returns song with full detail for modal', function () {
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Großer Gott wir loben Dich',
|
||||||
|
'ccli_id' => '100200',
|
||||||
|
'copyright_text' => '© Public Domain',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$group1 = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Strophe 1',
|
||||||
|
'color' => '#3B82F6',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$group2 = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Refrain',
|
||||||
|
'color' => '#10B981',
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Normal',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_group_id' => $group1->id,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_group_id' => $group2->id,
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson("/api/songs/{$song->id}");
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonPath('data.title', 'Großer Gott wir loben Dich')
|
||||||
|
->assertJsonPath('data.ccli_id', '100200')
|
||||||
|
->assertJsonPath('data.copyright_text', '© Public Domain')
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'id', 'title', 'ccli_id', 'copyright_text',
|
||||||
|
'groups' => [['id', 'name', 'color', 'order', 'slides']],
|
||||||
|
'arrangements' => [['id', 'name', 'is_default', 'arrangement_groups']],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Groups in correct order
|
||||||
|
expect($response->json('data.groups.0.name'))->toBe('Strophe 1');
|
||||||
|
expect($response->json('data.groups.1.name'))->toBe('Refrain');
|
||||||
|
|
||||||
|
// Arrangement with arrangement_groups
|
||||||
|
expect($response->json('data.arrangements.0.name'))->toBe('Normal');
|
||||||
|
expect($response->json('data.arrangements.0.arrangement_groups'))->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Metadata Auto-Save ---
|
||||||
|
|
||||||
|
test('update saves title via auto-save', function () {
|
||||||
|
$song = Song::factory()->create(['title' => 'Original Title']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => 'Neuer Titel',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonFragment(['message' => 'Song erfolgreich aktualisiert']);
|
||||||
|
|
||||||
|
$song->refresh();
|
||||||
|
expect($song->title)->toBe('Neuer Titel');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update saves ccli_id via auto-save', function () {
|
||||||
|
$song = Song::factory()->create(['ccli_id' => '111111']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => $song->title,
|
||||||
|
'ccli_id' => '999888',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$song->refresh();
|
||||||
|
expect($song->ccli_id)->toBe('999888');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update saves copyright_text via auto-save', function () {
|
||||||
|
$song = Song::factory()->create(['copyright_text' => null]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => $song->title,
|
||||||
|
'copyright_text' => '© 2024 Neuer Copyright-Text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$song->refresh();
|
||||||
|
expect($song->copyright_text)->toBe('© 2024 Neuer Copyright-Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update can clear optional fields with null', function () {
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'ccli_id' => '555555',
|
||||||
|
'copyright_text' => 'Some copyright',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => $song->title,
|
||||||
|
'ccli_id' => null,
|
||||||
|
'copyright_text' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$song->refresh();
|
||||||
|
expect($song->ccli_id)->toBeNull();
|
||||||
|
expect($song->copyright_text)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update returns full song detail with arrangements', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
SongGroup::factory()->create(['song_id' => $song->id]);
|
||||||
|
SongArrangement::factory()->create(['song_id' => $song->id, 'is_default' => true]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => 'Updated Song',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [
|
||||||
|
'id', 'title', 'ccli_id', 'copyright_text',
|
||||||
|
'groups', 'arrangements',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update validates title is required', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update validates unique ccli_id against other songs', function () {
|
||||||
|
Song::factory()->create(['ccli_id' => '777777']);
|
||||||
|
$song = Song::factory()->create(['ccli_id' => '888888']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => $song->title,
|
||||||
|
'ccli_id' => '777777',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['ccli_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update requires authentication', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
|
||||||
|
$response = $this->putJson("/api/songs/{$song->id}", [
|
||||||
|
'title' => 'Should Fail',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('show returns 404 for soft-deleted song', function () {
|
||||||
|
$song = Song::factory()->create(['deleted_at' => now()]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson("/api/songs/{$song->id}");
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update returns 404 for nonexistent song', function () {
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->putJson('/api/songs/99999', [
|
||||||
|
'title' => 'Ghost Song',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
110
tests/Feature/SongIndexTest.php
Normal file
110
tests/Feature/SongIndexTest.php
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Song Index Page Tests (Inertia)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->withoutVite();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs index page renders for authenticated users', function () {
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->get('/songs');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertInertia(fn ($page) => $page->component('Songs/Index'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs index page redirects unauthenticated users to login', function () {
|
||||||
|
$response = $this->get('/songs');
|
||||||
|
|
||||||
|
$response->assertRedirect('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs index route is named songs.index', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
$url = route('songs.index');
|
||||||
|
|
||||||
|
expect($url)->toContain('/songs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api returns data for songs page', function () {
|
||||||
|
Song::factory()->count(3)->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson('/api/songs');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'data' => [['id', 'title', 'ccli_id', 'has_translation', 'created_at', 'updated_at']],
|
||||||
|
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||||
|
]);
|
||||||
|
expect($response->json('meta.total'))->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api search filters by title', function () {
|
||||||
|
Song::factory()->create(['title' => 'Großer Gott wir loben dich']);
|
||||||
|
Song::factory()->create(['title' => 'Amazing Grace']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson('/api/songs?search=Großer');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->json('meta.total'))->toBe(1);
|
||||||
|
expect($response->json('data.0.title'))->toBe('Großer Gott wir loben dich');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api search filters by ccli id', function () {
|
||||||
|
Song::factory()->create(['ccli_id' => '7654321', 'title' => 'Song A']);
|
||||||
|
Song::factory()->create(['ccli_id' => '1234567', 'title' => 'Song B']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson('/api/songs?search=7654321');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->json('meta.total'))->toBe(1);
|
||||||
|
expect($response->json('data.0.ccli_id'))->toBe('7654321');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api does not return soft-deleted songs', function () {
|
||||||
|
Song::factory()->count(2)->create();
|
||||||
|
Song::factory()->create(['deleted_at' => now()]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson('/api/songs');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->json('meta.total'))->toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api paginates results', function () {
|
||||||
|
Song::factory()->count(25)->create();
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->getJson('/api/songs?per_page=10');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect($response->json('meta.total'))->toBe(25);
|
||||||
|
expect($response->json('meta.per_page'))->toBe(10);
|
||||||
|
expect($response->json('meta.last_page'))->toBe(3);
|
||||||
|
expect(count($response->json('data')))->toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('songs api delete soft-deletes a song', function () {
|
||||||
|
$song = Song::factory()->create(['title' => 'To Delete']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)
|
||||||
|
->deleteJson("/api/songs/{$song->id}");
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
expect(Song::find($song->id))->toBeNull();
|
||||||
|
expect(Song::withTrashed()->find($song->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
@ -253,3 +253,127 @@
|
||||||
$response->assertHeader('Content-Type', 'application/pdf');
|
$response->assertHeader('Content-Type', 'application/pdf');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('song preview returns json with groups in arrangement order', function () {
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Testlied',
|
||||||
|
'copyright_text' => 'Test Copyright',
|
||||||
|
'ccli_id' => '123456',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verse = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Strophe 1',
|
||||||
|
'color' => '#3b82f6',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$chorus = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Refrain',
|
||||||
|
'color' => '#ef4444',
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $verse->id,
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => 'Strophe Text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $chorus->id,
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => 'Refrain Text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Normal',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Order: Chorus first, then Verse
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_group_id' => $chorus->id,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_group_id' => $verse->id,
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->getJson(route('songs.preview', [$song, $arrangement]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJsonStructure([
|
||||||
|
'song' => ['id', 'title', 'copyright_text', 'ccli_id'],
|
||||||
|
'arrangement' => ['id', 'name'],
|
||||||
|
'groups' => [
|
||||||
|
'*' => [
|
||||||
|
'name',
|
||||||
|
'color',
|
||||||
|
'slides' => [
|
||||||
|
'*' => ['text_content', 'text_content_translated'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Chorus should be first (order=1), Verse second (order=2)
|
||||||
|
$data = $response->json();
|
||||||
|
expect($data['groups'][0]['name'])->toBe('Refrain');
|
||||||
|
expect($data['groups'][1]['name'])->toBe('Strophe 1');
|
||||||
|
expect($data['groups'][0]['slides'][0]['text_content'])->toBe('Refrain Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song preview includes translation text when slides have translations', function () {
|
||||||
|
$song = Song::factory()->create(['title' => 'Lied mit Übersetzung']);
|
||||||
|
|
||||||
|
$group = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Verse',
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $group->id,
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => 'Original Text',
|
||||||
|
'text_content_translated' => 'Translated Text',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$arrangement = SongArrangement::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongArrangementGroup::factory()->create([
|
||||||
|
'song_arrangement_id' => $arrangement->id,
|
||||||
|
'song_group_id' => $group->id,
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->getJson(route('songs.preview', [$song, $arrangement]));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$data = $response->json();
|
||||||
|
expect($data['groups'][0]['slides'][0]['text_content'])->toBe('Original Text');
|
||||||
|
expect($data['groups'][0]['slides'][0]['text_content_translated'])->toBe('Translated Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song preview returns 404 when arrangement does not belong to song', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$otherSong = Song::factory()->create();
|
||||||
|
$arrangement = SongArrangement::factory()->create(['song_id' => $otherSong->id]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->user)->getJson(route('songs.preview', [$song, $arrangement]));
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('song preview requires authentication', function () {
|
||||||
|
$song = Song::factory()->create();
|
||||||
|
$arrangement = SongArrangement::factory()->create(['song_id' => $song->id]);
|
||||||
|
|
||||||
|
$response = $this->getJson(route('songs.preview', [$song, $arrangement]));
|
||||||
|
|
||||||
|
$response->assertUnauthorized();
|
||||||
|
});
|
||||||
|
|
|
||||||
83
tests/Feature/TranslatePageTest.php
Normal file
83
tests/Feature/TranslatePageTest.php
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Controllers\TranslationController;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\SongGroup;
|
||||||
|
use App\Models\SongSlide;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class TranslatePageTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_translate_page_response_contains_ordered_groups_and_slides(): void
|
||||||
|
{
|
||||||
|
$song = Song::factory()->create([
|
||||||
|
'title' => 'Grosser Gott',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$groupLater = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Refrain',
|
||||||
|
'color' => '#22c55e',
|
||||||
|
'order' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$groupFirst = SongGroup::factory()->create([
|
||||||
|
'song_id' => $song->id,
|
||||||
|
'name' => 'Strophe 1',
|
||||||
|
'color' => '#0ea5e9',
|
||||||
|
'order' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $groupFirst->id,
|
||||||
|
'order' => 2,
|
||||||
|
'text_content' => "Zeile A\nZeile B",
|
||||||
|
'text_content_translated' => "Line A\nLine B",
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $groupFirst->id,
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => "Zeile C\nZeile D\nZeile E",
|
||||||
|
'text_content_translated' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
SongSlide::factory()->create([
|
||||||
|
'song_group_id' => $groupLater->id,
|
||||||
|
'order' => 1,
|
||||||
|
'text_content' => 'Refrain',
|
||||||
|
'text_content_translated' => 'Chorus',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$controller = app(TranslationController::class);
|
||||||
|
$inertiaResponse = $controller->page($song);
|
||||||
|
|
||||||
|
$request = Request::create('/songs/'.$song->id.'/translate', 'GET');
|
||||||
|
$request->headers->set('X-Inertia', 'true');
|
||||||
|
|
||||||
|
$response = $inertiaResponse->toResponse($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(JsonResponse::class, $response);
|
||||||
|
|
||||||
|
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
$this->assertSame('Songs/Translate', $payload['component']);
|
||||||
|
$this->assertSame($song->id, $payload['props']['song']['id']);
|
||||||
|
$this->assertSame('Grosser Gott', $payload['props']['song']['title']);
|
||||||
|
$this->assertCount(2, $payload['props']['song']['groups']);
|
||||||
|
$this->assertSame('Strophe 1', $payload['props']['song']['groups'][0]['name']);
|
||||||
|
$this->assertSame(1, $payload['props']['song']['groups'][0]['slides'][0]['order']);
|
||||||
|
$this->assertSame("Zeile C\nZeile D\nZeile E", $payload['props']['song']['groups'][0]['slides'][0]['text_content']);
|
||||||
|
$this->assertSame(2, $payload['props']['song']['groups'][0]['slides'][1]['order']);
|
||||||
|
$this->assertSame("Zeile A\nZeile B", $payload['props']['song']['groups'][0]['slides'][1]['text_content']);
|
||||||
|
$this->assertSame('Refrain', $payload['props']['song']['groups'][1]['name']);
|
||||||
|
$this->assertSame('Chorus', $payload['props']['song']['groups'][1]['slides'][0]['text_content_translated']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue