feat(branding): favicons + PWA manifest, and custom branded error pages
Branding assets (public/, served directly):
- favicon.svg (scalable, primary), favicon.ico (16/32/48 PNG-embedded),
apple-touch-icon.png (180), icon-192/512.png, site.webmanifest. The mark is
the in-app brand glyph (orange Lucide "server" on dark). Wired into both
layouts via a shared partials/head-icons (rel=icon svg+ico, apple-touch,
manifest, theme-color #06080A). nginx serves the manifest as
application/manifest+json.
Never show raw error text (custom messages only):
- Custom branded resources/views/errors/{403,404,419,429,500,503}.blade.php on a
self-contained error layout (design tokens, brand mark, bilingual via
lang/errors.php) — replaces Laravel's default Symfony/whoops pages.
- docker-compose.prod.yml pins APP_DEBUG=false so a prod error can never render a
stack trace (the custom pages show instead).
- Files download error dispatched a bare $e->getMessage(); wrap it in the
localized files.download_failed (consistent with upload/delete).
Verified in-browser: icon links present + all assets 200; custom 404 renders the
branded German message (no raw "Not Found"/trace), responsive at 375, 0 console.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
parent
07f9110f90
commit
c5d3de6e0b
|
|
@ -107,7 +107,7 @@ class Index extends Component
|
|||
try {
|
||||
$content = $fleet->getFile($active, rtrim($this->path, '/').'/'.basename($name));
|
||||
} catch (Throwable $e) {
|
||||
$this->dispatch('notify', message: $e->getMessage());
|
||||
$this->dispatch('notify', message: __('files.download_failed', ['error' => $e->getMessage()]));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ x-image: &image
|
|||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
# Safety net: force debug OFF in prod so an uncaught error can never render a
|
||||
# stack trace to the user (the custom resources/views/errors/* pages show
|
||||
# instead). `environment` overrides any APP_DEBUG left in the prod .env.
|
||||
environment:
|
||||
APP_DEBUG: "false"
|
||||
networks:
|
||||
- clusev
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ http {
|
|||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
# Correct MIME for the PWA manifest (the bundled mime.types maps .webmanifest
|
||||
# to octet-stream). Per-file default_type avoids replacing the global types map.
|
||||
location = /site.webmanifest { default_type application/manifest+json; access_log off; }
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
// Custom error pages (resources/views/errors/*). Never show a raw status text or
|
||||
// stack trace — always a friendly, localized message (R16). German = source of truth.
|
||||
return [
|
||||
'back_home' => 'Zurück zum Dashboard',
|
||||
'generic_title' => 'Fehler',
|
||||
|
||||
'403_title' => 'Kein Zugriff',
|
||||
'403_body' => 'Du hast keine Berechtigung, diese Seite zu sehen.',
|
||||
|
||||
'404_title' => 'Seite nicht gefunden',
|
||||
'404_body' => 'Diese Seite existiert nicht oder wurde verschoben.',
|
||||
|
||||
'419_title' => 'Sitzung abgelaufen',
|
||||
'419_body' => 'Deine Sitzung ist abgelaufen. Bitte lade die Seite neu und melde dich erneut an.',
|
||||
|
||||
'429_title' => 'Zu viele Anfragen',
|
||||
'429_body' => 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten und erneut versuchen.',
|
||||
|
||||
'500_title' => 'Unerwarteter Fehler',
|
||||
'500_body' => 'Auf dem Server ist ein unerwarteter Fehler aufgetreten. Bitte später erneut versuchen.',
|
||||
|
||||
'503_title' => 'Wartungsmodus',
|
||||
'503_body' => 'Clusev wird gerade gewartet. Bitte in Kürze erneut versuchen.',
|
||||
];
|
||||
|
|
@ -24,6 +24,7 @@ return [
|
|||
// Upload / notifications
|
||||
'uploaded' => '„:name“ hochgeladen.',
|
||||
'upload_failed' => 'Upload fehlgeschlagen: :error',
|
||||
'download_failed' => 'Download fehlgeschlagen: :error',
|
||||
|
||||
// Delete (confirm modal)
|
||||
'delete_heading' => 'Datei löschen',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
// Custom error pages (resources/views/errors/*). Never show a raw status text or
|
||||
// stack trace — always a friendly, localized message (R16). English translation.
|
||||
return [
|
||||
'back_home' => 'Back to dashboard',
|
||||
'generic_title' => 'Error',
|
||||
|
||||
'403_title' => 'Access denied',
|
||||
'403_body' => 'You do not have permission to view this page.',
|
||||
|
||||
'404_title' => 'Page not found',
|
||||
'404_body' => 'This page does not exist or has moved.',
|
||||
|
||||
'419_title' => 'Session expired',
|
||||
'419_body' => 'Your session has expired. Please reload the page and sign in again.',
|
||||
|
||||
'429_title' => 'Too many requests',
|
||||
'429_body' => 'Too many requests in a short time. Please wait a moment and try again.',
|
||||
|
||||
'500_title' => 'Unexpected error',
|
||||
'500_body' => 'An unexpected error occurred on the server. Please try again later.',
|
||||
|
||||
'503_title' => 'Down for maintenance',
|
||||
'503_body' => 'Clusev is undergoing maintenance. Please check back shortly.',
|
||||
];
|
||||
|
|
@ -24,6 +24,7 @@ return [
|
|||
// Upload / notifications
|
||||
'uploaded' => '“:name” uploaded.',
|
||||
'upload_failed' => 'Upload failed: :error',
|
||||
'download_failed' => 'Download failed: :error',
|
||||
|
||||
// Delete (confirm modal)
|
||||
'delete_heading' => 'Delete file',
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 1.3 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Clusev">
|
||||
<rect x="1" y="1" width="30" height="30" rx="7" fill="#0B0E12" stroke="#FF6B2C" stroke-opacity="0.45" stroke-width="1.4"/>
|
||||
<g transform="translate(4 4)" fill="none" stroke="#FF6B2C" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
||||
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
||||
<path d="M6 6h.01"/>
|
||||
<path d="M6 18h.01"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 517 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "Clusev",
|
||||
"short_name": "Clusev",
|
||||
"description": "Self-hosted control panel to administer a fleet of Linux servers from one dashboard.",
|
||||
"lang": "de",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
|
||||
],
|
||||
"theme_color": "#06080A",
|
||||
"background_color": "#06080A",
|
||||
"display": "standalone",
|
||||
"start_url": "/"
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.403_title')])
|
||||
@section('code', '403')
|
||||
@section('heading', __('errors.403_title'))
|
||||
@section('message', __('errors.403_body'))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.404_title')])
|
||||
@section('code', '404')
|
||||
@section('heading', __('errors.404_title'))
|
||||
@section('message', __('errors.404_body'))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.419_title')])
|
||||
@section('code', '419')
|
||||
@section('heading', __('errors.419_title'))
|
||||
@section('message', __('errors.419_body'))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.429_title')])
|
||||
@section('code', '429')
|
||||
@section('heading', __('errors.429_title'))
|
||||
@section('message', __('errors.429_body'))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.500_title')])
|
||||
@section('code', '500')
|
||||
@section('heading', __('errors.500_title'))
|
||||
@section('message', __('errors.500_body'))
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
@extends('errors.layout', ['title' => __('errors.503_title')])
|
||||
@section('code', '503')
|
||||
@section('heading', __('errors.503_title'))
|
||||
@section('message', __('errors.503_body'))
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ ($title ?? __('errors.generic_title')).' — Clusev' }}</title>
|
||||
@include('partials.head-icons')
|
||||
@vite(['resources/css/app.css'])
|
||||
</head>
|
||||
{{-- Self-contained: no Livewire/session/auth deps, so it renders even when those
|
||||
are the thing that broke. Design tokens come from the bundled CSS (R3). --}}
|
||||
<body class="grid min-h-screen place-items-center bg-void font-sans text-ink antialiased">
|
||||
<main class="w-full max-w-md px-6 py-10 text-center">
|
||||
<div class="mb-8 flex items-center justify-center gap-2.5">
|
||||
<span class="grid h-9 w-9 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent shadow-[0_0_18px_-2px_var(--color-accent)]">
|
||||
<x-icon name="server" class="h-5 w-5" />
|
||||
</span>
|
||||
<span class="font-display text-xl font-semibold tracking-wide text-ink">Clus<span class="text-accent">e</span>v</span>
|
||||
</div>
|
||||
|
||||
<p class="font-mono text-[64px] font-bold leading-none text-accent/25 sm:text-[80px]">@yield('code')</p>
|
||||
<h1 class="mt-3 font-display text-2xl font-semibold text-ink">@yield('heading')</h1>
|
||||
<p class="mx-auto mt-3 max-w-sm text-sm leading-relaxed text-ink-2">@yield('message')</p>
|
||||
|
||||
<div class="mt-8">
|
||||
<x-btn href="/" variant="secondary" size="lg">
|
||||
<x-icon name="chevron-left" class="h-3.5 w-3.5" /> {{ __('errors.back_home') }}
|
||||
</x-btn>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Clusev' }}</title>
|
||||
@include('partials.head-icons')
|
||||
<script>
|
||||
window.__clusev = { reverb: {
|
||||
key: @js($reverb['key']),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'Clusev' }}</title>
|
||||
@include('partials.head-icons')
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
{{-- Favicons + PWA manifest. Assets live in public/ (served directly, not via Vite):
|
||||
favicon.svg (scalable, primary), favicon.ico (legacy 16/32/48), apple-touch-icon,
|
||||
icon-192/512 + site.webmanifest. theme-color matches the void background. --}}
|
||||
<link rel="icon" href="/favicon.ico" sizes="32x32">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="theme-color" content="#06080A">
|
||||
Loading…
Reference in New Issue