Files
kopalnia_2026/resources/views/components/app-layout.blade.php
T
2026-09-03 12:21:17 +02:00

296 lines
16 KiB
PHP

@props([
'active' => null, // nazwa trasy aktywnej pozycji (np. 'wells.index')
'section' => null, // klucz sekcji (gdy strona nie jest pozycją menu, np. landing sekcji)
'title' => 'SoftSPM 2',
])
@php
$sections = config('navigation');
// Ustal aktywną sekcję: jawny prop > sekcja zawierająca aktywną pozycję > pierwsza.
$activeSectionKey = $section;
if (! $activeSectionKey && $active) {
foreach ($sections as $s) {
foreach ($s['items'] as $it) {
if (($it['route'] ?? null) === $active) {
$activeSectionKey = $s['key'];
break 2;
}
}
}
}
$activeSectionKey = $activeSectionKey ?? ($sections[0]['key'] ?? null);
$activeSection = collect($sections)->firstWhere('key', $activeSectionKey);
// Motyw z cookie (serwer ustawia klasę przy renderze - brak migotania).
// Czytamy SUROWE $_COOKIE - cookie 'theme' ustawia JS bez szyfrowania, więc
// request()->cookie() (przez EncryptCookies) by go nie odczytał.
$theme = ($_COOKIE['theme'] ?? null) === 'dark' ? 'dark' : 'light';
// Inicjały użytkownika do awatara w menu.
$uname = auth()->user()?->USR_USERNAME ?? '?';
$unameParts = preg_split('/[^\p{L}\p{N}]+/u', $uname, -1, PREG_SPLIT_NO_EMPTY) ?: [$uname];
$initials = mb_strtoupper(count($unameParts) >= 2
? mb_substr($unameParts[0], 0, 1).mb_substr($unameParts[1], 0, 1)
: mb_substr($uname, 0, 2));
$languages = ['pl' => 'PL', 'en' => 'EN', 'de' => 'DE'];
@endphp
<!DOCTYPE html>
<html class="{{ $theme }}" lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ $title }}</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="bg-surface text-on-surface min-h-screen">
{{-- ===== Górny pasek: SEKCJE (zakres) ===== --}}
<header class="sticky top-0 z-30 bg-surface-container-lowest border-b border-outline-variant/40">
<div class="flex items-center justify-between h-16 px-gutter">
{{-- Logo + sekcje razem, wyrównane do lewej --}}
<div class="flex items-center gap-10 min-w-0">
<a href="{{ route('dashboard') }}" class="shrink-0">
<img src="{{ asset('images/logo.png') }}" alt="SoftSPM 2" class="h-9 w-auto">
</a>
<nav class="hidden md:flex items-center gap-6 font-body-md text-body-md overflow-x-auto">
@foreach ($sections as $s)
@php
$landing = isset($s['route']) && Route::has($s['route'])
? route($s['route'])
: route('section', $s['key']);
@endphp
<a href="{{ $landing }}"
class="whitespace-nowrap py-1 border-b-2 transition-colors hover:text-primary
{{ $activeSectionKey === $s['key'] ? 'text-primary font-semibold border-primary' : 'text-on-surface border-transparent' }}">
{{ $s['label'] }}
</a>
@endforeach
</nav>
</div>
<div class="flex items-center gap-2 shrink-0">
{{-- Przełącznik motywu jasny/ciemny --}}
<button type="button" id="theme-toggle" title="Przełącz motyw jasny/ciemny" aria-label="Przełącz motyw jasny/ciemny"
class="p-2 rounded-full text-on-surface-variant hover:bg-surface-container hover:text-primary transition-colors">
{{-- W jasnym pokazuje księżyc (przejście w ciemny) --}}
<svg class="w-5 h-5 dark:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z"/>
</svg>
{{-- W ciemnym pokazuje słońce (przejście w jasny) --}}
<svg class="w-5 h-5 hidden dark:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
</button>
{{-- Menu użytkownika: awatar -> rozwijane (język + wyloguj) --}}
<div class="relative" id="user-menu">
<button type="button" id="user-menu-button" aria-haspopup="true" aria-expanded="false"
class="flex items-center gap-2 rounded-full hover:opacity-90 transition-opacity">
<span class="grid place-items-center w-9 h-9 rounded-full bg-primary text-on-primary font-data-label text-data-label">{{ $initials }}</span>
</button>
<div id="user-menu-panel"
class="hidden absolute right-0 mt-2 w-60 bg-surface-container-lowest border border-outline-variant/40 rounded-xl shadow-card py-2 z-40">
<div class="px-4 py-2 border-b border-outline-variant/30">
<p class="font-body-sm text-body-sm text-on-surface-variant">Zalogowano jako</p>
<p class="font-data-label text-data-label text-on-surface truncate">{{ $uname }}</p>
</div>
<div class="px-4 py-3">
<p class="font-data-label text-data-label text-on-surface-variant/70 uppercase tracking-wider mb-2">Język</p>
<div class="flex gap-1.5">
@foreach ($languages as $code => $label)
<a href="{{ route('locale.set', $code) }}"
class="px-3 py-1 rounded-md font-data-label text-data-label transition-colors
{{ app()->getLocale() === $code ? 'bg-secondary-container text-on-secondary-container font-semibold' : 'text-on-surface-variant hover:bg-surface-container' }}">
{{ $label }}
</a>
@endforeach
</div>
</div>
<div class="border-t border-outline-variant/30 mt-1 pt-1">
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit"
class="w-full text-left px-4 py-2 font-body-md text-body-md text-on-surface hover:bg-surface-container transition-colors">
Wyloguj
</button>
</form>
</div>
</div>
</div>
</div>
</div>
</header>
<div class="flex min-h-[calc(100vh-4rem)]">
{{-- ===== Lewy sidebar: POZYCJE aktywnej sekcji ===== --}}
<aside class="hidden lg:block w-72 shrink-0 bg-surface-container-lowest border-r border-outline-variant/40 p-gutter">
<p class="font-data-label text-data-label text-on-surface-variant uppercase tracking-wider mb-4">
{{ $activeSection['label'] ?? 'Nawigacja' }}
</p>
<nav class="space-y-1">
@forelse ($activeSection['items'] ?? [] as $it)
@php
$r = $it['route'] ?? null;
$url = ($r && Route::has($r)) ? route($r) : '#';
$isActive = $r && $r === $active;
@endphp
<a href="{{ $url }}"
class="flex items-center gap-3 px-4 py-3 rounded-full font-title-sm text-title-sm transition-all
{{ $isActive ? 'bg-secondary-container text-on-secondary-container font-semibold' : 'text-on-surface-variant hover:bg-surface-variant/50' }}">
<x-icon :name="$it['icon'] ?? ''" class="w-5 h-5 shrink-0" />
<span>{{ $it['label'] }}@if (! empty($it['append_department']) && session('department')) - {{ session('department') }}@endif</span>
</a>
@empty
<p class="px-3 py-2 font-body-sm text-body-sm text-on-surface-variant">Moduł w przygotowaniu.</p>
@endforelse
</nav>
</aside>
{{-- ===== Treść ===== --}}
<main class="flex-1 p-gutter">
{{ $slot }}
</main>
</div>
<footer class="border-t border-outline-variant/30 px-gutter py-3 text-center font-body-sm text-body-sm text-on-surface-variant">
© SoftSPM Przemysław Zatorski · v.2.0
</footer>
{{-- ===== Globalny preloader (pokazywany gdy nawigacja/wyszukiwanie trwa dłużej) ===== --}}
<div id="page-loader" class="fixed inset-0 z-[70] hidden items-center justify-center bg-on-surface/30 backdrop-blur-sm">
<div class="flex flex-col items-center gap-3 bg-surface-container-lowest/95 px-7 py-6 rounded-xl shadow-card border border-outline-variant/40">
<div class="w-12 h-12 rounded-full border-4 border-secondary/25 border-t-secondary animate-spin"></div>
<span class="font-data-label text-data-label text-on-surface-variant tracking-wide">Ładowanie...</span>
</div>
</div>
{{-- ===== Wspólny modal potwierdzenia (wyśrodkowany) ===== --}}
<div id="confirm-modal" class="fixed inset-0 z-50 hidden items-center justify-center p-4">
<div class="absolute inset-0 bg-on-surface/40 backdrop-blur-sm" data-confirm-cancel></div>
<div class="relative w-full max-w-md bg-surface-container-lowest rounded-xl shadow-card border border-outline-variant/40 p-6 space-y-4">
<h3 id="confirm-modal-title" class="font-headline-md text-headline-md text-on-surface">Potwierdzenie</h3>
<p id="confirm-modal-message" class="font-body-md text-body-md text-on-surface-variant">Czy na pewno?</p>
<div class="flex justify-end gap-2 pt-2">
<button type="button" data-confirm-cancel
class="px-5 py-2.5 rounded-full font-title-sm text-title-sm text-on-surface-variant hover:bg-surface-container transition-all">Anuluj</button>
<button type="button" id="confirm-modal-ok"
class="bg-error text-on-error px-5 py-2.5 rounded-full font-title-sm text-title-sm hover:opacity-90 active:scale-95 transition-all">Usuń</button>
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('confirm-modal');
if (!modal) return;
const titleEl = document.getElementById('confirm-modal-title');
const msgEl = document.getElementById('confirm-modal-message');
const okBtn = document.getElementById('confirm-modal-ok');
let pendingForm = null;
function open(form) {
pendingForm = form;
titleEl.textContent = form.dataset.confirmTitle || 'Potwierdzenie usunięcia';
msgEl.textContent = form.dataset.confirmMessage || 'Czy na pewno?';
okBtn.textContent = form.dataset.confirmButton || 'Usuń';
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function close() {
modal.classList.add('hidden');
modal.classList.remove('flex');
pendingForm = null;
}
document.addEventListener('click', function (e) {
const trigger = e.target.closest('[data-confirm-trigger]');
if (trigger) { e.preventDefault(); open(trigger.closest('form')); return; }
if (e.target.closest('[data-confirm-cancel]')) { close(); }
});
okBtn.addEventListener('click', function () { if (pendingForm) pendingForm.submit(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();
</script>
{{-- Globalny preloader: pojawia się tylko gdy odpowiedź serwera trwa dłużej niż DELAY --}}
<script>
(function () {
const loader = document.getElementById('page-loader');
if (!loader) return;
const DELAY = 250; // ms - pokaż dopiero gdy ładowanie się przeciąga
let timer = null;
function show() { loader.classList.remove('hidden'); loader.classList.add('flex'); }
function hide() { clearTimeout(timer); loader.classList.add('hidden'); loader.classList.remove('flex'); }
function schedule() { clearTimeout(timer); timer = setTimeout(show, DELAY); }
// Wysłanie formularza (wyszukiwarki, filtry, zapisy) - oprócz otwieranych w nowej karcie.
document.addEventListener('submit', function (e) {
const form = e.target;
if (form.matches && form.matches('form') && form.target !== '_blank') schedule();
}, true);
// Nawigacja kliknięciem w wewnętrzny link (paginacja, menu, akcje).
document.addEventListener('click', function (e) {
const a = e.target.closest && e.target.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href');
if (!href || href.charAt(0) === '#' || a.target === '_blank' || a.hasAttribute('download')) return;
if (a.origin && a.origin !== window.location.origin) return; // tylko ten sam serwer
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
schedule();
});
// Powrót z cache przeglądarki (przycisk Wstecz) - ukryj ewentualny preloader.
window.addEventListener('pageshow', hide);
})();
</script>
{{-- Motyw (jasny/ciemny) + menu użytkownika --}}
<script>
(function () {
// ===== Motyw - zapis w cookie na ~400 dni, odświeżany przy każdej wizycie =====
const COOKIE_MAX_AGE = 60 * 60 * 24 * 400; // sekundy (~400 dni, maksimum przeglądarek)
function applyTheme(t) {
const r = document.documentElement;
r.classList.toggle('dark', t === 'dark');
r.classList.toggle('light', t !== 'dark');
document.cookie = 'theme=' + t + ';path=/;max-age=' + COOKIE_MAX_AGE + ';samesite=lax';
}
// Odśwież okno ważności cookie (utrzymanie wyboru "na zawsze" mimo limitu przeglądarki).
applyTheme(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
const toggle = document.getElementById('theme-toggle');
if (toggle) toggle.addEventListener('click', function () {
applyTheme(document.documentElement.classList.contains('dark') ? 'light' : 'dark');
});
// ===== Menu użytkownika (rozwijane) =====
const btn = document.getElementById('user-menu-button');
const panel = document.getElementById('user-menu-panel');
if (btn && panel) {
function closeMenu() { panel.classList.add('hidden'); btn.setAttribute('aria-expanded', 'false'); }
btn.addEventListener('click', function (e) {
e.stopPropagation();
const willOpen = panel.classList.contains('hidden');
panel.classList.toggle('hidden');
btn.setAttribute('aria-expanded', String(willOpen));
});
document.addEventListener('click', function (e) {
if (!panel.contains(e.target) && !btn.contains(e.target)) closeMenu();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeMenu(); });
}
})();
</script>
</body>
</html>