T20: Song DB Page - Songs/Index.vue with search, action buttons, pagination - Upload area for .pro files (calls T23 placeholder) - Song-Datenbank nav link added to AuthenticatedLayout - Tests: 9 new (44 assertions) T21: Song DB Edit Popup - SongEditModal.vue with metadata + ArrangementConfigurator - Auto-save with fetch (500ms debounce for text, immediate on blur) - Tests: 11 new (53 assertions) T22: Song DB Translate Page - Songs/Translate.vue with two-column editor - URL fetch or manual paste, line-count constraints - Group headers with colors, save marks has_translation=true - Tests: 1 new (12 assertions) T23: .pro File Placeholders - ProParserNotImplementedException with HTTP 501 - ProFileController with importPro/downloadPro placeholders - German error messages - Tests: 5 new (7 assertions) T24: Service Finalization + Status - Two-step finalization with warnings (unmatched songs, missing slides) - Download placeholder toast - isReadyToFinalize accessor on Service model - Tests: 11 new (30 assertions) All tests passing: 174/174 (905 assertions) Build: ✓ Vite production build successful German UI: All user-facing text in German with 'Du' form
314 lines
15 KiB
Vue
314 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>
|
|
<ResponsiveNavLink
|
|
v-if="$page.props.ziggy?.routes?.['songs.index']"
|
|
:href="route('songs.index')"
|
|
:active="route().current('songs.*')"
|
|
>
|
|
Song-Datenbank
|
|
</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>
|