feat: Wave 2 - Service list, Song CRUD, Slide upload, Arrangements, Song matching, Translation
T8: Service List Page - ServiceController with index, finalize, reopen actions - Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded) - German UI with finalize/reopen toggle buttons - Status aggregation via SQL subqueries for efficiency - Tests: 3 passing (46 assertions) T9: Song CRUD Backend - SongController with full REST API (index, store, show, update, destroy) - SongService for default groups/arrangements creation - SongRequest validation (title required, ccli_id unique) - Search by title and CCLI ID - last_used_in_service accessor via service_songs join - Tests: 20 passing (85 assertions) T10: Slide Upload Component - SlideController with store, destroy, updateExpireDate - SlideUploader.vue with vue3-dropzone drag-and-drop - SlideGrid.vue with thumbnail grid and inline expire date editing - Multi-format support: images (sync), PPT (async job), ZIP (extract) - Type validation: information (global), moderation/sermon (service-specific) - Tests: 15 passing (37 assertions) T11: Arrangement Configurator - ArrangementController with store, clone, update, destroy - ArrangementConfigurator.vue with vue-draggable-plus - Drag-and-drop arrangement editor with colored group pills - Clone from default or existing arrangement - Color picker for group customization - Prevent deletion of last arrangement - Tests: 4 passing (17 assertions) T12: Song Matching Service - SongMatchingService with autoMatch, manualAssign, requestCreation, unassign - ServiceSongController API endpoints for song assignment - Auto-match by CCLI ID during CTS sync - Manual assignment with searchable song select - Email request for missing songs (MissingSongRequest mailable) - Tests: 14 passing (33 assertions) T13: Translation Service - TranslationService with fetchFromUrl, importTranslation, removeTranslation - TranslationController API endpoints - URL scraping (best-effort HTTP fetch with strip_tags) - Line-count distribution algorithm (match original slide line counts) - Mark song as translated, remove translation - Tests: 18 passing (18 assertions) All tests passing: 103/103 (488 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form
This commit is contained in:
parent
57d54ec06b
commit
d915f8cfc2
|
|
@ -1,3 +1,16 @@
|
|||
- 2026-03-01: Fuer 1920x1080 Slide-Output ohne Upscaling funktioniert in Intervention Image v3 die Kombination aus schwarzer Canvas (`create()->fill('000000')`), `scaleDown(width: 1920, height: 1080)` und zentriertem `place(...)` stabil.
|
||||
- 2026-03-01: Bei Fake-Storage in Tests muessen Zielordner vor direktem Intervention-`save()` explizit erstellt werden (`makeDirectory`/`mkdir`), sonst wirft Intervention `NotWritableException`.
|
||||
- 2026-03-01: Fuer Testverifikation von Letterbox/Pillarbox sind farbige PNG-Testbilder sinnvoller als `UploadedFile::fake()->image(...)`, weil Fake-Bilder sonst komplett schwarz sein koennen.
|
||||
- 2026-03-01: CTS-Sync laeuft stabil mit `EventRequest::where("from", heute)` + `EventAgendaRequest::fromEvent(...)->get()`, wenn Services per `cts_event_id` und Agenda-Songs per (`service_id`,`order`) upserted werden; CCLI-Matching bleibt strikt auf `songs.ccli_id` und setzt nur dann `song_id`/`matched_at`.
|
||||
- 2026-03-01: SongController CRUD nutzt `auth:sanctum` Middleware; `actingAs()` in Tests funktioniert damit problemlos (Sanctum unterstuetzt Session-Auth in Tests).
|
||||
- 2026-03-01: SQLite gibt `date`-Spalten als `YYYY-MM-DD 00:00:00` zurueck statt `YYYY-MM-DD` — Accessor muss `substr($date, 0, 10)` nutzen fuer saubere Date-Only Werte.
|
||||
- 2026-03-01: `Attribute::get()` in Laravel 12 fuer berechnete Accessors statt altem `get{Name}Attribute()` Pattern. Snake_case `last_used_in_service` mapped automatisch auf `lastUsedInService()` Methode.
|
||||
- 2026-03-01: Default-Gruppen (Strophe 1=#3B82F6, Refrain=#10B981, Bridge=#F59E0B) und Default-Arrangement 'Normal' werden automatisch bei Song-Erstellung via SongService erzeugt.
|
||||
- 2026-03-01: `Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at')` stellt sicher, dass Soft-Deleted Songs die Unique-Constraint nicht blockieren.
|
||||
- 2026-03-01: `bootstrap/app.php` braucht explizit `api: __DIR__.'/../routes/api.php'` in `withRouting()` — ist nicht automatisch registriert in Laravel 12.
|
||||
- 2026-03-01: Service-Listenstatus laesst sich performant in einem Query aggregieren via `withCount(...)` fuer Song-Metriken plus `addSelect`-Subqueries fuer `has_sermon_slides` und datumsabhaengige `info_slides_count` (inkl. globaler `information`-Slides mit `service_id = null`).
|
||||
- 2026-03-01: TranslationService line-count distribution: iterate groups (by order) → slides (by order), for each slide count lines in `text_content`, then slice that many lines from the translated text array. `array_slice` + offset tracking works cleanly.
|
||||
- 2026-03-01: URL scraping is best-effort only: `Http::timeout(10)->get($url)` + `strip_tags()` + `trim()`. Return null on any failure — no exceptions bubble up. PHP 8.1+ allows `catch (\Exception)` without variable capture.
|
||||
- 2026-03-01: Translation routes: `POST /api/translation/fetch-url` (preview), `POST /api/songs/{song}/translation/import` (save), `DELETE /api/songs/{song}/translation` (remove). All under `auth:sanctum` middleware.
|
||||
- 2026-03-01: `removeTranslation` uses a two-step approach: collect slide IDs via `SongSlide::whereIn('song_group_id', $song->groups()->pluck('id'))` then bulk-update `text_content_translated = null`, avoiding N+1 queries.
|
||||
- 2026-03-01: Der Arrangement-Konfigurator bleibt stabil bei mehrfachen Gruppeninstanzen, wenn die Sequenz mit Vue-Keys im Muster `${group.id}-${index}` gerendert und die Reihenfolge nach jedem Drag-End sofort per `router.put(..., { preserveScroll: true })` gespeichert wird.
|
||||
|
|
|
|||
164
app/Http/Controllers/ArrangementController.php
Normal file
164
app/Http/Controllers/ArrangementController.php
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ArrangementController extends Controller
|
||||
{
|
||||
public function store(Request $request, Song $song): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($song, $data): void {
|
||||
$arrangement = $song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$source = $song->arrangements()
|
||||
->where('is_default', true)
|
||||
->with('arrangementGroups')
|
||||
->first();
|
||||
|
||||
if ($source === null) {
|
||||
$source = $song->arrangements()
|
||||
->with('arrangementGroups')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
$this->cloneGroups($source, $arrangement);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde hinzugefügt.');
|
||||
}
|
||||
|
||||
public function clone(Request $request, SongArrangement $arrangement): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($arrangement, $data): void {
|
||||
$arrangement->loadMissing('arrangementGroups');
|
||||
|
||||
$clone = $arrangement->song->arrangements()->create([
|
||||
'name' => $data['name'],
|
||||
'is_default' => false,
|
||||
]);
|
||||
|
||||
$this->cloneGroups($arrangement, $clone);
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde geklont.');
|
||||
}
|
||||
|
||||
public function update(Request $request, SongArrangement $arrangement): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'groups' => ['array'],
|
||||
'groups.*.song_group_id' => ['required', 'integer', 'exists:song_groups,id'],
|
||||
'groups.*.order' => ['required', 'integer', 'min:1'],
|
||||
'group_colors' => ['sometimes', 'array'],
|
||||
'group_colors.*' => ['required', 'string', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||
]);
|
||||
|
||||
$groupIds = collect($data['groups'] ?? [])->pluck('song_group_id')->values();
|
||||
$uniqueGroupIds = $groupIds->unique()->values();
|
||||
|
||||
$validGroupIds = $arrangement->song->groups()
|
||||
->whereIn('id', $uniqueGroupIds)
|
||||
->pluck('id');
|
||||
|
||||
if ($uniqueGroupIds->count() !== $validGroupIds->count()) {
|
||||
throw ValidationException::withMessages([
|
||||
'groups' => 'Du kannst nur Gruppen aus diesem Song verwenden.',
|
||||
]);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($arrangement, $groupIds, $data): void {
|
||||
$arrangement->arrangementGroups()->delete();
|
||||
|
||||
$rows = $groupIds
|
||||
->values()
|
||||
->map(fn (int $songGroupId, int $index) => [
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'song_group_id' => $songGroupId,
|
||||
'order' => $index + 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$arrangement->arrangementGroups()->insert($rows);
|
||||
}
|
||||
|
||||
if (!empty($data['group_colors'])) {
|
||||
foreach ($data['group_colors'] as $groupId => $color) {
|
||||
$arrangement->song->groups()
|
||||
->whereKey((int) $groupId)
|
||||
->update(['color' => $color]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde gespeichert.');
|
||||
}
|
||||
|
||||
public function destroy(SongArrangement $arrangement): RedirectResponse
|
||||
{
|
||||
$song = $arrangement->song;
|
||||
|
||||
if ($song->arrangements()->count() <= 1) {
|
||||
return back()->with('error', 'Das letzte Arrangement kann nicht gelöscht werden.');
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($arrangement, $song): void {
|
||||
$deletedWasDefault = $arrangement->is_default;
|
||||
$arrangement->delete();
|
||||
|
||||
if ($deletedWasDefault) {
|
||||
$song->arrangements()
|
||||
->orderBy('id')
|
||||
->limit(1)
|
||||
->update(['is_default' => true]);
|
||||
}
|
||||
});
|
||||
|
||||
return back()->with('success', 'Arrangement wurde gelöscht.');
|
||||
}
|
||||
|
||||
private function cloneGroups(?SongArrangement $source, SongArrangement $target): void
|
||||
{
|
||||
if ($source === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$groups = $source->arrangementGroups
|
||||
->sortBy('order')
|
||||
->values();
|
||||
|
||||
$rows = $groups
|
||||
->map(fn ($arrangementGroup) => [
|
||||
'song_arrangement_id' => $target->id,
|
||||
'song_group_id' => $arrangementGroup->song_group_id,
|
||||
'order' => $arrangementGroup->order,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
])
|
||||
->all();
|
||||
|
||||
if ($rows !== []) {
|
||||
$target->arrangementGroups()->insert($rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/Http/Controllers/ServiceController.php
Normal file
84
app/Http/Controllers/ServiceController.php
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\Slide;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ServiceController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$services = Service::query()
|
||||
->whereDate('date', '>=', Carbon::today())
|
||||
->orderBy('date')
|
||||
->withCount([
|
||||
'serviceSongs as songs_total_count',
|
||||
'serviceSongs as songs_mapped_count' => fn ($query) => $query->whereNotNull('song_id'),
|
||||
'serviceSongs as songs_arranged_count' => fn ($query) => $query->whereNotNull('song_arrangement_id'),
|
||||
])
|
||||
->addSelect([
|
||||
'has_sermon_slides' => Slide::query()
|
||||
->selectRaw('CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END')
|
||||
->whereColumn('slides.service_id', 'services.id')
|
||||
->where('slides.type', 'sermon'),
|
||||
'info_slides_count' => Slide::query()
|
||||
->selectRaw('COUNT(*)')
|
||||
->where('slides.type', 'information')
|
||||
->where(function ($query) {
|
||||
$query
|
||||
->whereNull('slides.service_id')
|
||||
->orWhereColumn('slides.service_id', 'services.id');
|
||||
})
|
||||
->whereNotNull('slides.expire_date')
|
||||
->whereColumn('slides.expire_date', '>=', 'services.date'),
|
||||
])
|
||||
->get()
|
||||
->map(fn (Service $service) => [
|
||||
'id' => $service->id,
|
||||
'title' => $service->title,
|
||||
'date' => $service->date?->toDateString(),
|
||||
'preacher_name' => $service->preacher_name,
|
||||
'beamer_tech_name' => $service->beamer_tech_name,
|
||||
'last_synced_at' => $service->last_synced_at?->toJSON(),
|
||||
'updated_at' => $service->updated_at?->toJSON(),
|
||||
'finalized_at' => $service->finalized_at?->toJSON(),
|
||||
'songs_total_count' => (int) $service->songs_total_count,
|
||||
'songs_mapped_count' => (int) $service->songs_mapped_count,
|
||||
'songs_arranged_count' => (int) $service->songs_arranged_count,
|
||||
'has_sermon_slides' => (bool) $service->has_sermon_slides,
|
||||
'info_slides_count' => (int) $service->info_slides_count,
|
||||
])
|
||||
->values();
|
||||
|
||||
return Inertia::render('Services/Index', [
|
||||
'services' => $services,
|
||||
]);
|
||||
}
|
||||
|
||||
public function finalize(Service $service): RedirectResponse
|
||||
{
|
||||
$service->update([
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('services.index')
|
||||
->with('success', 'Service wurde abgeschlossen.');
|
||||
}
|
||||
|
||||
public function reopen(Service $service): RedirectResponse
|
||||
{
|
||||
$service->update([
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('services.index')
|
||||
->with('success', 'Service wurde wieder geoeffnet.');
|
||||
}
|
||||
}
|
||||
67
app/Http/Controllers/ServiceSongController.php
Normal file
67
app/Http/Controllers/ServiceSongController.php
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Services\SongMatchingService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceSongController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongMatchingService $songMatchingService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manuell einen Song aus der DB einem ServiceSong zuordnen.
|
||||
*/
|
||||
public function assignSong(int $serviceSongId, Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'song_id' => ['required', 'integer', 'exists:songs,id'],
|
||||
]);
|
||||
|
||||
$serviceSong = ServiceSong::findOrFail($serviceSongId);
|
||||
$song = Song::findOrFail($validated['song_id']);
|
||||
|
||||
$this->songMatchingService->manualAssign($serviceSong, $song);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich zugeordnet',
|
||||
'service_song' => $serviceSong->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* E-Mail-Anfrage für fehlenden Song senden.
|
||||
*/
|
||||
public function requestSong(int $serviceSongId): JsonResponse
|
||||
{
|
||||
$serviceSong = ServiceSong::findOrFail($serviceSongId);
|
||||
|
||||
$this->songMatchingService->requestCreation($serviceSong);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Anfrage wurde gesendet',
|
||||
'service_song' => $serviceSong->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Zuordnung entfernen.
|
||||
*/
|
||||
public function unassign(int $serviceSongId): JsonResponse
|
||||
{
|
||||
$serviceSong = ServiceSong::findOrFail($serviceSongId);
|
||||
|
||||
$this->songMatchingService->unassign($serviceSong);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Zuordnung entfernt',
|
||||
'service_song' => $serviceSong->fresh(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
214
app/Http/Controllers/SlideController.php
Normal file
214
app/Http/Controllers/SlideController.php
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Slide;
|
||||
use App\Services\FileConversionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class SlideController extends Controller
|
||||
{
|
||||
private const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg'];
|
||||
|
||||
private const POWERPOINT_EXTENSIONS = ['ppt', 'pptx'];
|
||||
|
||||
public function store(Request $request, FileConversionService $conversionService): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'file' => ['required', 'file', 'max:51200'],
|
||||
'type' => ['required', Rule::in(['information', 'moderation', 'sermon'])],
|
||||
'service_id' => ['nullable', 'exists:services,id'],
|
||||
'expire_date' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
// moderation and sermon slides require a service_id
|
||||
if (in_array($validated['type'], ['moderation', 'sermon']) && empty($validated['service_id'])) {
|
||||
return response()->json([
|
||||
'message' => 'Moderations- und Predigtfolien benötigen einen Gottesdienst.',
|
||||
'errors' => ['service_id' => ['Gottesdienst ist erforderlich.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @var UploadedFile $file */
|
||||
$file = $request->file('file');
|
||||
$extension = strtolower($file->getClientOriginalExtension());
|
||||
|
||||
// Validate file extension
|
||||
$allowedExtensions = [...self::IMAGE_EXTENSIONS, ...self::POWERPOINT_EXTENSIONS, 'zip'];
|
||||
if (! in_array($extension, $allowedExtensions, true)) {
|
||||
return response()->json([
|
||||
'message' => 'Dateityp nicht erlaubt.',
|
||||
'errors' => ['file' => ['Nur PNG, JPG, PPT, PPTX und ZIP-Dateien sind erlaubt.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$uploaderName = $request->user()?->name ?? 'Unbekannt';
|
||||
$serviceId = $validated['service_id'] ?? null;
|
||||
$type = $validated['type'];
|
||||
$expireDate = $validated['expire_date'] ?? null;
|
||||
|
||||
// Handle PowerPoint files — dispatch async job
|
||||
if (in_array($extension, self::POWERPOINT_EXTENSIONS, true)) {
|
||||
return $this->handlePowerPoint($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
|
||||
}
|
||||
|
||||
// Handle ZIP files — extract and process
|
||||
if ($extension === 'zip') {
|
||||
return $this->handleZip($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
|
||||
}
|
||||
|
||||
// Handle images — convert synchronously
|
||||
return $this->handleImage($file, $conversionService, $type, $serviceId, $uploaderName, $expireDate);
|
||||
}
|
||||
|
||||
public function destroy(Slide $slide): JsonResponse
|
||||
{
|
||||
$slide->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Folie wurde gelöscht.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateExpireDate(Request $request, Slide $slide): JsonResponse
|
||||
{
|
||||
if ($slide->type !== 'information') {
|
||||
return response()->json([
|
||||
'message' => 'Ablaufdatum kann nur für Informationsfolien gesetzt werden.',
|
||||
'errors' => ['type' => ['Nur Informationsfolien haben ein Ablaufdatum.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'expire_date' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
$slide->update(['expire_date' => $validated['expire_date']]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'slide' => $slide->fresh(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleImage(
|
||||
UploadedFile $file,
|
||||
FileConversionService $conversionService,
|
||||
string $type,
|
||||
?int $serviceId,
|
||||
string $uploaderName,
|
||||
?string $expireDate,
|
||||
): JsonResponse {
|
||||
try {
|
||||
$result = $conversionService->convertImage($file);
|
||||
|
||||
$slide = Slide::create([
|
||||
'type' => $type,
|
||||
'service_id' => $serviceId,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_filename' => $result['filename'],
|
||||
'thumbnail_filename' => $result['thumbnail'],
|
||||
'expire_date' => $expireDate,
|
||||
'uploader_name' => $uploaderName,
|
||||
'uploaded_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'slide' => $slide,
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
private function handlePowerPoint(
|
||||
UploadedFile $file,
|
||||
FileConversionService $conversionService,
|
||||
string $type,
|
||||
?int $serviceId,
|
||||
string $uploaderName,
|
||||
?string $expireDate,
|
||||
): JsonResponse {
|
||||
// Store file persistently so the job can access it
|
||||
$storedPath = $file->store('temp/ppt', 'local');
|
||||
|
||||
try {
|
||||
$jobId = $conversionService->convertPowerPoint(
|
||||
storage_path('app/' . $storedPath)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'job_id' => $jobId,
|
||||
'message' => 'PowerPoint wird verarbeitet...',
|
||||
'meta' => [
|
||||
'type' => $type,
|
||||
'service_id' => $serviceId,
|
||||
'uploader_name' => $uploaderName,
|
||||
'expire_date' => $expireDate,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
],
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
private function handleZip(
|
||||
UploadedFile $file,
|
||||
FileConversionService $conversionService,
|
||||
string $type,
|
||||
?int $serviceId,
|
||||
string $uploaderName,
|
||||
?string $expireDate,
|
||||
): JsonResponse {
|
||||
try {
|
||||
$results = $conversionService->processZip($file);
|
||||
$slides = [];
|
||||
|
||||
foreach ($results as $result) {
|
||||
// Skip PPT job results (they are handled asynchronously)
|
||||
if (isset($result['job_id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$slides[] = Slide::create([
|
||||
'type' => $type,
|
||||
'service_id' => $serviceId,
|
||||
'original_filename' => $file->getClientOriginalName(),
|
||||
'stored_filename' => $result['filename'],
|
||||
'thumbnail_filename' => $result['thumbnail'],
|
||||
'expire_date' => $expireDate,
|
||||
'uploader_name' => $uploaderName,
|
||||
'uploaded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'slides' => $slides,
|
||||
'count' => count($slides),
|
||||
]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
Log::warning('ZIP-Verarbeitung fehlgeschlagen', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
}
|
||||
173
app/Http/Controllers/SongController.php
Normal file
173
app/Http/Controllers/SongController.php
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SongRequest;
|
||||
use App\Models\Song;
|
||||
use App\Services\SongService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SongService $songService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Songs auflisten (paginiert, durchsuchbar).
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Song::query();
|
||||
|
||||
if ($search = $request->input('search')) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title', 'like', "%{$search}%")
|
||||
->orWhere('ccli_id', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$songs = $query->orderBy('title')
|
||||
->paginate($request->input('per_page', 20));
|
||||
|
||||
return response()->json([
|
||||
'data' => $songs->map(fn (Song $song) => [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'author' => $song->author,
|
||||
'has_translation' => $song->has_translation,
|
||||
'last_used_at' => $song->last_used_at?->toDateString(),
|
||||
'last_used_in_service' => $song->last_used_in_service,
|
||||
'created_at' => $song->created_at->toDateTimeString(),
|
||||
'updated_at' => $song->updated_at->toDateTimeString(),
|
||||
]),
|
||||
'meta' => [
|
||||
'current_page' => $songs->currentPage(),
|
||||
'last_page' => $songs->lastPage(),
|
||||
'per_page' => $songs->perPage(),
|
||||
'total' => $songs->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Song erstellen mit Default-Gruppen und -Arrangement.
|
||||
*/
|
||||
public function store(SongRequest $request): JsonResponse
|
||||
{
|
||||
$song = DB::transaction(function () use ($request) {
|
||||
$song = Song::create($request->validated());
|
||||
|
||||
$this->songService->createDefaultGroups($song);
|
||||
$this->songService->createDefaultArrangement($song);
|
||||
|
||||
return $song;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich erstellt',
|
||||
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song mit Gruppen, Slides und Arrangements anzeigen.
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::with(['groups.slides', 'arrangements.arrangementGroups'])->find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->formatSongDetail($song),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Metadaten aktualisieren.
|
||||
*/
|
||||
public function update(SongRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
$song->update($request->validated());
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich aktualisiert',
|
||||
'data' => $this->formatSongDetail($song->fresh(['groups.slides', 'arrangements.arrangementGroups'])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song soft-löschen.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
$song = Song::find($id);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json(['message' => 'Song nicht gefunden'], 404);
|
||||
}
|
||||
|
||||
$song->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Song erfolgreich gelöscht',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song-Detail formatieren.
|
||||
*/
|
||||
private function formatSongDetail(Song $song): array
|
||||
{
|
||||
return [
|
||||
'id' => $song->id,
|
||||
'title' => $song->title,
|
||||
'ccli_id' => $song->ccli_id,
|
||||
'author' => $song->author,
|
||||
'copyright_text' => $song->copyright_text,
|
||||
'copyright_year' => $song->copyright_year,
|
||||
'publisher' => $song->publisher,
|
||||
'has_translation' => $song->has_translation,
|
||||
'last_used_at' => $song->last_used_at?->toDateString(),
|
||||
'last_used_in_service' => $song->last_used_in_service,
|
||||
'created_at' => $song->created_at->toDateTimeString(),
|
||||
'updated_at' => $song->updated_at->toDateTimeString(),
|
||||
'groups' => $song->groups->sortBy('order')->values()->map(fn ($group) => [
|
||||
'id' => $group->id,
|
||||
'name' => $group->name,
|
||||
'color' => $group->color,
|
||||
'order' => $group->order,
|
||||
'slides' => $group->slides->sortBy('order')->values()->map(fn ($slide) => [
|
||||
'id' => $slide->id,
|
||||
'order' => $slide->order,
|
||||
'text_content' => $slide->text_content,
|
||||
'text_content_translated' => $slide->text_content_translated,
|
||||
'notes' => $slide->notes,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
'arrangements' => $song->arrangements->map(fn ($arr) => [
|
||||
'id' => $arr->id,
|
||||
'name' => $arr->name,
|
||||
'is_default' => $arr->is_default,
|
||||
'arrangement_groups' => $arr->arrangementGroups->sortBy('order')->values()->map(fn ($ag) => [
|
||||
'id' => $ag->id,
|
||||
'song_group_id' => $ag->song_group_id,
|
||||
'order' => $ag->order,
|
||||
])->toArray(),
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Http/Controllers/TranslationController.php
Normal file
87
app/Http/Controllers/TranslationController.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Services\TranslationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TranslationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TranslationService $translationService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* URL abrufen und Text zum Prüfen zurückgeben.
|
||||
*
|
||||
* Der Text wird NICHT automatisch gespeichert — der Benutzer
|
||||
* prüft ihn zuerst und importiert dann explizit.
|
||||
*/
|
||||
public function fetchUrl(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'url' => ['required', 'url'],
|
||||
]);
|
||||
|
||||
$text = $this->translationService->fetchFromUrl($request->input('url'));
|
||||
|
||||
if ($text === null) {
|
||||
return response()->json([
|
||||
'message' => 'Konnte Text nicht abrufen',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'text' => $text,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext für einen Song importieren.
|
||||
*
|
||||
* Verteilt den Text zeilenweise auf die Slides des Songs.
|
||||
*/
|
||||
public function import(int $songId, Request $request): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json([
|
||||
'message' => 'Song nicht gefunden',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'text' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$this->translationService->importTranslation($song, $request->input('text'));
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Übersetzung erfolgreich importiert',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*/
|
||||
public function destroy(int $songId): JsonResponse
|
||||
{
|
||||
$song = Song::find($songId);
|
||||
|
||||
if (! $song) {
|
||||
return response()->json([
|
||||
'message' => 'Song nicht gefunden',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->translationService->removeTranslation($song);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Übersetzung entfernt',
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
app/Http/Requests/SongRequest.php
Normal file
37
app/Http/Requests/SongRequest.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SongRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$songId = $this->route('song');
|
||||
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'ccli_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:50',
|
||||
Rule::unique('songs', 'ccli_id')->ignore($songId)->whereNull('deleted_at'),
|
||||
],
|
||||
'author' => ['nullable', 'string', 'max:255'],
|
||||
'copyright_text' => ['nullable', 'string', 'max:1000'],
|
||||
'copyright_year' => ['nullable', 'string', 'max:10'],
|
||||
'publisher' => ['nullable', 'string', 'max:255'],
|
||||
'has_translation' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
class Song extends Model
|
||||
{
|
||||
|
|
@ -45,4 +46,24 @@ public function serviceSongs(): HasMany
|
|||
{
|
||||
return $this->hasMany(ServiceSong::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Letztes Service-Datum, in dem dieser Song verwendet wurde.
|
||||
* Berechnet über die service_songs -> services Verknüpfung.
|
||||
*/
|
||||
public function lastUsedInService(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$latestDate = $this->serviceSongs()
|
||||
->join('services', 'services.id', '=', 'service_songs.service_id')
|
||||
->max('services.date');
|
||||
|
||||
if ($latestDate === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SQLite gibt date-Spalten als 'YYYY-MM-DD 00:00:00' zurück
|
||||
return substr((string) $latestDate, 0, 10);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,6 +214,8 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
|
|||
'unmatched_songs_count' => 0,
|
||||
];
|
||||
|
||||
$songMatchingService = app(SongMatchingService::class);
|
||||
|
||||
foreach ($agendaSongs as $index => $song) {
|
||||
$ctsSongId = (string) ($song->getId() ?? '');
|
||||
if ($ctsSongId === '') {
|
||||
|
|
@ -221,9 +223,6 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
|
|||
}
|
||||
|
||||
$ccliId = $this->normalizeCcli($song->getCcli() ?? null);
|
||||
$matchedSong = $ccliId === null
|
||||
? null
|
||||
: DB::table('songs')->where('ccli_id', $ccliId)->first();
|
||||
|
||||
DB::table('service_songs')->updateOrInsert(
|
||||
[
|
||||
|
|
@ -233,16 +232,26 @@ private function syncServiceAgendaSongs(int $serviceId, int $eventId): array
|
|||
[
|
||||
'cts_song_name' => (string) ($song->getName() ?? ''),
|
||||
'cts_ccli_id' => $ccliId,
|
||||
'song_id' => $matchedSong?->id,
|
||||
'matched_at' => $matchedSong !== null ? Carbon::now() : null,
|
||||
'updated_at' => Carbon::now(),
|
||||
'created_at' => Carbon::now(),
|
||||
]
|
||||
);
|
||||
|
||||
$serviceSong = \App\Models\ServiceSong::where('service_id', $serviceId)
|
||||
->where('order', $index + 1)
|
||||
->first();
|
||||
|
||||
$matched = false;
|
||||
|
||||
if ($serviceSong !== null && $serviceSong->cts_ccli_id && ! $serviceSong->song_id) {
|
||||
$matched = $songMatchingService->autoMatch($serviceSong);
|
||||
} elseif ($serviceSong !== null && $serviceSong->song_id) {
|
||||
$matched = true;
|
||||
}
|
||||
|
||||
$summary['songs_count']++;
|
||||
|
||||
if ($matchedSong !== null) {
|
||||
if ($matched) {
|
||||
$summary['matched_songs_count']++;
|
||||
} else {
|
||||
$summary['unmatched_songs_count']++;
|
||||
|
|
|
|||
86
app/Services/SongMatchingService.php
Normal file
86
app/Services/SongMatchingService.php
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\MissingSongRequest;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class SongMatchingService
|
||||
{
|
||||
/**
|
||||
* Auto-match a service song to a song in the DB by CCLI ID.
|
||||
*
|
||||
* Returns true if a match was found and assigned, false otherwise.
|
||||
* Skips if already matched or no CCLI ID present.
|
||||
*/
|
||||
public function autoMatch(ServiceSong $serviceSong): bool
|
||||
{
|
||||
if ($serviceSong->song_id !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($serviceSong->cts_ccli_id === null || $serviceSong->cts_ccli_id === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$song = Song::where('ccli_id', $serviceSong->cts_ccli_id)->first();
|
||||
|
||||
if ($song === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$serviceSong->update([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually assign a song to a service song.
|
||||
* Overwrites any existing assignment.
|
||||
*/
|
||||
public function manualAssign(ServiceSong $serviceSong, Song $song): void
|
||||
{
|
||||
$serviceSong->update([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a missing song request email and record the timestamp.
|
||||
*/
|
||||
public function requestCreation(ServiceSong $serviceSong): void
|
||||
{
|
||||
$service = $serviceSong->service;
|
||||
|
||||
$recipientEmail = (string) Config::get('services.song_request.email', 'songs@example.com');
|
||||
|
||||
Mail::to($recipientEmail)->send(new MissingSongRequest(
|
||||
songName: $serviceSong->cts_song_name,
|
||||
ccliId: $serviceSong->cts_ccli_id,
|
||||
service: $service,
|
||||
));
|
||||
|
||||
$serviceSong->update([
|
||||
'request_sent_at' => Carbon::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove song assignment from a service song.
|
||||
*/
|
||||
public function unassign(ServiceSong $serviceSong): void
|
||||
{
|
||||
$serviceSong->update([
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
77
app/Services/SongService.php
Normal file
77
app/Services/SongService.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
/**
|
||||
* Default-Gruppen für ein neues Lied erstellen.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Collection<int, SongGroup>
|
||||
*/
|
||||
public function createDefaultGroups(Song $song): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
$defaults = [
|
||||
['name' => 'Strophe 1', 'color' => '#3B82F6', 'order' => 1],
|
||||
['name' => 'Refrain', 'color' => '#10B981', 'order' => 2],
|
||||
['name' => 'Bridge', 'color' => '#F59E0B', 'order' => 3],
|
||||
];
|
||||
|
||||
foreach ($defaults as $groupData) {
|
||||
$song->groups()->create($groupData);
|
||||
}
|
||||
|
||||
return $song->groups()->orderBy('order')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard "Normal"-Arrangement mit allen Gruppen erstellen.
|
||||
*/
|
||||
public function createDefaultArrangement(Song $song): SongArrangement
|
||||
{
|
||||
$arrangement = $song->arrangements()->create([
|
||||
'name' => 'Normal',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
$groups = $song->groups()->orderBy('order')->get();
|
||||
|
||||
foreach ($groups as $index => $group) {
|
||||
$arrangement->arrangementGroups()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => $index + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $arrangement->load('arrangementGroups');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrangement duplizieren mit neuem Namen.
|
||||
*/
|
||||
public function duplicateArrangement(SongArrangement $arrangement, string $name): SongArrangement
|
||||
{
|
||||
return DB::transaction(function () use ($arrangement, $name) {
|
||||
$clone = $arrangement->replicate(['id', 'created_at', 'updated_at']);
|
||||
$clone->name = $name;
|
||||
$clone->is_default = false;
|
||||
$clone->save();
|
||||
|
||||
foreach ($arrangement->arrangementGroups()->orderBy('order')->get() as $group) {
|
||||
SongArrangementGroup::create([
|
||||
'song_arrangement_id' => $clone->id,
|
||||
'song_group_id' => $group->song_group_id,
|
||||
'order' => $group->order,
|
||||
]);
|
||||
}
|
||||
|
||||
return $clone->load('arrangementGroups');
|
||||
});
|
||||
}
|
||||
}
|
||||
99
app/Services/TranslationService.php
Normal file
99
app/Services/TranslationService.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongSlide;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TranslationService
|
||||
{
|
||||
/**
|
||||
* Text von einer URL abrufen (Best-Effort).
|
||||
*
|
||||
* HTML-Tags werden entfernt, nur reiner Text zurückgegeben.
|
||||
* Bei Fehlern wird null zurückgegeben, ohne Exception.
|
||||
*/
|
||||
public function fetchFromUrl(string $url): ?string
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(10)->get($url);
|
||||
|
||||
if ($response->successful()) {
|
||||
$html = $response->body();
|
||||
$text = strip_tags($html);
|
||||
$text = trim($text);
|
||||
|
||||
return $text !== '' ? $text : null;
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// Best-effort: Fehler stillschweigend behandeln
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungstext auf Slides verteilen, basierend auf der Zeilenanzahl jeder Slide.
|
||||
*
|
||||
* Für jede Gruppe (nach order sortiert) und jede Slide (nach order sortiert):
|
||||
* Nimm so viele Zeilen aus dem übersetzten Text, wie die Original-Slide Zeilen hat.
|
||||
*
|
||||
* Beispiel:
|
||||
* Slide 1 hat 4 Zeilen → bekommt die nächsten 4 Zeilen der Übersetzung
|
||||
* Slide 2 hat 2 Zeilen → bekommt die nächsten 2 Zeilen
|
||||
* Slide 3 hat 4 Zeilen → bekommt die nächsten 4 Zeilen
|
||||
*/
|
||||
public function importTranslation(Song $song, string $text): void
|
||||
{
|
||||
$translatedLines = explode("\n", $text);
|
||||
$offset = 0;
|
||||
|
||||
// Alle Gruppen nach order sortiert laden, mit Slides
|
||||
$groups = $song->groups()->orderBy('order')->with([
|
||||
'slides' => fn ($query) => $query->orderBy('order'),
|
||||
])->get();
|
||||
|
||||
foreach ($groups as $group) {
|
||||
foreach ($group->slides as $slide) {
|
||||
$originalLineCount = count(explode("\n", $slide->text_content ?? ''));
|
||||
$chunk = array_slice($translatedLines, $offset, $originalLineCount);
|
||||
$offset += $originalLineCount;
|
||||
|
||||
$slide->update([
|
||||
'text_content_translated' => implode("\n", $chunk),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->markAsTranslated($song);
|
||||
}
|
||||
|
||||
/**
|
||||
* Song als "hat Übersetzung" markieren.
|
||||
*/
|
||||
public function markAsTranslated(Song $song): void
|
||||
{
|
||||
$song->update(['has_translation' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzung eines Songs komplett entfernen.
|
||||
*
|
||||
* Löscht alle text_content_translated Felder und setzt has_translation auf false.
|
||||
*/
|
||||
public function removeTranslation(Song $song): void
|
||||
{
|
||||
// Alle Slides des Songs über die Gruppen aktualisieren
|
||||
$slideIds = SongSlide::whereIn(
|
||||
'song_group_id',
|
||||
$song->groups()->pluck('id')
|
||||
)->pluck('id');
|
||||
|
||||
SongSlide::whereIn('id', $slideIds)->update([
|
||||
'text_content_translated' => null,
|
||||
]);
|
||||
|
||||
$song->update(['has_translation' => false]);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
|
|
|
|||
296
resources/js/Components/ArrangementConfigurator.vue
Normal file
296
resources/js/Components/ArrangementConfigurator.vue
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
|
||||
const props = defineProps({
|
||||
songId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
arrangements: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
availableGroups: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedArrangementId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
|
||||
const selectedId = ref(
|
||||
props.selectedArrangementId ?? props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id ?? null,
|
||||
)
|
||||
|
||||
const arrangementGroups = ref([])
|
||||
const poolGroups = ref([])
|
||||
|
||||
const selectedArrangement = computed(() =>
|
||||
props.arrangements.find((arrangement) => arrangement.id === Number(selectedId.value)) ?? null,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.availableGroups,
|
||||
(groups) => {
|
||||
poolGroups.value = groups.map((group) => ({ ...group }))
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedArrangement,
|
||||
(arrangement) => {
|
||||
arrangementGroups.value = arrangement?.groups?.map((group) => ({ ...group })) ?? []
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const colorMap = computed(() => {
|
||||
const map = {}
|
||||
|
||||
poolGroups.value.forEach((group) => {
|
||||
map[group.id] = group.color
|
||||
})
|
||||
|
||||
arrangementGroups.value.forEach((group) => {
|
||||
map[group.id] = group.color
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const groupPillStyle = (group) => ({
|
||||
backgroundColor: group.color,
|
||||
})
|
||||
|
||||
const addArrangement = () => {
|
||||
const name = window.prompt('Name des neuen Arrangements')
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
router.post(
|
||||
`/songs/${props.songId}/arrangements`,
|
||||
{ name },
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const cloneArrangement = () => {
|
||||
if (!selectedArrangement.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = window.prompt('Name des neuen Arrangements', `${selectedArrangement.value.name} Kopie`)
|
||||
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
|
||||
router.post(
|
||||
`/arrangements/${selectedArrangement.value.id}/clone`,
|
||||
{ name },
|
||||
{
|
||||
preserveScroll: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const removeGroupAt = (index) => {
|
||||
arrangementGroups.value.splice(index, 1)
|
||||
saveArrangement()
|
||||
}
|
||||
|
||||
const syncPoolColor = (groupId, color) => {
|
||||
poolGroups.value = poolGroups.value.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
color,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const updateGroupColor = (group, color) => {
|
||||
group.color = color
|
||||
syncPoolColor(group.id, color)
|
||||
saveArrangement()
|
||||
}
|
||||
|
||||
const saveArrangement = () => {
|
||||
if (!selectedArrangement.value) {
|
||||
return
|
||||
}
|
||||
|
||||
router.put(
|
||||
`/arrangements/${selectedArrangement.value.id}`,
|
||||
{
|
||||
groups: arrangementGroups.value.map((group, index) => ({
|
||||
song_group_id: group.id,
|
||||
order: index + 1,
|
||||
})),
|
||||
group_colors: colorMap.value,
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const deleteArrangement = () => {
|
||||
if (!selectedArrangement.value) {
|
||||
return
|
||||
}
|
||||
|
||||
router.delete(`/arrangements/${selectedArrangement.value.id}`, {
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="min-w-64 flex-1">
|
||||
<label
|
||||
for="arrangement-select"
|
||||
class="mb-1 block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Arrangement
|
||||
</label>
|
||||
<select
|
||||
id="arrangement-select"
|
||||
v-model="selectedId"
|
||||
class="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
>
|
||||
<option
|
||||
v-for="arrangement in arrangements"
|
||||
:key="arrangement.id"
|
||||
:value="arrangement.id"
|
||||
>
|
||||
{{ arrangement.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-blue-700"
|
||||
@click="addArrangement"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-slate-700 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-slate-800"
|
||||
@click="cloneArrangement"
|
||||
>
|
||||
Klonen
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-700"
|
||||
@click="deleteArrangement"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
|
||||
Verfügbare Gruppen
|
||||
</h4>
|
||||
|
||||
<VueDraggable
|
||||
v-model="poolGroups"
|
||||
:group="{ name: 'song-groups', pull: 'clone', put: false }"
|
||||
:sort="false"
|
||||
class="min-h-24 rounded-lg border border-dashed border-gray-300 bg-gray-50 p-3"
|
||||
>
|
||||
<div
|
||||
v-for="group in poolGroups"
|
||||
:key="group.id"
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
|
||||
>
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
|
||||
:style="groupPillStyle(group)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
|
||||
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
|
||||
Farbe ändern
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
|
||||
:value="group.color"
|
||||
@input="updateGroupColor(group, $event.target.value)"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-gray-600">
|
||||
Gruppenfolge
|
||||
</h4>
|
||||
|
||||
<VueDraggable
|
||||
v-model="arrangementGroups"
|
||||
:group="{ name: 'song-groups', pull: true, put: true }"
|
||||
class="min-h-24 rounded-lg border border-gray-200 bg-gray-50 p-3"
|
||||
@end="saveArrangement"
|
||||
>
|
||||
<div
|
||||
v-for="(group, index) in arrangementGroups"
|
||||
:key="`${group.id}-${index}`"
|
||||
class="mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-white p-2"
|
||||
>
|
||||
<span class="cursor-move text-gray-500">⋮⋮</span>
|
||||
|
||||
<span
|
||||
class="inline-flex rounded-full px-3 py-1 text-sm font-semibold text-white"
|
||||
:style="groupPillStyle(group)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</span>
|
||||
|
||||
<label class="ml-auto flex items-center gap-2 text-xs text-gray-600">
|
||||
Farbe ändern
|
||||
<input
|
||||
type="color"
|
||||
class="h-8 w-8 cursor-pointer rounded border border-gray-300"
|
||||
:value="group.color"
|
||||
@input="updateGroupColor(group, $event.target.value)"
|
||||
>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-2 py-1 text-xs font-semibold text-red-600 hover:bg-red-50"
|
||||
@click="removeGroupAt(index)"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
340
resources/js/Components/SlideGrid.vue
Normal file
340
resources/js/Components/SlideGrid.vue
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
|
||||
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
|
||||
|
||||
const props = defineProps({
|
||||
slides: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (v) => ['information', 'moderation', 'sermon'].includes(v),
|
||||
},
|
||||
showExpireDate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['deleted', 'updated'])
|
||||
|
||||
// Delete confirmation state
|
||||
const confirmingDelete = ref(false)
|
||||
const slideToDelete = ref(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Inline expire date editing
|
||||
const editingExpireId = ref(null)
|
||||
const editingExpireValue = ref('')
|
||||
|
||||
// Sort slides: newest first
|
||||
const sortedSlides = computed(() => {
|
||||
return [...props.slides].sort(
|
||||
(a, b) => new Date(b.uploaded_at) - new Date(a.uploaded_at),
|
||||
)
|
||||
})
|
||||
|
||||
function thumbnailUrl(slide) {
|
||||
if (!slide.thumbnail_filename) return null
|
||||
return `/storage/${slide.thumbnail_filename}`
|
||||
}
|
||||
|
||||
function fullImageUrl(slide) {
|
||||
if (!slide.stored_filename) return null
|
||||
return `/storage/${slide.stored_filename}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '—'
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
// Delete slide
|
||||
function promptDelete(slide) {
|
||||
slideToDelete.value = slide
|
||||
confirmingDelete.value = true
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
confirmingDelete.value = false
|
||||
slideToDelete.value = null
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
if (!slideToDelete.value) return
|
||||
deleting.value = true
|
||||
|
||||
router.delete(route('slides.destroy', slideToDelete.value.id), {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
confirmingDelete.value = false
|
||||
slideToDelete.value = null
|
||||
deleting.value = false
|
||||
emit('deleted')
|
||||
},
|
||||
onError: () => {
|
||||
deleting.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Expire date editing
|
||||
function startEditExpire(slide) {
|
||||
editingExpireId.value = slide.id
|
||||
editingExpireValue.value = slide.expire_date
|
||||
? new Date(slide.expire_date).toISOString().split('T')[0]
|
||||
: ''
|
||||
}
|
||||
|
||||
function saveExpireDate(slide) {
|
||||
router.patch(
|
||||
route('slides.update-expire-date', slide.id),
|
||||
{ expire_date: editingExpireValue.value || null },
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onSuccess: () => {
|
||||
editingExpireId.value = null
|
||||
emit('updated')
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function cancelEditExpire() {
|
||||
editingExpireId.value = null
|
||||
editingExpireValue.value = ''
|
||||
}
|
||||
|
||||
function isExpireSoon(expireDate) {
|
||||
if (!expireDate) return false
|
||||
const diff = new Date(expireDate) - new Date()
|
||||
return diff > 0 && diff < 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
}
|
||||
|
||||
function isExpired(expireDate) {
|
||||
if (!expireDate) return false
|
||||
return new Date(expireDate) < new Date()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slide-grid">
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="sortedSlides.length === 0"
|
||||
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50/50 py-10"
|
||||
>
|
||||
<svg class="h-10 w-10 text-gray-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
<p class="text-sm text-gray-400">Noch keine Folien vorhanden</p>
|
||||
</div>
|
||||
|
||||
<!-- Grid of thumbnails -->
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
|
||||
>
|
||||
<div
|
||||
v-for="slide in sortedSlides"
|
||||
:key="slide.id"
|
||||
class="slide-card group relative overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-all duration-300 hover:shadow-md hover:border-gray-300 hover:-translate-y-0.5"
|
||||
>
|
||||
<!-- PPT processing indicator -->
|
||||
<div
|
||||
v-if="slide._processing"
|
||||
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/90 backdrop-blur-sm"
|
||||
>
|
||||
<LoadingSpinner size="md" />
|
||||
<span class="mt-2 text-xs font-medium text-gray-500">Verarbeitung läuft...</span>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<div class="relative aspect-video overflow-hidden bg-gray-900">
|
||||
<img
|
||||
v-if="thumbnailUrl(slide)"
|
||||
:src="thumbnailUrl(slide)"
|
||||
:alt="slide.original_filename"
|
||||
class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900"
|
||||
>
|
||||
<svg class="h-8 w-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Delete button overlay -->
|
||||
<button
|
||||
@click.stop="promptDelete(slide)"
|
||||
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:text-white group-hover:opacity-100"
|
||||
title="Löschen"
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Full image link overlay -->
|
||||
<a
|
||||
v-if="fullImageUrl(slide)"
|
||||
:href="fullImageUrl(slide)"
|
||||
target="_blank"
|
||||
class="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-white/90 hover:text-gray-800 group-hover:opacity-100"
|
||||
title="Vollbild öffnen"
|
||||
>
|
||||
<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="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="px-3 py-2.5">
|
||||
<!-- Upload info (muted) -->
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-gray-400">
|
||||
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="slide.uploader_name"
|
||||
class="mt-0.5 flex items-center gap-1.5 text-[11px] text-gray-400"
|
||||
>
|
||||
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
|
||||
</svg>
|
||||
<span class="truncate">{{ slide.uploader_name }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Expire date (prominent for information slides) -->
|
||||
<div
|
||||
v-if="showExpireDate"
|
||||
class="mt-2"
|
||||
>
|
||||
<!-- Display mode -->
|
||||
<div
|
||||
v-if="editingExpireId !== slide.id"
|
||||
class="flex items-center gap-1.5 cursor-pointer rounded-lg px-2 py-1 -mx-1 transition-colors hover:bg-gray-50"
|
||||
@click="startEditExpire(slide)"
|
||||
:title="'Ablaufdatum ändern'"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 shrink-0"
|
||||
:class="{
|
||||
'text-red-500': isExpired(slide.expire_date),
|
||||
'text-amber-500': isExpireSoon(slide.expire_date),
|
||||
'text-emerald-500': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
|
||||
'text-gray-300': !slide.expire_date,
|
||||
}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
|
||||
</svg>
|
||||
<span
|
||||
class="text-xs font-semibold"
|
||||
:class="{
|
||||
'text-red-600': isExpired(slide.expire_date),
|
||||
'text-amber-600': isExpireSoon(slide.expire_date),
|
||||
'text-gray-700': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
|
||||
'text-gray-400 font-normal': !slide.expire_date,
|
||||
}"
|
||||
>
|
||||
{{ slide.expire_date ? formatDate(slide.expire_date) : 'Kein Ablaufdatum' }}
|
||||
</span>
|
||||
|
||||
<!-- Tiny edit icon -->
|
||||
<svg class="h-3 w-3 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity" 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.125" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
v-model="editingExpireValue"
|
||||
type="date"
|
||||
class="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400/40"
|
||||
@keydown.enter="saveExpireDate(slide)"
|
||||
@keydown.escape="cancelEditExpire"
|
||||
/>
|
||||
<button
|
||||
@click="saveExpireDate(slide)"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-amber-500 text-white shadow-sm transition hover:bg-amber-600"
|
||||
title="Speichern"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="cancelEditExpire"
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-500 shadow-sm transition hover:bg-gray-50"
|
||||
title="Abbrechen"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<ConfirmDialog
|
||||
:show="confirmingDelete"
|
||||
title="Folie löschen?"
|
||||
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
|
||||
confirm-label="Löschen"
|
||||
cancel-label="Abbrechen"
|
||||
variant="danger"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-card {
|
||||
contain: layout;
|
||||
}
|
||||
|
||||
.slide-card img {
|
||||
image-rendering: auto;
|
||||
}
|
||||
</style>
|
||||
283
resources/js/Components/SlideUploader.vue
Normal file
283
resources/js/Components/SlideUploader.vue
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { router } from '@inertiajs/vue3'
|
||||
import Vue3Dropzone from '@jaxtheprime/vue3-dropzone'
|
||||
import '@jaxtheprime/vue3-dropzone/dist/style.css'
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (v) => ['information', 'moderation', 'sermon'].includes(v),
|
||||
},
|
||||
serviceId: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
showExpireDate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['uploaded'])
|
||||
|
||||
const files = ref([])
|
||||
const expireDate = ref('')
|
||||
const uploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const uploadError = ref('')
|
||||
const uploadedCount = ref(0)
|
||||
const totalCount = ref(0)
|
||||
|
||||
const acceptedTypes = [
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/zip',
|
||||
]
|
||||
|
||||
const acceptedExtensions = ['.png', '.jpg', '.jpeg', '.ppt', '.pptx', '.zip']
|
||||
|
||||
const isUploading = computed(() => uploading.value)
|
||||
const progressPercent = computed(() => Math.round(uploadProgress.value))
|
||||
|
||||
function processFiles() {
|
||||
if (files.value.length === 0) return
|
||||
|
||||
uploadError.value = ''
|
||||
totalCount.value = files.value.length
|
||||
uploadedCount.value = 0
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
uploadNextFile(0)
|
||||
}
|
||||
|
||||
function uploadNextFile(index) {
|
||||
if (index >= files.value.length) {
|
||||
uploading.value = false
|
||||
uploadProgress.value = 100
|
||||
files.value = []
|
||||
emit('uploaded')
|
||||
|
||||
// Reset progress after brief display
|
||||
setTimeout(() => {
|
||||
uploadProgress.value = 0
|
||||
uploadedCount.value = 0
|
||||
totalCount.value = 0
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
const file = files.value[index]
|
||||
|
||||
// Validate extension client-side
|
||||
const ext = '.' + file.name.split('.').pop().toLowerCase()
|
||||
if (!acceptedExtensions.includes(ext)) {
|
||||
uploadError.value = `"${file.name}" — Dateityp nicht erlaubt. Nur PNG, JPG, PPT, PPTX und ZIP.`
|
||||
uploading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', props.type)
|
||||
|
||||
if (props.serviceId) {
|
||||
formData.append('service_id', props.serviceId)
|
||||
}
|
||||
|
||||
if (props.showExpireDate && expireDate.value) {
|
||||
formData.append('expire_date', expireDate.value)
|
||||
}
|
||||
|
||||
router.post(route('slides.store'), formData, {
|
||||
forceFormData: true,
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
onProgress: (event) => {
|
||||
if (event.percentage) {
|
||||
// Per-file progress weighted across total
|
||||
const perFileWeight = 100 / totalCount.value
|
||||
uploadProgress.value = (index * perFileWeight) + (event.percentage / 100 * perFileWeight)
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
uploadedCount.value = index + 1
|
||||
uploadNextFile(index + 1)
|
||||
},
|
||||
onError: (errors) => {
|
||||
uploading.value = false
|
||||
uploadError.value = errors.file?.[0] || errors.message || 'Upload fehlgeschlagen.'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function dismissError() {
|
||||
uploadError.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="slide-uploader">
|
||||
<!-- Expire date picker for information slides -->
|
||||
<div
|
||||
v-if="showExpireDate"
|
||||
class="mb-4 flex items-center gap-3"
|
||||
>
|
||||
<label class="text-sm font-medium text-gray-600">
|
||||
Ablaufdatum für neue Folien
|
||||
</label>
|
||||
<input
|
||||
v-model="expireDate"
|
||||
type="date"
|
||||
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm transition focus:border-amber-400 focus:outline-none focus:ring-2 focus:ring-amber-400/30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<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 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="uploadError"
|
||||
class="mb-4 flex items-center gap-3 rounded-xl border border-red-200 bg-red-50/80 px-4 py-3 text-sm text-red-700"
|
||||
>
|
||||
<svg class="h-5 w-5 shrink-0 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<span class="flex-1">{{ uploadError }}</span>
|
||||
<button
|
||||
@click="dismissError"
|
||||
class="shrink-0 rounded-lg p-0.5 text-red-400 transition hover:text-red-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>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Upload progress bar -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="isUploading || (uploadProgress > 0 && uploadProgress <= 100)"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500 mb-1.5">
|
||||
<span class="font-medium">
|
||||
Hochladen... {{ uploadedCount }}/{{ totalCount }}
|
||||
</span>
|
||||
<span class="tabular-nums">{{ progressPercent }}%</span>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div
|
||||
class="h-full rounded-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500 ease-out"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Dropzone -->
|
||||
<Vue3Dropzone
|
||||
v-model="files"
|
||||
:multiple="true"
|
||||
:accept="acceptedTypes"
|
||||
:max-file-size="50"
|
||||
:max-files="20"
|
||||
:disabled="isUploading"
|
||||
:show-select-button="false"
|
||||
class="slide-dropzone"
|
||||
@change="processFiles"
|
||||
>
|
||||
<template #placeholder-img>
|
||||
<div class="flex flex-col items-center gap-3 pointer-events-none">
|
||||
<!-- Big plus icon -->
|
||||
<div class="relative">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-50 to-orange-50 shadow-sm ring-1 ring-amber-200/60">
|
||||
<svg class="h-8 w-8 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Animated pulse ring -->
|
||||
<div class="absolute inset-0 rounded-2xl ring-2 ring-amber-300/20 animate-ping" style="animation-duration: 3s;" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<span class="mt-3 block text-sm font-semibold text-gray-700">
|
||||
Dateien hier ablegen
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<span class="mt-1 block text-xs text-gray-400">
|
||||
oder klicken zum Auswählen
|
||||
</span>
|
||||
<span class="mt-2 block text-[11px] text-gray-300">
|
||||
PNG, JPG, PPT, PPTX, ZIP — max 50 MB
|
||||
</span>
|
||||
</template>
|
||||
</Vue3Dropzone>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-dropzone {
|
||||
--v3-dropzone--primary: 245, 158, 11;
|
||||
--v3-dropzone--border: 229, 231, 235;
|
||||
--v3-dropzone--description: 156, 163, 175;
|
||||
--v3-dropzone--overlay: 245, 158, 11;
|
||||
--v3-dropzone--overlay-opacity: 0.08;
|
||||
}
|
||||
|
||||
.slide-dropzone :deep(.v3-dropzone) {
|
||||
border: 2px dashed rgb(229 231 235);
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(135deg, rgb(255 251 235 / 0.5), rgb(254 243 199 / 0.3));
|
||||
padding: 2rem 1.5rem;
|
||||
min-height: 160px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-dropzone :deep(.v3-dropzone:hover) {
|
||||
border-color: rgb(251 191 36);
|
||||
background: linear-gradient(135deg, rgb(255 251 235 / 0.8), rgb(254 243 199 / 0.5));
|
||||
box-shadow: 0 0 0 4px rgb(251 191 36 / 0.1);
|
||||
}
|
||||
|
||||
.slide-dropzone :deep(.v3-dropzone.v3-dropzone--active) {
|
||||
border-color: rgb(245 158 11);
|
||||
background: linear-gradient(135deg, rgb(255 251 235), rgb(254 243 199 / 0.7));
|
||||
box-shadow: 0 0 0 4px rgb(245 158 11 / 0.15);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
.slide-dropzone :deep(.v3-dropzone--disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Hide the default preview since we handle it in SlideGrid */
|
||||
.slide-dropzone :deep(.v3-dropzone__preview) {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -83,8 +83,8 @@ function triggerSync() {
|
|||
<!-- Desktop Navigation -->
|
||||
<div class="hidden items-center gap-1 sm:flex">
|
||||
<NavLink
|
||||
:href="route('dashboard')"
|
||||
:active="route().current('dashboard') || route().current('services.*')"
|
||||
:href="route('services.index')"
|
||||
:active="route().current('services.*')"
|
||||
>
|
||||
Services
|
||||
</NavLink>
|
||||
|
|
@ -231,8 +231,8 @@ function triggerSync() {
|
|||
<!-- Mobile Navigation -->
|
||||
<div class="space-y-1 pb-3 pt-2">
|
||||
<ResponsiveNavLink
|
||||
:href="route('dashboard')"
|
||||
:active="route().current('dashboard') || route().current('services.*')"
|
||||
:href="route('services.index')"
|
||||
:active="route().current('services.*')"
|
||||
>
|
||||
Services
|
||||
</ResponsiveNavLink>
|
||||
|
|
|
|||
233
resources/js/Pages/Services/Index.vue
Normal file
233
resources/js/Pages/Services/Index.vue
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<script setup>
|
||||
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'
|
||||
import { Head, router } from '@inertiajs/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
services: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const toastMessage = ref('')
|
||||
|
||||
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',
|
||||
})
|
||||
}
|
||||
|
||||
function showComingSoon() {
|
||||
toastMessage.value = 'Demnaechst verfuegbar'
|
||||
|
||||
setTimeout(() => {
|
||||
toastMessage.value = ''
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
function finalizeService(serviceId) {
|
||||
router.post(route('services.finalize', serviceId), {}, { preserveScroll: true })
|
||||
}
|
||||
|
||||
function reopenService(serviceId) {
|
||||
router.post(route('services.reopen', serviceId), {}, { preserveScroll: true })
|
||||
}
|
||||
|
||||
function mappingStatusClass(service) {
|
||||
if (service.songs_total_count === 0) {
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
if (service.songs_mapped_count === service.songs_total_count) {
|
||||
return 'text-emerald-700'
|
||||
}
|
||||
|
||||
return 'text-orange-600'
|
||||
}
|
||||
|
||||
function arrangementStatusClass(service) {
|
||||
if (service.songs_total_count === 0) {
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
if (service.songs_arranged_count === service.songs_total_count) {
|
||||
return 'text-emerald-700'
|
||||
}
|
||||
|
||||
return 'text-orange-600'
|
||||
}
|
||||
|
||||
function stateIconClass(isDone) {
|
||||
return isDone ? 'text-emerald-600' : 'text-red-600'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head title="Services" />
|
||||
|
||||
<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">Services</h2>
|
||||
<p class="text-sm text-gray-500">Hier siehst du alle heutigen und kommenden Services.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="py-8">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
role="status"
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm">
|
||||
<div v-if="services.length === 0" class="p-8 text-center text-sm text-gray-500">
|
||||
Aktuell gibt es keine heutigen oder kommenden Services.
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<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">Prediger</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Beamer-Techniker</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Anzahl Songs</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Letzte Änderung</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">Status</th>
|
||||
<th class="px-4 py-3 text-left 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="service in services" :key="service.id" class="align-top hover:bg-gray-50/60">
|
||||
<td class="px-4 py-4">
|
||||
<div class="font-medium text-gray-900">{{ service.title }}</div>
|
||||
<div class="mt-1 text-xs text-gray-500">{{ formatDate(service.date) }}</div>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4 text-sm text-gray-700">
|
||||
{{ service.preacher_name || '-' }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4 text-sm text-gray-700">
|
||||
{{ service.beamer_tech_name || '-' }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4 text-sm text-gray-700">
|
||||
{{ service.songs_total_count }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4 text-sm text-gray-700">
|
||||
{{ formatDateTime(service.updated_at) }}
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4">
|
||||
<div class="space-y-1.5 text-xs font-medium">
|
||||
<div :class="mappingStatusClass(service)">
|
||||
{{ service.songs_mapped_count }}/{{ service.songs_total_count }} Songs zugeordnet
|
||||
</div>
|
||||
|
||||
<div :class="arrangementStatusClass(service)">
|
||||
{{ service.songs_arranged_count }}/{{ service.songs_total_count }} Arrangements geprueft
|
||||
</div>
|
||||
|
||||
<div :class="stateIconClass(service.has_sermon_slides)" class="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{{ service.has_sermon_slides ? '✓' : '•' }}</span>
|
||||
<span>Predigtfolien</span>
|
||||
</div>
|
||||
|
||||
<div :class="stateIconClass(service.info_slides_count > 0)" class="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{{ service.info_slides_count > 0 ? '✓' : '•' }}</span>
|
||||
<span>{{ service.info_slides_count }} Infofolien</span>
|
||||
</div>
|
||||
|
||||
<div :class="stateIconClass(Boolean(service.finalized_at))" class="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{{ service.finalized_at ? '✓' : '•' }}</span>
|
||||
<span>
|
||||
Abgeschlossen am
|
||||
{{ service.finalized_at ? formatDateTime(service.finalized_at) : '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="px-4 py-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<template v-if="service.finalized_at">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 transition hover:bg-amber-100"
|
||||
@click="reopenService(service.id)"
|
||||
>
|
||||
Wieder öffnen
|
||||
</button>
|
||||
<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"
|
||||
@click="showComingSoon"
|
||||
>
|
||||
Herunterladen
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<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"
|
||||
@click="showComingSoon"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<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"
|
||||
@click="finalizeService(service.id)"
|
||||
>
|
||||
Abschließen
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
</template>
|
||||
39
routes/api.php
Normal file
39
routes/api.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\ServiceSongController;
|
||||
use App\Http\Controllers\SongController;
|
||||
use App\Http\Controllers\TranslationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| API Routen
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Alle API-Routen sind authentifizierungspflichtig und verwenden
|
||||
| das Prefix /api automatisch durch die Laravel API-Routing-Konvention.
|
||||
|
|
||||
*/
|
||||
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::apiResource('songs', SongController::class);
|
||||
|
||||
Route::post('/service-songs/{serviceSongId}/assign', [ServiceSongController::class, 'assignSong'])
|
||||
->name('api.service-songs.assign');
|
||||
|
||||
Route::post('/service-songs/{serviceSongId}/request', [ServiceSongController::class, 'requestSong'])
|
||||
->name('api.service-songs.request');
|
||||
|
||||
Route::post('/service-songs/{serviceSongId}/unassign', [ServiceSongController::class, 'unassign'])
|
||||
->name('api.service-songs.unassign');
|
||||
|
||||
// Übersetzung
|
||||
Route::post('/translation/fetch-url', [TranslationController::class, 'fetchUrl'])
|
||||
->name('api.translation.fetch-url');
|
||||
|
||||
Route::post('/songs/{song}/translation/import', [TranslationController::class, 'import'])
|
||||
->name('api.songs.translation.import');
|
||||
|
||||
Route::delete('/songs/{song}/translation', [TranslationController::class, 'destroy'])
|
||||
->name('api.songs.translation.destroy');
|
||||
});
|
||||
|
|
@ -1,35 +1,23 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AuthController;
|
||||
use App\Http\Controllers\ArrangementController;
|
||||
use App\Http\Controllers\ServiceController;
|
||||
use App\Http\Controllers\SlideController;
|
||||
use App\Http\Controllers\SyncController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Inertia\Inertia;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentifizierung (öffentlich)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::middleware('guest')->group(function () {
|
||||
Route::get('/login', [AuthController::class, 'showLogin'])->name('login');
|
||||
Route::get('/auth/churchtools', [AuthController::class, 'redirect'])->name('auth.churchtools');
|
||||
Route::get('/auth/churchtools/callback', [AuthController::class, 'callback'])->name('auth.churchtools.callback');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Abmeldung
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::post('/logout', [AuthController::class, 'logout'])
|
||||
->middleware('auth')
|
||||
->name('logout');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Geschützte Routen (nur für angemeldete Benutzer)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/', function () {
|
||||
return redirect()->route('dashboard');
|
||||
|
|
@ -39,5 +27,23 @@
|
|||
return Inertia::render('Dashboard');
|
||||
})->name('dashboard');
|
||||
|
||||
Route::get('/services', [ServiceController::class, 'index'])->name('services.index');
|
||||
Route::post('/services/{service}/finalize', [ServiceController::class, 'finalize'])->name('services.finalize');
|
||||
Route::post('/services/{service}/reopen', [ServiceController::class, 'reopen'])->name('services.reopen');
|
||||
|
||||
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::put('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@update')->name('arrangements.update');
|
||||
Route::delete('/arrangements/{arrangement}', '\\App\\Http\\Controllers\\ArrangementController@destroy')->name('arrangements.destroy');
|
||||
|
||||
Route::post('/sync', [SyncController::class, 'sync'])->name('sync');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Folien-Verwaltung
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
Route::post('/slides', '\\App\\Http\\Controllers\\SlideController@store')->name('slides.store');
|
||||
Route::delete('/slides/{slide}', '\\App\\Http\\Controllers\\SlideController@destroy')->name('slides.destroy');
|
||||
Route::patch('/slides/{slide}/expire-date', '\\App\\Http\\Controllers\\SlideController@updateExpireDate')->name('slides.update-expire-date');
|
||||
});
|
||||
|
|
|
|||
183
tests/Feature/ArrangementControllerTest.php
Normal file
183
tests/Feature/ArrangementControllerTest.php
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ArrangementControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_arrangement_clones_groups_from_default_arrangement(): void
|
||||
{
|
||||
[$song, $normal] = $this->createSongWithDefaultArrangement();
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
$response = $this->post(route('arrangements.store', $song), [
|
||||
'name' => 'Kurz',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$newArrangement = SongArrangement::query()
|
||||
->where('song_id', $song->id)
|
||||
->where('name', 'Kurz')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($newArrangement);
|
||||
|
||||
$normalGroups = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$newGroups = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $newArrangement->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($normalGroups, $newGroups);
|
||||
}
|
||||
|
||||
public function test_clone_arrangement_duplicates_current_arrangement_groups(): void
|
||||
{
|
||||
[$song, $normal] = $this->createSongWithDefaultArrangement();
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
$response = $this->post(route('arrangements.clone', $normal), [
|
||||
'name' => 'Normal Kopie',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$clone = SongArrangement::query()
|
||||
->where('song_id', $song->id)
|
||||
->where('name', 'Normal Kopie')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($clone);
|
||||
$this->assertFalse($clone->is_default);
|
||||
|
||||
$originalGroups = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$cloneGroups = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $clone->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame($originalGroups, $cloneGroups);
|
||||
}
|
||||
|
||||
public function test_update_arrangement_reorders_and_persists_groups(): void
|
||||
{
|
||||
[, $normal, $verse, $chorus, $bridge] = $this->createSongWithDefaultArrangement();
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
$response = $this->put(route('arrangements.update', $normal), [
|
||||
'groups' => [
|
||||
['song_group_id' => $chorus->id, 'order' => 1],
|
||||
['song_group_id' => $bridge->id, 'order' => 2],
|
||||
['song_group_id' => $verse->id, 'order' => 3],
|
||||
['song_group_id' => $chorus->id, 'order' => 4],
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
|
||||
$updated = SongArrangementGroup::query()
|
||||
->where('song_arrangement_id', $normal->id)
|
||||
->orderBy('order')
|
||||
->pluck('song_group_id')
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
$chorus->id,
|
||||
$bridge->id,
|
||||
$verse->id,
|
||||
$chorus->id,
|
||||
], $updated);
|
||||
}
|
||||
|
||||
public function test_cannot_delete_the_last_arrangement_of_a_song(): void
|
||||
{
|
||||
[$song, $normal] = $this->createSongWithDefaultArrangement();
|
||||
$user = User::factory()->create();
|
||||
Auth::login($user);
|
||||
|
||||
$this->assertSame(1, $song->arrangements()->count());
|
||||
|
||||
$response = $this->delete(route('arrangements.destroy', $normal));
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHas('error', 'Das letzte Arrangement kann nicht gelöscht werden.');
|
||||
|
||||
$this->assertTrue(SongArrangement::query()->whereKey($normal->id)->exists());
|
||||
$this->assertSame(1, $song->arrangements()->count());
|
||||
}
|
||||
|
||||
private function createSongWithDefaultArrangement(): array
|
||||
{
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$verse = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Verse 1',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$chorus = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Chorus',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$bridge = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Bridge',
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
$normal = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Normal',
|
||||
'is_default' => true,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $verse->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $chorus->id,
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
SongArrangementGroup::factory()->create([
|
||||
'song_arrangement_id' => $normal->id,
|
||||
'song_group_id' => $verse->id,
|
||||
'order' => 3,
|
||||
]);
|
||||
|
||||
return [$song, $normal, $verse, $chorus, $bridge];
|
||||
}
|
||||
}
|
||||
179
tests/Feature/ServiceControllerTest.php
Normal file
179
tests/Feature/ServiceControllerTest.php
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
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;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ServiceControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_services_index_zeigt_nur_heutige_und_kuenftige_services_mit_statusdaten(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||
$this->withoutVite();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
Service::factory()->create([
|
||||
'date' => Carbon::today()->subDay(),
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
$todayService = Service::factory()->create([
|
||||
'title' => 'Gottesdienst Heute',
|
||||
'date' => Carbon::today(),
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
$futureService = Service::factory()->create([
|
||||
'title' => 'Gottesdienst Zukunft',
|
||||
'date' => Carbon::today()->addDays(3),
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
$song = Song::factory()->create();
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
]);
|
||||
|
||||
ServiceSong::create([
|
||||
'service_id' => $todayService->id,
|
||||
'song_id' => $song->id,
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'use_translation' => false,
|
||||
'order' => 1,
|
||||
'cts_song_name' => 'Song 1',
|
||||
'cts_ccli_id' => '100001',
|
||||
]);
|
||||
|
||||
ServiceSong::create([
|
||||
'service_id' => $todayService->id,
|
||||
'song_id' => $song->id,
|
||||
'song_arrangement_id' => null,
|
||||
'use_translation' => false,
|
||||
'order' => 2,
|
||||
'cts_song_name' => 'Song 2',
|
||||
'cts_ccli_id' => '100002',
|
||||
]);
|
||||
|
||||
ServiceSong::create([
|
||||
'service_id' => $todayService->id,
|
||||
'song_id' => null,
|
||||
'song_arrangement_id' => null,
|
||||
'use_translation' => false,
|
||||
'order' => 3,
|
||||
'cts_song_name' => 'Song 3',
|
||||
'cts_ccli_id' => '100003',
|
||||
]);
|
||||
|
||||
ServiceSong::create([
|
||||
'service_id' => $futureService->id,
|
||||
'song_id' => $song->id,
|
||||
'song_arrangement_id' => $arrangement->id,
|
||||
'use_translation' => false,
|
||||
'order' => 1,
|
||||
'cts_song_name' => 'Song 4',
|
||||
'cts_ccli_id' => '100004',
|
||||
]);
|
||||
|
||||
ServiceSong::create([
|
||||
'service_id' => $futureService->id,
|
||||
'song_id' => null,
|
||||
'song_arrangement_id' => null,
|
||||
'use_translation' => false,
|
||||
'order' => 2,
|
||||
'cts_song_name' => 'Song 5',
|
||||
'cts_ccli_id' => '100005',
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'service_id' => $todayService->id,
|
||||
'type' => 'sermon',
|
||||
'expire_date' => null,
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'service_id' => null,
|
||||
'type' => 'information',
|
||||
'expire_date' => Carbon::today()->addDay(),
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'service_id' => null,
|
||||
'type' => 'information',
|
||||
'expire_date' => Carbon::today()->addDays(5),
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'service_id' => $todayService->id,
|
||||
'type' => 'information',
|
||||
'expire_date' => Carbon::today()->addDays(10),
|
||||
]);
|
||||
|
||||
Slide::factory()->create([
|
||||
'service_id' => null,
|
||||
'type' => 'information',
|
||||
'expire_date' => Carbon::today()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('services.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(
|
||||
fn ($page) => $page
|
||||
->component('Services/Index')
|
||||
->has('services', 2)
|
||||
->where('services.0.id', $todayService->id)
|
||||
->where('services.0.title', 'Gottesdienst Heute')
|
||||
->where('services.0.songs_total_count', 3)
|
||||
->where('services.0.songs_mapped_count', 2)
|
||||
->where('services.0.songs_arranged_count', 1)
|
||||
->where('services.0.has_sermon_slides', true)
|
||||
->where('services.0.info_slides_count', 3)
|
||||
->where('services.1.id', $futureService->id)
|
||||
->where('services.1.title', 'Gottesdienst Zukunft')
|
||||
->where('services.1.songs_total_count', 2)
|
||||
->where('services.1.songs_mapped_count', 1)
|
||||
->where('services.1.songs_arranged_count', 1)
|
||||
->where('services.1.has_sermon_slides', false)
|
||||
->where('services.1.info_slides_count', 1)
|
||||
);
|
||||
}
|
||||
|
||||
public function test_service_kann_abgeschlossen_werden(): void
|
||||
{
|
||||
Carbon::setTestNow('2026-03-01 10:00:00');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create([
|
||||
'finalized_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('services.finalize', $service));
|
||||
|
||||
$response->assertRedirect(route('services.index'));
|
||||
$this->assertSame(now()->toDateTimeString(), $service->fresh()->finalized_at?->toDateTimeString());
|
||||
}
|
||||
|
||||
public function test_service_kann_wieder_geoeffnet_werden(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$service = Service::factory()->create([
|
||||
'finalized_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->post(route('services.reopen', $service));
|
||||
|
||||
$response->assertRedirect(route('services.index'));
|
||||
$this->assertNull($service->fresh()->finalized_at);
|
||||
}
|
||||
}
|
||||
300
tests/Feature/SlideControllerTest.php
Normal file
300
tests/Feature/SlideControllerTest.php
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\Slide;
|
||||
use App\Models\User;
|
||||
use App\Services\FileConversionService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
Queue::fake();
|
||||
$this->user = User::factory()->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Upload (store)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('upload image creates slide with 1920x1080 jpg', function () {
|
||||
$service = Service::factory()->create();
|
||||
$file = makePngUploadForSlide('test-slide.png', 800, 600);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'information',
|
||||
'service_id' => $service->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
$slide = Slide::first();
|
||||
expect($slide)->not->toBeNull();
|
||||
expect($slide->type)->toBe('information');
|
||||
expect($slide->service_id)->toBe($service->id);
|
||||
expect($slide->uploader_name)->toBe($this->user->name);
|
||||
expect($slide->original_filename)->toBe('test-slide.png');
|
||||
expect(Storage::disk('public')->exists($slide->stored_filename))->toBeTrue();
|
||||
expect(Storage::disk('public')->exists($slide->thumbnail_filename))->toBeTrue();
|
||||
});
|
||||
|
||||
test('upload image with expire_date stores date on slide', function () {
|
||||
$service = Service::factory()->create();
|
||||
$file = makePngUploadForSlide('info.png', 400, 300);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'information',
|
||||
'service_id' => $service->id,
|
||||
'expire_date' => '2026-06-15',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$slide = Slide::first();
|
||||
expect($slide->expire_date->format('Y-m-d'))->toBe('2026-06-15');
|
||||
});
|
||||
|
||||
test('upload moderation slide without service_id fails', function () {
|
||||
$file = makePngUploadForSlide('mod.png', 400, 300);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'moderation',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload information slide without service_id is allowed', function () {
|
||||
$file = makePngUploadForSlide('info.png', 400, 300);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'information',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect(Slide::first()->service_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('upload rejects unsupported file types', function () {
|
||||
$file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf');
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'information',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload rejects invalid type', function () {
|
||||
$file = makePngUploadForSlide('test.png', 400, 300);
|
||||
|
||||
$response = $this->postJson(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'invalid_type',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('upload pptx dispatches conversion job', function () {
|
||||
// Mock the conversion service to return a job ID
|
||||
$mockService = Mockery::mock(FileConversionService::class);
|
||||
$mockService->shouldReceive('convertPowerPoint')
|
||||
->once()
|
||||
->andReturn('test-job-uuid-1234');
|
||||
app()->instance(FileConversionService::class, $mockService);
|
||||
|
||||
// Create a real file with .pptx extension
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'cts-pptx-');
|
||||
$pptxPath = $tempPath . '.pptx';
|
||||
file_put_contents($pptxPath, str_repeat('x', 1024));
|
||||
$file = new UploadedFile($pptxPath, 'slides.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', null, true);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'sermon',
|
||||
'service_id' => Service::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure(['success', 'job_id']);
|
||||
$response->assertJson(['job_id' => 'test-job-uuid-1234']);
|
||||
|
||||
// Cleanup
|
||||
@unlink($pptxPath);
|
||||
@unlink($tempPath);
|
||||
});
|
||||
|
||||
test('upload zip processes contained images', function () {
|
||||
// Create a small zip with a valid image
|
||||
$tempDir = sys_get_temp_dir() . '/cts-zip-test-' . uniqid();
|
||||
mkdir($tempDir, 0775, true);
|
||||
|
||||
// Create an image inside
|
||||
$imgPath = $tempDir . '/slide1.png';
|
||||
$image = imagecreatetruecolor(200, 150);
|
||||
$blue = imagecolorallocate($image, 0, 0, 255);
|
||||
imagefill($image, 0, 0, $blue);
|
||||
imagepng($image, $imgPath);
|
||||
imagedestroy($image);
|
||||
|
||||
// Build zip
|
||||
$zipPath = $tempDir . '/slides.zip';
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath, ZipArchive::CREATE);
|
||||
$zip->addFile($imgPath, 'slide1.png');
|
||||
$zip->close();
|
||||
|
||||
$file = new UploadedFile($zipPath, 'slides.zip', 'application/zip', null, true);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'moderation',
|
||||
'service_id' => Service::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
// Cleanup
|
||||
@unlink($imgPath);
|
||||
@unlink($zipPath);
|
||||
@rmdir($tempDir);
|
||||
});
|
||||
|
||||
test('unauthenticated user cannot upload slides', function () {
|
||||
auth()->logout();
|
||||
$file = makePngUploadForSlide('test.png', 400, 300);
|
||||
|
||||
$response = $this->post(route('slides.store'), [
|
||||
'file' => $file,
|
||||
'type' => 'information',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Soft Delete (destroy)
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('delete slide soft deletes it', function () {
|
||||
$slide = Slide::factory()->create([
|
||||
'service_id' => Service::factory()->create()->id,
|
||||
'uploader_name' => $this->user->name,
|
||||
]);
|
||||
|
||||
$response = $this->delete(route('slides.destroy', $slide));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['success' => true]);
|
||||
|
||||
expect(Slide::find($slide->id))->toBeNull();
|
||||
expect(Slide::withTrashed()->find($slide->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('delete non-existing slide returns 404', function () {
|
||||
$response = $this->delete(route('slides.destroy', 99999));
|
||||
|
||||
$response->assertStatus(404);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Update Expire Date
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('update expire date on information slide', function () {
|
||||
$slide = Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'expire_date' => '2026-04-01',
|
||||
'service_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->patch(route('slides.update-expire-date', $slide), [
|
||||
'expire_date' => '2026-08-15',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJson(['success' => true]);
|
||||
expect($slide->fresh()->expire_date->format('Y-m-d'))->toBe('2026-08-15');
|
||||
});
|
||||
|
||||
test('update expire date rejects non-information slides', function () {
|
||||
$slide = Slide::factory()->create([
|
||||
'type' => 'sermon',
|
||||
'service_id' => Service::factory()->create()->id,
|
||||
]);
|
||||
|
||||
$response = $this->patch(route('slides.update-expire-date', $slide), [
|
||||
'expire_date' => '2026-08-15',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('expire date must be a valid date', function () {
|
||||
$slide = Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'service_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->patchJson(route('slides.update-expire-date', $slide), [
|
||||
'expire_date' => 'not-a-date',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
});
|
||||
|
||||
test('expire date can be set to null', function () {
|
||||
$slide = Slide::factory()->create([
|
||||
'type' => 'information',
|
||||
'expire_date' => '2026-04-01',
|
||||
'service_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->patch(route('slides.update-expire-date', $slide), [
|
||||
'expire_date' => null,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
expect($slide->fresh()->expire_date)->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
function makePngUploadForSlide(string $name, int $width, int $height): UploadedFile
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'cts-slide-');
|
||||
if ($path === false) {
|
||||
throw new RuntimeException('Temp-Datei konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$image = imagecreatetruecolor($width, $height);
|
||||
if ($image === false) {
|
||||
throw new RuntimeException('Bild konnte nicht erstellt werden.');
|
||||
}
|
||||
|
||||
$red = imagecolorallocate($image, 255, 0, 0);
|
||||
imagefill($image, 0, 0, $red);
|
||||
imagepng($image, $path);
|
||||
imagedestroy($image);
|
||||
|
||||
return new UploadedFile($path, $name, 'image/png', null, true);
|
||||
}
|
||||
309
tests/Feature/SongControllerTest.php
Normal file
309
tests/Feature/SongControllerTest.php
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Models\SongArrangement;
|
||||
use App\Models\SongArrangementGroup;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\User;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Song CRUD API Tests
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
});
|
||||
|
||||
// --- INDEX / LIST ---
|
||||
|
||||
test('songs index returns paginated list', function () {
|
||||
Song::factory()->count(3)->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/songs');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [['id', 'title', 'ccli_id', 'author', 'has_translation']],
|
||||
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||
]);
|
||||
expect($response->json('meta.total'))->toBe(3);
|
||||
});
|
||||
|
||||
test('songs index excludes 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 index search by title', function () {
|
||||
Song::factory()->create(['title' => 'Amazing Grace']);
|
||||
Song::factory()->create(['title' => 'Holy Spirit']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/songs?search=Amazing');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
expect($response->json('data.0.title'))->toBe('Amazing Grace');
|
||||
});
|
||||
|
||||
test('songs index search by ccli id', function () {
|
||||
Song::factory()->create(['ccli_id' => '123456', 'title' => 'Song A']);
|
||||
Song::factory()->create(['ccli_id' => '789012', 'title' => 'Song B']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/songs?search=123456');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('meta.total'))->toBe(1);
|
||||
expect($response->json('data.0.ccli_id'))->toBe('123456');
|
||||
});
|
||||
|
||||
test('songs index requires authentication', function () {
|
||||
$response = $this->getJson('/api/songs');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
||||
// --- STORE / CREATE ---
|
||||
|
||||
test('store creates song with default groups and arrangement', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/songs', [
|
||||
'title' => 'Neues Lied',
|
||||
'ccli_id' => '999999',
|
||||
'author' => 'Test Author',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonFragment(['message' => 'Song erfolgreich erstellt']);
|
||||
|
||||
$song = Song::where('title', 'Neues Lied')->first();
|
||||
expect($song)->not->toBeNull();
|
||||
|
||||
// Default groups: Strophe 1, Refrain, Bridge
|
||||
expect($song->groups)->toHaveCount(3);
|
||||
expect($song->groups->pluck('name')->toArray())
|
||||
->toBe(['Strophe 1', 'Refrain', 'Bridge']);
|
||||
|
||||
// Default "Normal" arrangement
|
||||
$arrangement = $song->arrangements()->where('is_default', true)->first();
|
||||
expect($arrangement)->not->toBeNull();
|
||||
expect($arrangement->name)->toBe('Normal');
|
||||
expect($arrangement->arrangementGroups)->toHaveCount(3);
|
||||
});
|
||||
|
||||
test('store validates required title', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/songs', [
|
||||
'ccli_id' => '111111',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['title']);
|
||||
});
|
||||
|
||||
test('store validates unique ccli_id', function () {
|
||||
Song::factory()->create(['ccli_id' => '555555']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/songs', [
|
||||
'title' => 'Duplicate Song',
|
||||
'ccli_id' => '555555',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['ccli_id']);
|
||||
});
|
||||
|
||||
test('store allows null ccli_id', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/songs', [
|
||||
'title' => 'Song ohne CCLI',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
expect(Song::where('title', 'Song ohne CCLI')->first()->ccli_id)->toBeNull();
|
||||
});
|
||||
|
||||
// --- SHOW ---
|
||||
|
||||
test('show returns song with groups slides and arrangements', function () {
|
||||
$song = Song::factory()->create();
|
||||
$group = SongGroup::factory()->create(['song_id' => $song->id, 'name' => 'Strophe 1']);
|
||||
SongArrangement::factory()->create(['song_id' => $song->id, 'name' => 'Normal', 'is_default' => true]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson("/api/songs/{$song->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'id', 'title', 'ccli_id', 'author', 'copyright_text',
|
||||
'has_translation', 'last_used_in_service',
|
||||
'groups' => [['id', 'name', 'color', 'order', 'slides']],
|
||||
'arrangements' => [['id', 'name', 'is_default', 'arrangement_groups']],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('show returns 404 for nonexistent song', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson('/api/songs/99999');
|
||||
|
||||
$response->assertNotFound()
|
||||
->assertJsonFragment(['message' => 'Song nicht gefunden']);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
test('update modifies song metadata', function () {
|
||||
$song = Song::factory()->create(['title' => 'Old Title']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->putJson("/api/songs/{$song->id}", [
|
||||
'title' => 'New Title',
|
||||
'author' => 'New Author',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['message' => 'Song erfolgreich aktualisiert']);
|
||||
|
||||
$song->refresh();
|
||||
expect($song->title)->toBe('New Title');
|
||||
expect($song->author)->toBe('New Author');
|
||||
});
|
||||
|
||||
test('update validates unique ccli_id excluding self', function () {
|
||||
$songA = Song::factory()->create(['ccli_id' => '111111']);
|
||||
$songB = Song::factory()->create(['ccli_id' => '222222']);
|
||||
|
||||
// Try setting songB's ccli_id to songA's
|
||||
$response = $this->actingAs($this->user)
|
||||
->putJson("/api/songs/{$songB->id}", [
|
||||
'title' => $songB->title,
|
||||
'ccli_id' => '111111',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['ccli_id']);
|
||||
});
|
||||
|
||||
test('update allows keeping own ccli_id', function () {
|
||||
$song = Song::factory()->create(['ccli_id' => '333333']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->putJson("/api/songs/{$song->id}", [
|
||||
'title' => 'Updated Title',
|
||||
'ccli_id' => '333333',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
// --- DESTROY / SOFT DELETE ---
|
||||
|
||||
test('destroy soft-deletes a song', function () {
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->deleteJson("/api/songs/{$song->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['message' => 'Song erfolgreich gelöscht']);
|
||||
|
||||
expect(Song::find($song->id))->toBeNull();
|
||||
expect(Song::withTrashed()->find($song->id))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('destroy returns 404 for nonexistent song', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->deleteJson('/api/songs/99999');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// --- LAST USED IN SERVICE ---
|
||||
|
||||
test('last_used_in_service returns correct date from service_songs', function () {
|
||||
$song = Song::factory()->create();
|
||||
$serviceOld = Service::factory()->create(['date' => '2025-06-01']);
|
||||
$serviceNew = Service::factory()->create(['date' => '2026-01-15']);
|
||||
|
||||
ServiceSong::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'service_id' => $serviceOld->id,
|
||||
]);
|
||||
ServiceSong::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'service_id' => $serviceNew->id,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson("/api/songs/{$song->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.last_used_in_service'))->toBe('2026-01-15');
|
||||
});
|
||||
|
||||
test('last_used_in_service returns null when never used', function () {
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson("/api/songs/{$song->id}");
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('data.last_used_in_service'))->toBeNull();
|
||||
});
|
||||
|
||||
// --- SONG SERVICE: DUPLICATE ARRANGEMENT ---
|
||||
|
||||
test('duplicate arrangement clones arrangement with groups', function () {
|
||||
$song = Song::factory()->create();
|
||||
$group1 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 1]);
|
||||
$group2 = SongGroup::factory()->create(['song_id' => $song->id, 'order' => 2]);
|
||||
|
||||
$arrangement = SongArrangement::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Original',
|
||||
'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,
|
||||
]);
|
||||
|
||||
$service = app(\App\Services\SongService::class);
|
||||
$clone = $service->duplicateArrangement($arrangement, 'Klone');
|
||||
|
||||
expect($clone->name)->toBe('Klone');
|
||||
expect($clone->is_default)->toBeFalse();
|
||||
expect($clone->arrangementGroups)->toHaveCount(2);
|
||||
expect($clone->arrangementGroups->pluck('song_group_id')->toArray())
|
||||
->toBe($arrangement->arrangementGroups->pluck('song_group_id')->toArray());
|
||||
});
|
||||
250
tests/Feature/SongMatchingTest.php
Normal file
250
tests/Feature/SongMatchingTest.php
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
|
||||
use App\Mail\MissingSongRequest;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceSong;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Services\SongMatchingService;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SongMatchingService — Unit-level feature tests
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('autoMatch ordnet Song per CCLI-ID zu', function () {
|
||||
$song = Song::factory()->create(['ccli_id' => '7115744']);
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => '7115744',
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$result = $service->autoMatch($serviceSong);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('autoMatch gibt false zurück wenn kein CCLI-ID vorhanden', function () {
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => null,
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$result = $service->autoMatch($serviceSong);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('autoMatch gibt false zurück wenn kein passender Song in DB', function () {
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => '9999999',
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$result = $service->autoMatch($serviceSong);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('autoMatch überspringt bereits zugeordnete Songs', function () {
|
||||
$existingSong = Song::factory()->create(['ccli_id' => '7115744']);
|
||||
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_ccli_id' => '7115744',
|
||||
'song_id' => $existingSong->id,
|
||||
'matched_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$result = $service->autoMatch($serviceSong);
|
||||
|
||||
expect($result)->toBeFalse();
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($existingSong->id);
|
||||
});
|
||||
|
||||
test('manualAssign ordnet Song manuell zu', function () {
|
||||
$song = Song::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$service->manualAssign($serviceSong, $song);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('manualAssign überschreibt bestehende Zuordnung', function () {
|
||||
$oldSong = Song::factory()->create();
|
||||
$newSong = Song::factory()->create();
|
||||
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => $oldSong->id,
|
||||
'matched_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$service->manualAssign($serviceSong, $newSong);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($newSong->id);
|
||||
expect($serviceSong->matched_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('requestCreation sendet E-Mail und setzt request_sent_at', function () {
|
||||
Mail::fake();
|
||||
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_song_name' => 'Großer Gott',
|
||||
'cts_ccli_id' => '12345678',
|
||||
'request_sent_at' => null,
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$service->requestCreation($serviceSong);
|
||||
|
||||
Mail::assertSent(MissingSongRequest::class, function (MissingSongRequest $mail) {
|
||||
return $mail->songName === 'Großer Gott'
|
||||
&& $mail->ccliId === '12345678';
|
||||
});
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->request_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('unassign entfernt Zuordnung', function () {
|
||||
$song = Song::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(SongMatchingService::class);
|
||||
$service->unassign($serviceSong);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBeNull();
|
||||
expect($serviceSong->matched_at)->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| ServiceSongController — API endpoint tests
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
test('POST /api/service-songs/{id}/assign ordnet Song zu', function () {
|
||||
$user = User::factory()->create();
|
||||
$song = Song::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => null,
|
||||
'matched_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/service-songs/{$serviceSong->id}/assign", [
|
||||
'song_id' => $song->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Song erfolgreich zugeordnet']);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBe($song->id);
|
||||
});
|
||||
|
||||
test('POST /api/service-songs/{id}/assign validiert song_id', function () {
|
||||
$user = User::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/service-songs/{$serviceSong->id}/assign", [
|
||||
'song_id' => 99999,
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('POST /api/service-songs/{id}/request sendet Anfrage-E-Mail', function () {
|
||||
Mail::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'cts_song_name' => 'Way Maker',
|
||||
'cts_ccli_id' => '7115744',
|
||||
'request_sent_at' => null,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/service-songs/{$serviceSong->id}/request");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Anfrage wurde gesendet']);
|
||||
|
||||
Mail::assertSent(MissingSongRequest::class);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->request_sent_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('POST /api/service-songs/{id}/unassign entfernt Zuordnung', function () {
|
||||
$user = User::factory()->create();
|
||||
$song = Song::factory()->create();
|
||||
$serviceSong = ServiceSong::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'matched_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/service-songs/{$serviceSong->id}/unassign");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['message' => 'Zuordnung entfernt']);
|
||||
|
||||
$serviceSong->refresh();
|
||||
expect($serviceSong->song_id)->toBeNull();
|
||||
expect($serviceSong->matched_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('API Endpunkte erfordern Authentifizierung', function () {
|
||||
$serviceSong = ServiceSong::factory()->create();
|
||||
|
||||
$this->postJson("/api/service-songs/{$serviceSong->id}/assign", ['song_id' => 1])
|
||||
->assertUnauthorized();
|
||||
|
||||
$this->postJson("/api/service-songs/{$serviceSong->id}/request")
|
||||
->assertUnauthorized();
|
||||
|
||||
$this->postJson("/api/service-songs/{$serviceSong->id}/unassign")
|
||||
->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('API gibt 404 für nicht existierende ServiceSong', function () {
|
||||
$user = User::factory()->create();
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->postJson('/api/service-songs/99999/assign', ['song_id' => $song->id])
|
||||
->assertNotFound();
|
||||
});
|
||||
376
tests/Feature/TranslationServiceTest.php
Normal file
376
tests/Feature/TranslationServiceTest.php
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\SongGroup;
|
||||
use App\Models\SongSlide;
|
||||
use App\Models\User;
|
||||
use App\Services\TranslationService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Translation Service Tests
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->service = app(TranslationService::class);
|
||||
});
|
||||
|
||||
// --- URL FETCH ---
|
||||
|
||||
test('fetchFromUrl returns text from successful HTTP response', function () {
|
||||
Http::fake([
|
||||
'https://example.com/lyrics' => Http::response('<html><body><p>Zeile 1</p><p>Zeile 2</p></body></html>', 200),
|
||||
]);
|
||||
|
||||
$result = $this->service->fetchFromUrl('https://example.com/lyrics');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result)->toContain('Zeile 1');
|
||||
expect($result)->toContain('Zeile 2');
|
||||
// HTML tags should be stripped
|
||||
expect($result)->not->toContain('<p>');
|
||||
expect($result)->not->toContain('<html>');
|
||||
});
|
||||
|
||||
test('fetchFromUrl returns null on HTTP failure', function () {
|
||||
Http::fake([
|
||||
'https://example.com/broken' => Http::response('Not Found', 404),
|
||||
]);
|
||||
|
||||
$result = $this->service->fetchFromUrl('https://example.com/broken');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('fetchFromUrl returns null on connection error', function () {
|
||||
Http::fake([
|
||||
'https://timeout.example.com/*' => fn () => throw new \Illuminate\Http\Client\ConnectionException('Timeout'),
|
||||
]);
|
||||
|
||||
$result = $this->service->fetchFromUrl('https://timeout.example.com/lyrics');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
test('fetchFromUrl returns null for empty response body', function () {
|
||||
Http::fake([
|
||||
'https://example.com/empty' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$result = $this->service->fetchFromUrl('https://example.com/empty');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
// --- IMPORT TRANSLATION (LINE-COUNT DISTRIBUTION) ---
|
||||
|
||||
test('importTranslation distributes lines by slide line counts', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Strophe 1',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
// Slide 1: 4 lines
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Original 1\nOriginal 2\nOriginal 3\nOriginal 4",
|
||||
]);
|
||||
|
||||
// Slide 2: 2 lines
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Original 5\nOriginal 6",
|
||||
]);
|
||||
|
||||
// Slide 3: 4 lines
|
||||
$slide3 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 3,
|
||||
'text_content' => "Original 7\nOriginal 8\nOriginal 9\nOriginal 10",
|
||||
]);
|
||||
|
||||
$translatedText = "Zeile 1\nZeile 2\nZeile 3\nZeile 4\nZeile 5\nZeile 6\nZeile 7\nZeile 8\nZeile 9\nZeile 10";
|
||||
|
||||
$this->service->importTranslation($song, $translatedText);
|
||||
|
||||
$slide1->refresh();
|
||||
$slide2->refresh();
|
||||
$slide3->refresh();
|
||||
|
||||
// Slide 1 gets lines 1-4
|
||||
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3\nZeile 4");
|
||||
// Slide 2 gets lines 5-6
|
||||
expect($slide2->text_content_translated)->toBe("Zeile 5\nZeile 6");
|
||||
// Slide 3 gets lines 7-10
|
||||
expect($slide3->text_content_translated)->toBe("Zeile 7\nZeile 8\nZeile 9\nZeile 10");
|
||||
});
|
||||
|
||||
test('importTranslation distributes across multiple groups', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$group1 = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Strophe 1',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$group2 = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'name' => 'Refrain',
|
||||
'order' => 2,
|
||||
]);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group1->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line A\nLine B",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group2->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line C\nLine D\nLine E",
|
||||
]);
|
||||
|
||||
$translatedText = "Über A\nÜber B\nÜber C\nÜber D\nÜber E";
|
||||
|
||||
$this->service->importTranslation($song, $translatedText);
|
||||
|
||||
$slide1->refresh();
|
||||
$slide2->refresh();
|
||||
|
||||
expect($slide1->text_content_translated)->toBe("Über A\nÜber B");
|
||||
expect($slide2->text_content_translated)->toBe("Über C\nÜber D\nÜber E");
|
||||
});
|
||||
|
||||
test('importTranslation handles fewer translation lines than original', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2\nLine 3",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Line 4\nLine 5",
|
||||
]);
|
||||
|
||||
// Only 3 lines for 5 lines total
|
||||
$translatedText = "Zeile 1\nZeile 2\nZeile 3";
|
||||
|
||||
$this->service->importTranslation($song, $translatedText);
|
||||
|
||||
$slide1->refresh();
|
||||
$slide2->refresh();
|
||||
|
||||
// Slide 1 gets all 3 available lines
|
||||
expect($slide1->text_content_translated)->toBe("Zeile 1\nZeile 2\nZeile 3");
|
||||
// Slide 2 gets empty (no lines left)
|
||||
expect($slide2->text_content_translated)->toBe('');
|
||||
});
|
||||
|
||||
test('importTranslation marks song as translated', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1",
|
||||
]);
|
||||
|
||||
$this->service->importTranslation($song, "Zeile 1");
|
||||
|
||||
$song->refresh();
|
||||
expect($song->has_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
// --- MARK AS TRANSLATED ---
|
||||
|
||||
test('markAsTranslated sets has_translation to true', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$this->service->markAsTranslated($song);
|
||||
|
||||
$song->refresh();
|
||||
expect($song->has_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
// --- REMOVE TRANSLATION ---
|
||||
|
||||
test('removeTranslation clears all translated text and sets flag to false', function () {
|
||||
$song = Song::factory()->create(['has_translation' => true]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$slide1 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Original",
|
||||
'text_content_translated' => "Übersetzt",
|
||||
]);
|
||||
|
||||
$slide2 = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 2,
|
||||
'text_content' => "Original 2",
|
||||
'text_content_translated' => "Übersetzt 2",
|
||||
]);
|
||||
|
||||
$this->service->removeTranslation($song);
|
||||
|
||||
$song->refresh();
|
||||
$slide1->refresh();
|
||||
$slide2->refresh();
|
||||
|
||||
expect($song->has_translation)->toBeFalse();
|
||||
expect($slide1->text_content_translated)->toBeNull();
|
||||
expect($slide2->text_content_translated)->toBeNull();
|
||||
});
|
||||
|
||||
// --- CONTROLLER ENDPOINTS ---
|
||||
|
||||
test('POST translation/fetch-url returns scraped text', function () {
|
||||
Http::fake([
|
||||
'https://lyrics.example.com/song' => Http::response('<div>Liedtext Zeile 1</div>', 200),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/translation/fetch-url', [
|
||||
'url' => 'https://lyrics.example.com/song',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['text']);
|
||||
|
||||
expect($response->json('text'))->toContain('Liedtext Zeile 1');
|
||||
});
|
||||
|
||||
test('POST translation/fetch-url returns error on failure', function () {
|
||||
Http::fake([
|
||||
'https://broken.example.com/*' => Http::response('', 500),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/translation/fetch-url', [
|
||||
'url' => 'https://broken.example.com/song',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonFragment(['message' => 'Konnte Text nicht abrufen']);
|
||||
});
|
||||
|
||||
test('POST translation/fetch-url validates url field', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/translation/fetch-url', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['url']);
|
||||
});
|
||||
|
||||
test('POST songs/{song}/translation/import distributes and saves translation', function () {
|
||||
$song = Song::factory()->create(['has_translation' => false]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$slide = SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Line 1\nLine 2",
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/api/songs/{$song->id}/translation/import", [
|
||||
'text' => "Zeile 1\nZeile 2",
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['message' => 'Übersetzung erfolgreich importiert']);
|
||||
|
||||
$slide->refresh();
|
||||
$song->refresh();
|
||||
|
||||
expect($slide->text_content_translated)->toBe("Zeile 1\nZeile 2");
|
||||
expect($song->has_translation)->toBeTrue();
|
||||
});
|
||||
|
||||
test('POST songs/{song}/translation/import validates text field', function () {
|
||||
$song = Song::factory()->create();
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson("/api/songs/{$song->id}/translation/import", []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['text']);
|
||||
});
|
||||
|
||||
test('POST songs/{song}/translation/import returns 404 for missing song', function () {
|
||||
$response = $this->actingAs($this->user)
|
||||
->postJson('/api/songs/99999/translation/import', [
|
||||
'text' => 'Some text',
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
test('DELETE songs/{song}/translation removes translation', function () {
|
||||
$song = Song::factory()->create(['has_translation' => true]);
|
||||
|
||||
$group = SongGroup::factory()->create([
|
||||
'song_id' => $song->id,
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
SongSlide::factory()->create([
|
||||
'song_group_id' => $group->id,
|
||||
'order' => 1,
|
||||
'text_content' => "Original",
|
||||
'text_content_translated' => "Übersetzt",
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->deleteJson("/api/songs/{$song->id}/translation");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonFragment(['message' => 'Übersetzung entfernt']);
|
||||
|
||||
$song->refresh();
|
||||
expect($song->has_translation)->toBeFalse();
|
||||
});
|
||||
|
||||
test('translation endpoints require authentication', function () {
|
||||
$this->postJson('/api/translation/fetch-url', ['url' => 'https://example.com'])
|
||||
->assertUnauthorized();
|
||||
|
||||
$this->postJson('/api/songs/1/translation/import', ['text' => 'test'])
|
||||
->assertUnauthorized();
|
||||
|
||||
$this->deleteJson('/api/songs/1/translation')
|
||||
->assertUnauthorized();
|
||||
});
|
||||
Loading…
Reference in a new issue