pp-planer/resources/js/Layouts/AuthenticatedLayout.vue
Thorsten Bus d915f8cfc2 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
2026-03-01 19:55:37 +01:00

307 lines
15 KiB
Vue

<script setup>
import { ref, computed } from 'vue'
import { Link, router, usePage } from '@inertiajs/vue3'
import Dropdown from '@/Components/Dropdown.vue'
import DropdownLink from '@/Components/DropdownLink.vue'
import NavLink from '@/Components/NavLink.vue'
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue'
import FlashMessage from '@/Components/FlashMessage.vue'
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
const page = usePage()
const showingNavigationDropdown = ref(false)
const syncing = ref(false)
const user = computed(() => page.props.auth?.user)
const lastSyncedAt = computed(() => page.props.last_synced_at)
const formattedSyncDate = computed(() => {
if (!lastSyncedAt.value) return null
const date = new Date(lastSyncedAt.value)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
})
const userInitials = computed(() => {
if (!user.value?.name) return '?'
return user.value.name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
})
function triggerSync() {
if (syncing.value) return
syncing.value = true
router.post(
'/sync',
{},
{
preserveScroll: true,
preserveState: true,
onFinish: () => {
syncing.value = false
},
},
)
}
</script>
<template>
<div class="min-h-screen bg-gray-50/80">
<FlashMessage />
<!-- Top Navigation Bar -->
<nav class="sticky top-0 z-50 border-b border-gray-200/80 bg-white/95 backdrop-blur-sm">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Left: Logo + Nav -->
<div class="flex items-center gap-1">
<!-- Logo / App Name -->
<Link
:href="route('dashboard')"
class="mr-6 flex items-center gap-2.5 transition-opacity hover:opacity-80"
>
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-orange-600 shadow-sm">
<svg class="h-4.5 w-4.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5" />
</svg>
</div>
<span class="hidden text-[15px] font-semibold tracking-tight text-gray-900 sm:block">
{{ $page.props.app_name || 'CTS Presenter' }}
</span>
</Link>
<!-- Desktop Navigation -->
<div class="hidden items-center gap-1 sm:flex">
<NavLink
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</NavLink>
<NavLink
v-if="$page.props.ziggy?.routes?.['songs.index']"
:href="route('songs.index')"
:active="route().current('songs.*')"
>
Song-Datenbank
</NavLink>
<a
v-else
href="#"
class="inline-flex items-center border-b-2 border-transparent px-1 pt-1 text-sm font-medium leading-5 text-gray-400 cursor-not-allowed"
title="Song-Datenbank (demnächst)"
>
Song-Datenbank
</a>
</div>
</div>
<!-- Right: Sync + User -->
<div class="hidden items-center gap-4 sm:flex">
<!-- Sync Info & Button -->
<div class="flex items-center gap-3">
<span
v-if="formattedSyncDate"
class="text-xs text-gray-400"
>
Zuletzt aktualisiert: {{ formattedSyncDate }}
</span>
<button
@click="triggerSync"
:disabled="syncing"
class="group inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm transition-all hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 focus:outline-none focus:ring-2 focus:ring-amber-400/40 focus:ring-offset-1 disabled:cursor-wait disabled:opacity-60"
>
<LoadingSpinner v-if="syncing" size="sm" />
<svg
v-else
class="h-3.5 w-3.5 transition-transform group-hover:rotate-45"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
Daten aktualisieren
</button>
</div>
<!-- User Dropdown -->
<div class="relative">
<Dropdown align="right" width="48">
<template #trigger>
<button
type="button"
class="flex items-center gap-2 rounded-lg p-1.5 text-sm transition-colors hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-amber-400/40"
>
<!-- Avatar -->
<img
v-if="user?.avatar"
:src="user.avatar"
:alt="user.name"
class="h-7 w-7 rounded-full object-cover ring-2 ring-gray-100"
/>
<span
v-else
class="flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-gray-600 to-gray-700 text-[10px] font-bold tracking-wide text-white ring-2 ring-gray-100"
>
{{ userInitials }}
</span>
<span class="max-w-[120px] truncate font-medium text-gray-700">
{{ user?.name }}
</span>
<svg
class="h-4 w-4 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
</template>
<template #content>
<div class="px-4 py-2 text-xs text-gray-400">
{{ user?.email }}
</div>
<DropdownLink
:href="route('logout')"
method="post"
as="button"
>
Abmelden
</DropdownLink>
</template>
</Dropdown>
</div>
</div>
<!-- Mobile Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button
@click="showingNavigationDropdown = !showingNavigationDropdown"
class="inline-flex items-center justify-center rounded-lg p-2 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600 focus:bg-gray-100 focus:outline-none"
>
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path
:class="{ hidden: showingNavigationDropdown, 'inline-flex': !showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{ hidden: !showingNavigationDropdown, 'inline-flex': showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile Responsive Menu -->
<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-show="showingNavigationDropdown" class="border-t border-gray-100 sm:hidden">
<!-- Mobile Navigation -->
<div class="space-y-1 pb-3 pt-2">
<ResponsiveNavLink
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</ResponsiveNavLink>
</div>
<!-- Mobile Sync -->
<div class="border-t border-gray-100 px-4 py-3">
<button
@click="triggerSync"
:disabled="syncing"
class="flex w-full items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-amber-50 hover:text-amber-700 disabled:opacity-60"
>
<LoadingSpinner v-if="syncing" size="sm" />
<svg v-else 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="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
Daten aktualisieren
</button>
<p v-if="formattedSyncDate" class="mt-1.5 text-xs text-gray-400">
Zuletzt: {{ formattedSyncDate }}
</p>
</div>
<!-- Mobile User Info -->
<div class="border-t border-gray-100 pb-2 pt-3">
<div class="flex items-center gap-3 px-4">
<img
v-if="user?.avatar"
:src="user.avatar"
:alt="user?.name"
class="h-9 w-9 rounded-full object-cover"
/>
<span
v-else
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-gray-600 to-gray-700 text-xs font-bold text-white"
>
{{ userInitials }}
</span>
<div class="min-w-0">
<div class="truncate text-sm font-medium text-gray-800">{{ user?.name }}</div>
<div class="truncate text-xs text-gray-500">{{ user?.email }}</div>
</div>
</div>
<div class="mt-3 space-y-1">
<ResponsiveNavLink
:href="route('logout')"
method="post"
as="button"
>
Abmelden
</ResponsiveNavLink>
</div>
</div>
</div>
</Transition>
</nav>
<!-- Page Heading -->
<header v-if="$slots.header" class="border-b border-gray-200/60 bg-white">
<div class="mx-auto max-w-7xl px-4 py-5 sm:px-6 lg:px-8">
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main>
<slot />
</main>
</div>
</template>