pp-planer/resources/js/Layouts/AuthenticatedLayout.vue

350 lines
17 KiB
Vue

<script setup>
import { ref, computed } from 'vue'
import { Link, router, usePage } from '@inertiajs/vue3'
import { route } from 'ziggy-js'
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)
})
const hasSongsRoute = computed(() => {
try {
// Try to call the route function - if it works, the route exists
route('songs.index')
return true
} catch {
return false
}
})
function triggerSync() {
if (syncing.value) return
syncing.value = true
router.post(
'/sync',
{},
{
preserveScroll: 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
data-testid="auth-layout-logo"
: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
data-testid="auth-layout-nav-services"
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</NavLink>
<NavLink
data-testid="auth-layout-nav-songs"
v-if="hasSongsRoute"
:href="route('songs.index')"
:active="route().current('songs.*')"
>
Song-Datenbank
</NavLink>
<NavLink
data-testid="auth-layout-nav-api-logs"
:href="route('api-logs.index')"
:active="route().current('api-logs.*')"
>
API-Log
</NavLink>
<a
v-if="!hasSongsRoute"
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
data-testid="auth-layout-sync-timestamp"
v-if="formattedSyncDate"
class="text-xs text-gray-400"
>
Zuletzt aktualisiert: {{ formattedSyncDate }}
</span>
<button
data-testid="auth-layout-sync-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
data-testid="auth-layout-user-dropdown-trigger"
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
data-testid="auth-layout-logout-link"
: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
data-testid="auth-layout-mobile-hamburger"
@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
data-testid="auth-layout-mobile-nav-services"
:href="route('services.index')"
:active="route().current('services.*')"
>
Services
</ResponsiveNavLink>
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-songs"
v-if="hasSongsRoute"
:href="route('songs.index')"
:active="route().current('songs.*')"
>
Song-Datenbank
</ResponsiveNavLink>
<ResponsiveNavLink
data-testid="auth-layout-mobile-nav-api-logs"
:href="route('api-logs.index')"
:active="route().current('api-logs.*')"
>
API-Log
</ResponsiveNavLink>
</div>
<!-- Mobile Sync -->
<div class="border-t border-gray-100 px-4 py-3">
<button
data-testid="auth-layout-mobile-sync-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
data-testid="auth-layout-mobile-logout-link"
: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>