diff --git a/.env.example b/.env.example index 37f4814e9..75baa32a1 100644 --- a/.env.example +++ b/.env.example @@ -389,6 +389,8 @@ APP_SERVICE=laravel.test # Port mapping: host → container APP_PORT=80 VITE_PORT=5173 +# Browser-reachable host for Docker Vite hot reload URLs and HMR. +VITE_DEV_SERVER_HOST=localhost FORWARD_DB_PORT=3306 FORWARD_REDIS_PORT=6379 FORWARD_MAILPIT_PORT=1025 diff --git a/app/Http/Middleware/ContentSecurityPolicy.php b/app/Http/Middleware/ContentSecurityPolicy.php index fc6ddfdb6..fde4b8210 100644 --- a/app/Http/Middleware/ContentSecurityPolicy.php +++ b/app/Http/Middleware/ContentSecurityPolicy.php @@ -41,6 +41,12 @@ class ContentSecurityPolicy // Reuse the nonce generated above (shared with Blade via csp_nonce()) + $viteDevServerSources = $this->viteDevServerSources(); + $viteAssetSource = $viteDevServerSources === null ? '' : ' '.$viteDevServerSources['asset']; + $viteConnectSources = $viteDevServerSources === null + ? '' + : ' '.$viteDevServerSources['asset'].' '.$viteDevServerSources['websocket']; + // Build CSP directives for non-Turnstile pages // 'strict-dynamic' propagates trust from nonce-validated scripts to // dynamically loaded scripts (e.g. TinyMCE loaded via createElement). @@ -51,14 +57,14 @@ class ContentSecurityPolicy // standard Alpine evaluator (new Function) that cannot be tree-shaken, and // TinyMCE (loaded from CDN on admin content pages) also relies on eval. // 'unsafe-inline' has been removed in favor of nonce-based validation. - "script-src 'self' 'nonce-{$nonce}' 'unsafe-eval' 'strict-dynamic' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com blob:", - "script-src-elem 'self' 'nonce-{$nonce}' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com", + "script-src 'self' 'nonce-{$nonce}' 'unsafe-eval' 'strict-dynamic' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com blob:{$viteAssetSource}", + "script-src-elem 'self' 'nonce-{$nonce}' https://challenges.cloudflare.com https://cdn.tiny.cloud https://cdn.jsdelivr.net/ https://static.cloudflareinsights.com/ https://cdnjs.cloudflare.com/ https://unpkg.com/ https://cdn.tailwindcss.com/ https://code.jquery.com https://apis.google.com https://www.google.com https://www.gstatic.com https://ajax.cloudflare.com{$viteAssetSource}", "script-src-attr 'none'", - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", - "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud", - "font-src 'self' https://fonts.gstatic.com https://fonts.bunny.net https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud data:", - "img-src 'self' data: https: blob:", - "connect-src 'self' https://www.google.com https://cdn.tiny.cloud https://sp.tinymce.com", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud{$viteAssetSource}", + "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com https://fonts.bunny.net https://cdn.jsdelivr.net/ https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud{$viteAssetSource}", + "font-src 'self' https://fonts.gstatic.com https://fonts.bunny.net https://cdnjs.cloudflare.com/ https://cdn.tiny.cloud data:{$viteAssetSource}", + "img-src 'self' data: https: blob:{$viteAssetSource}", + "connect-src 'self' https://www.google.com https://cdn.tiny.cloud https://sp.tinymce.com{$viteConnectSources}", "frame-src 'self' https://www.google.com https://www.gstatic.com https://challenges.cloudflare.com https://cdn.tiny.cloud data: blob:", "child-src 'self' https://www.google.com https://challenges.cloudflare.com https://cdn.tiny.cloud blob:", "worker-src 'self' blob:", @@ -83,4 +89,40 @@ class ContentSecurityPolicy return $response; } + + /** + * @return array{asset: string, websocket: string}|null + */ + private function viteDevServerSources(): ?array + { + if (config('app.env') !== 'local' || ! Vite::isRunningHot()) { + return null; + } + + $hotUrl = trim((string) file_get_contents(Vite::hotFile())); + $parts = parse_url($hotUrl); + + if (! is_array($parts)) { + return null; + } + + $scheme = $parts['scheme'] ?? null; + $host = $parts['host'] ?? null; + + if (! in_array($scheme, ['http', 'https'], true) + || ! is_string($host) + || ! in_array($host, ['localhost', '127.0.0.1', '::1'], true)) { + return null; + } + + $urlHost = str_contains($host, ':') ? "[{$host}]" : $host; + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + $assetOrigin = "{$scheme}://{$urlHost}{$port}"; + $websocketScheme = $scheme === 'https' ? 'wss' : 'ws'; + + return [ + 'asset' => $assetOrigin, + 'websocket' => "{$websocketScheme}://{$urlHost}{$port}", + ]; + } } diff --git a/docker/8.5/start-container b/docker/8.5/start-container index 474e2e64c..321151390 100644 --- a/docker/8.5/start-container +++ b/docker/8.5/start-container @@ -27,6 +27,10 @@ listen.group = www-data listen.mode = 0660 EOF +# Vite is started separately with `make npm-dev`. A hot file left behind by a +# previous dev-server process makes Laravel request assets from a dead port. +unlink /var/www/html/public/hot 2>/dev/null || true + # Set timezone ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone diff --git a/resources/css/app.css b/resources/css/app.css index 94f8c9354..3e6b8424d 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -152,6 +152,249 @@ display: none !important; } +/* Shared application shell */ +.skip-link { + @apply fixed left-4 top-3 z-[100] -translate-y-20 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-gray-950 shadow-xl transition-transform focus:translate-y-0 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-gray-900 dark:text-white; +} + +:where(a, button, input, select, textarea, summary):focus-visible { + outline: 2px solid var(--color-primary-400); + outline-offset: 2px; +} + +.layout-shell { + background-color: var(--surface-body); +} + +.dark .layout-shell { + background-color: var(--surface-body-dark); +} + +.layout-content { + isolation: isolate; +} + +.layout-main { + background-color: var(--surface-body); + background-image: + radial-gradient(circle at 92% 0%, color-mix(in srgb, var(--color-primary-500) 9%, transparent), transparent 28rem), + linear-gradient(to bottom, color-mix(in srgb, var(--surface-body) 92%, white), var(--surface-body)); + scroll-behavior: smooth; +} + +.dark .layout-main { + background-color: var(--surface-body-dark); + background-image: + radial-gradient(circle at 92% 0%, color-mix(in srgb, var(--color-primary-500) 13%, transparent), transparent 30rem), + linear-gradient(to bottom, color-mix(in srgb, var(--surface-body-dark) 88%, black), var(--surface-body-dark)); +} + +.layout-sidebar { + border-right: 1px solid var(--surface-chrome-border); + background-image: + radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--color-primary-400) 18%, transparent), transparent 18rem), + linear-gradient(to bottom, color-mix(in srgb, var(--surface-sidebar) 92%, black), var(--surface-sidebar)); + box-shadow: 12px 0 36px rgb(15 23 42 / 0.14); +} + +.dark .layout-sidebar { + border-right-color: var(--surface-chrome-border-dark); + background-image: + radial-gradient(circle at 10% 0%, color-mix(in srgb, var(--color-primary-500) 15%, transparent), transparent 18rem), + linear-gradient(to bottom, color-mix(in srgb, var(--surface-sidebar-dark) 92%, black), var(--surface-sidebar-dark)); + box-shadow: 12px 0 40px rgb(0 0 0 / 0.3); +} + +.layout-sidebar__brand { + min-height: 4.75rem; + border-bottom: 1px solid var(--surface-chrome-border); +} + +.dark .layout-sidebar__brand { + border-bottom-color: var(--surface-chrome-border-dark); +} + +.layout-sidebar__logo { + border: 1px solid rgb(255 255 255 / 0.1); + background: rgb(255 255 255 / 0.07); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.08); +} + +.layout-sidebar__nav { + scrollbar-color: rgb(255 255 255 / 0.18) transparent; + scrollbar-width: thin; +} + +.user-sidebar-menu > .sidebar-section > button, +.user-sidebar-menu > a, +.admin-sidebar-menu > div > button, +.admin-sidebar-menu > div > a { + min-height: 2.75rem; + border-radius: 0.75rem; +} + +.user-sidebar-menu .sidebar-submenu, +.admin-sidebar-menu [x-show] { + border-left: 1px solid rgb(255 255 255 / 0.09); +} + +.user-sidebar-menu .sidebar-submenu a, +.admin-sidebar-menu [x-show] a { + min-height: 2.5rem; + border-radius: 0.625rem; +} + +.admin-sidebar-menu > div { + margin-bottom: 0.25rem; +} + +.layout-topbar { + border-bottom: 1px solid var(--surface-chrome-border); + background-image: linear-gradient(to right, color-mix(in srgb, var(--surface-header) 94%, black), var(--surface-header)); + box-shadow: 0 8px 30px rgb(15 23 42 / 0.12); +} + +.dark .layout-topbar { + border-bottom-color: var(--surface-chrome-border-dark); + background-image: linear-gradient(to right, color-mix(in srgb, var(--surface-header-dark) 94%, black), var(--surface-header-dark)); + box-shadow: 0 8px 32px rgb(0 0 0 / 0.25); +} + +.layout-primary-nav__search { + border-radius: 0.75rem; + box-shadow: 0 1px 0 rgb(255 255 255 / 0.08); +} + +.layout-flash { + @apply flex items-start gap-3 rounded-2xl border px-4 py-3.5 text-sm font-medium shadow-sm; +} + +.layout-flash > i { + @apply mt-0.5 shrink-0 text-base; +} + +.layout-flash--success { + @apply border-emerald-200 bg-emerald-50/95 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/80 dark:text-emerald-100; +} + +.layout-flash--error { + @apply border-red-200 bg-red-50/95 text-red-900 dark:border-red-800 dark:bg-red-950/80 dark:text-red-100; +} + +.layout-flash--warning { + @apply border-amber-200 bg-amber-50/95 text-amber-950 dark:border-amber-800 dark:bg-amber-950/80 dark:text-amber-100; +} + +.app-page-header, +.admin-page-header { + border-bottom: 1px solid var(--border-default); + background-image: linear-gradient(120deg, color-mix(in srgb, var(--color-primary-50) 72%, white), transparent 62%); +} + +.dark .app-page-header, +.dark .admin-page-header { + border-bottom-color: var(--border-default-dark); + background-image: linear-gradient(120deg, color-mix(in srgb, var(--color-primary-950) 35%, transparent), transparent 62%); +} + +.admin-page-header__icon { + border: 1px solid color-mix(in srgb, var(--color-primary-500) 24%, transparent); + background-color: color-mix(in srgb, var(--color-primary-500) 11%, transparent); + color: var(--color-primary-700); +} + +.dark .admin-page-header__icon { + color: var(--color-primary-300); +} + +.admin-card, +.admin-stat-card, +.admin-data-table-wrap { + border-color: var(--border-default); +} + +.dark .admin-card, +.dark .admin-stat-card, +.dark .admin-data-table-wrap { + border-color: var(--border-default-dark); +} + +.admin-stat-card { + background-image: linear-gradient(145deg, color-mix(in srgb, var(--color-primary-50) 42%, transparent), transparent 58%); +} + +.dark .admin-stat-card { + background-image: linear-gradient(145deg, color-mix(in srgb, var(--color-primary-950) 24%, transparent), transparent 58%); +} + +.admin-data-table-wrap { + border-radius: 0.875rem; +} + +.admin-dashboard-page__hero { + position: relative; + overflow: hidden; + background-image: + radial-gradient(circle at 92% 20%, color-mix(in srgb, var(--color-primary-400) 18%, transparent), transparent 16rem), + linear-gradient(125deg, color-mix(in srgb, var(--color-primary-50) 78%, white), var(--surface-card) 62%); +} + +.dark .admin-dashboard-page__hero { + background-image: + radial-gradient(circle at 92% 20%, color-mix(in srgb, var(--color-primary-500) 16%, transparent), transparent 18rem), + linear-gradient(125deg, color-mix(in srgb, var(--color-primary-950) 38%, var(--surface-card-dark)), var(--surface-card-dark) 62%); +} + +.admin-dashboard-page__stats-grid > div, +.admin-dashboard-page [data-widget] { + border-color: var(--border-default); + border-radius: 1rem; + background-color: var(--surface-card) !important; + box-shadow: 0 1px 2px rgb(15 23 42 / 0.04), 0 10px 30px rgb(15 23 42 / 0.04); +} + +.dark .admin-dashboard-page__stats-grid > div, +.dark .admin-dashboard-page [data-widget] { + border-color: var(--border-default-dark); + background-color: var(--surface-card-dark) !important; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.18), 0 12px 32px rgb(0 0 0 / 0.12); +} + +.admin-dashboard-page__stats-grid > div { + background-image: linear-gradient(145deg, color-mix(in srgb, var(--color-primary-50) 36%, transparent), transparent 58%); +} + +.dark .admin-dashboard-page__stats-grid > div { + background-image: linear-gradient(145deg, color-mix(in srgb, var(--color-primary-950) 20%, transparent), transparent 58%); +} + +#sidebar a[aria-current="page"] { + background-color: color-mix(in srgb, var(--color-primary-500) 22%, transparent); + color: white; + box-shadow: inset 3px 0 0 var(--color-primary-400); +} + +@media (max-width: 767px) { + .layout-sidebar { + box-shadow: 20px 0 60px rgb(2 6 23 / 0.42); + } + + .layout-topbar__tools { + max-width: 60vw; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + /* Default document flow - public/guest pages need vertical scrolling */ html, body { diff --git a/resources/js/alpine/components/admin-submenu.js b/resources/js/alpine/components/admin-submenu.js index cfb687111..22fac3a90 100644 --- a/resources/js/alpine/components/admin-submenu.js +++ b/resources/js/alpine/components/admin-submenu.js @@ -6,9 +6,19 @@ import Alpine from '@alpinejs/csp'; Alpine.data('adminSubmenu', () => ({ open: false, + init() { + this.open = Array.from(this.$el.querySelectorAll('a[href]')).some((link) => { + try { + var linkUrl = new URL(link.href, window.location.origin); + return linkUrl.origin === window.location.origin && linkUrl.pathname === window.location.pathname; + } catch (_error) { + return false; + } + }); + }, + toggle() { this.open = !this.open; } })); - diff --git a/resources/js/alpine/components/mobile-nav.js b/resources/js/alpine/components/mobile-nav.js index bc9f9b38d..b3a0ccc2d 100644 --- a/resources/js/alpine/components/mobile-nav.js +++ b/resources/js/alpine/components/mobile-nav.js @@ -50,6 +50,17 @@ Alpine.data('mobileSidebar', () => ({ * Document-level delegation for mobile menu elements without x-data. */ (function() { + document.querySelectorAll('#sidebar a[href]').forEach(function(link) { + try { + var linkUrl = new URL(link.href, window.location.origin); + if (linkUrl.origin === window.location.origin && linkUrl.pathname === window.location.pathname) { + link.setAttribute('aria-current', 'page'); + } + } catch (_error) { + // Ignore malformed or non-navigational URLs. + } + }); + var toggle = document.getElementById('mobile-menu-toggle'); var panel = document.getElementById('mobile-nav-panel'); var iconOpen = document.getElementById('mobile-menu-icon-open'); @@ -82,6 +93,7 @@ Alpine.data('mobileSidebar', () => ({ searchToggle.addEventListener('click', function(ev) { ev.preventDefault(); searchForm.classList.toggle('hidden'); + searchToggle.setAttribute('aria-expanded', searchForm.classList.contains('hidden') ? 'false' : 'true'); if (panel && !searchForm.classList.contains('hidden')) { panel.classList.add('hidden'); if (iconOpen && iconClose) { iconOpen.classList.remove('hidden'); iconClose.classList.add('hidden'); } @@ -91,10 +103,44 @@ Alpine.data('mobileSidebar', () => ({ } var mobileSidebarToggle = document.getElementById('mobile-sidebar-toggle'); + var mobileSidebarClose = document.getElementById('mobile-sidebar-close'); + var mobileSidebarBackdrop = document.getElementById('mobile-sidebar-backdrop'); + + function setMobileSidebar(open) { + var sidebar = document.getElementById('sidebar'); + if (!sidebar || !mobileSidebarToggle) return; + + sidebar.classList.toggle('hidden', !open); + sidebar.classList.toggle('flex', open); + mobileSidebarBackdrop?.classList.toggle('hidden', !open); + mobileSidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + mobileSidebarToggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); + + if (open) { + mobileSidebarClose?.focus(); + } else if (window.innerWidth < 768) { + mobileSidebarToggle.focus(); + } + } + if (mobileSidebarToggle && !mobileSidebarToggle.closest('[x-data]')) { mobileSidebarToggle.addEventListener('click', function() { var sidebar = document.getElementById('sidebar'); - if (sidebar) { sidebar.classList.toggle('hidden'); sidebar.classList.toggle('flex'); } + if (sidebar) setMobileSidebar(sidebar.classList.contains('hidden')); + }); + + mobileSidebarClose?.addEventListener('click', function() { + setMobileSidebar(false); + }); + + mobileSidebarBackdrop?.addEventListener('click', function() { + setMobileSidebar(false); + }); + + document.addEventListener('keydown', function(event) { + if (event.key === 'Escape' && mobileSidebarToggle.getAttribute('aria-expanded') === 'true') { + setMobileSidebar(false); + } }); } @@ -105,5 +151,9 @@ Alpine.data('mobileSidebar', () => ({ if (iconOpen && iconClose) { iconOpen.classList.remove('hidden'); iconClose.classList.add('hidden'); } if (toggle) toggle.setAttribute('aria-expanded', 'false'); } + + if (window.innerWidth >= 768 && mobileSidebarToggle) { + setMobileSidebar(false); + } }); })(); diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index c2f502ee8..f27191bff 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -14,15 +14,17 @@
-
+
- +
-

Admin Dashboard

-

Welcome to the administration panel

+

Operations overview

+

Admin Dashboard

+

Monitor index health, users, releases, and site activity from one workspace.

@@ -34,7 +36,7 @@ -

+
diff --git a/resources/views/components/admin/card.blade.php b/resources/views/components/admin/card.blade.php index 6ae9d7352..81ab16dd6 100644 --- a/resources/views/components/admin/card.blade.php +++ b/resources/views/components/admin/card.blade.php @@ -3,6 +3,6 @@ 'noPadding' => false, ]) -
merge(['class' => 'bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700']) }}> +
merge(['class' => 'admin-card surface-panel rounded-2xl border shadow-sm']) }}> {{ $slot }}
diff --git a/resources/views/components/admin/data-table.blade.php b/resources/views/components/admin/data-table.blade.php index d36832865..6f63c29f3 100644 --- a/resources/views/components/admin/data-table.blade.php +++ b/resources/views/components/admin/data-table.blade.php @@ -5,7 +5,7 @@ 'striped' => false, ]) -
+
merge(['class' => 'min-w-full table-auto divide-y divide-gray-200 dark:divide-gray-700']) }}> @if(isset($head)) null, ]) -
-
-
-

+
merge(['class' => 'admin-page-header px-4 py-5 sm:px-6']) }}> +
+
+

@if($icon) - + @endif - {{ $title }} + {{ $title }}

@if($subtitle)

{{ $subtitle }}

@endif
@if(isset($actions)) -
+
{{ $actions }}
@endif
- diff --git a/resources/views/components/admin/stat-card.blade.php b/resources/views/components/admin/stat-card.blade.php index 7ab12856e..2ff999c86 100644 --- a/resources/views/components/admin/stat-card.blade.php +++ b/resources/views/components/admin/stat-card.blade.php @@ -19,14 +19,14 @@ $colors = $colorMap[$color] ?? $colorMap['blue']; @endphp -
+
merge(['class' => 'admin-stat-card surface-panel rounded-2xl border p-5 shadow-sm sm:p-6']) }}>

{{ $label }}

{{ $value }}

-
- +
+
@if($footer || isset($footerSlot)) @@ -39,4 +39,3 @@
@endif
- diff --git a/resources/views/components/page-header.blade.php b/resources/views/components/page-header.blade.php index fb312668f..2d1a39f06 100644 --- a/resources/views/components/page-header.blade.php +++ b/resources/views/components/page-header.blade.php @@ -4,10 +4,10 @@ 'icon' => null, ]) -
merge(['class' => 'px-6 py-6']) }}> +
merge(['class' => 'app-page-header px-4 py-5 sm:px-6 sm:py-6']) }}>
-

+

@if($icon) @endif @@ -19,7 +19,7 @@

@isset($actions) -
+
{{ $actions }}
@endisset diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 01bdfc1fa..55267b63d 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -27,31 +27,51 @@ @stack('meta') @stack('styles') - -
+ + + +
-

-
@@ -50,4 +50,3 @@
- diff --git a/resources/views/partials/filelist-modal.blade.php b/resources/views/partials/filelist-modal.blade.php index 3ea620a25..d17fae296 100644 --- a/resources/views/partials/filelist-modal.blade.php +++ b/resources/views/partials/filelist-modal.blade.php @@ -33,8 +33,8 @@

File List

- @@ -60,4 +60,3 @@ - diff --git a/resources/views/partials/header-menu.blade.php b/resources/views/partials/header-menu.blade.php index f033ea7b7..194c1bbf6 100644 --- a/resources/views/partials/header-menu.blade.php +++ b/resources/views/partials/header-menu.blade.php @@ -1,14 +1,14 @@ -