Move the navigation into a floating toolbar at the bottom

The top app bar repeated the site's name, which already heads the upload
and download pages, and spread its actions to the window's far edge. A
floating toolbar centred above the bottom edge, as wide as its buttons,
now holds them: Upload and the admin pages with the current one filled,
and the account menu; guests get Upload, the theme toggle and Log in,
without tooltips. On a phone the sign-in card no longer stretches to the
full height, and the admin dashboard's empty state sits outside the
scrolling table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 11:12:12 +02:00
co-authored by Claude Opus 5
parent 2a363defd1
commit accbc17c1c
8 changed files with 161 additions and 98 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo.
- The theme follows the system's light or dark setting until a user picks Light, Dark or System in Settings → Appearance, or from the account menu. A theme chosen in 1.x is kept.
- A single top app bar replaces the sidebar and header layouts. Signed-in users reach Upload, the admin pages, Settings and Log out from the account menu.
- A floating toolbar centred at the bottom of every page replaces the sidebar and header layouts. Signed-in users reach Upload and the admin pages from it, and Settings, the theme and Log out from its account menu. The site's name and logo head the upload and download pages.
- Confirmations for deleting a share, removing the logo, clearing the system password and deleting the account are dialogs instead of browser prompts, and "Saved." messages are snackbars.
- The two-factor setup opens full screen on a phone.
- HTTP error pages and Markdown mail (password reset, email verification) use the same Material design and colours. Set `MAIL_MARKDOWN_THEME=default` to get Laravel's mail theme back.
+4 -4
View File
@@ -3,13 +3,13 @@
<head>
@include('partials.head')
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
@include('partials.app-bar')
<main class="mx-auto w-full max-w-5xl px-4 pt-4 pb-16 sm:px-6">
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
<main class="mx-auto w-full max-w-5xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12">
{{ $slot }}
</main>
@include('partials.toolbar')
<x-toast />
</body>
</html>
+4 -4
View File
@@ -3,10 +3,8 @@
<head>
@include('partials.head')
</head>
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased">
@include('partials.app-bar')
<main class="flex flex-1 justify-center px-4 pt-6 pb-16 sm:items-center sm:pt-0">
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
<main class="flex flex-1 items-start justify-center px-4 pt-8 pb-32 sm:items-center">
<div class="w-full max-w-md rounded-corner-xl bg-surface-container-low p-6 sm:p-8">
<div class="flex flex-col gap-6">
{{ $slot }}
@@ -14,6 +12,8 @@
</div>
</main>
@include('partials.toolbar')
<x-toast />
</body>
</html>
@@ -11,6 +11,10 @@
</div>
<x-card :title="__('All Shares')" variant="outlined">
{{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}}
@if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else
<div class="-mx-4 overflow-x-auto">
<x-table>
<thead>
@@ -25,7 +29,7 @@
</tr>
</thead>
<tbody>
@forelse ($shares as $share)
@foreach ($shares as $share)
<tr wire:key="share-{{ $share->id }}">
<td class="font-mono">{{ $share->token }}</td>
<td class="text-end tabular-nums">{{ $share->files_count }}</td>
@@ -44,18 +48,13 @@
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
</td>
</tr>
@empty
<tr>
<td colspan="7">
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
</td>
</tr>
@endforelse
@endforeach
</tbody>
</x-table>
</div>
<div class="mt-4">{{ $shares->links() }}</div>
@endif
</x-card>
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
@@ -1,47 +0,0 @@
{{-- The one top app bar on every page: the site's logo and title, then the account menu for a
signed-in user, or the theme toggle and a way to sign in for everyone else. --}}
@php
$siteTitle = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare');
$siteLogo = \App\Models\Setting::get('site_logo');
@endphp
<x-app-bar>
<x-slot:navigation>
<a href="{{ route('home') }}" class="focus-ring flex min-w-0 items-center gap-3 rounded-corner-full py-2 ps-3 pe-4" data-test="app-bar-home">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="" class="h-8 w-auto" />
@else
<x-app-logo-icon class="size-8 shrink-0 text-primary" />
@endif
<span class="truncate type-title-lg text-on-surface">{{ $siteTitle }}</span>
</a>
</x-slot:navigation>
<x-slot:actions>
@auth
<span class="me-2 inline-flex">
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
<x-menu-item :label="__('Upload')" icon="upload" :link="route('upload')" />
@if (auth()->user()->is_admin)
<x-menu-item :label="__('Admin dashboard')" icon="dashboard" :link="route('admin.dashboard')" />
<x-menu-item :label="__('Admin settings')" icon="admin_panel_settings" :link="route('admin.settings')" />
@endif
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item :label="__('Log out')" icon="logout" type="submit" data-test="logout-button" />
</form>
</x-slot:footer>
</x-account-menu>
</span>
@else
<x-theme-toggle />
@if (Route::has('login') && ! request()->routeIs('login'))
<span class="me-1 inline-flex"><x-button :label="__('Log in')" :link="route('login')" /></span>
@endif
@endauth
</x-slot:actions>
</x-app-bar>
@@ -0,0 +1,44 @@
{{-- The one navigation on every page: a floating toolbar centred above the bottom edge, as wide as
its buttons. The site's name and logo head the upload and download pages instead of a bar.
A signed-in user gets the pages they can reach and the account menu; everyone else gets the
upload page, the theme toggle and a way to sign in without tooltips, so the download page
stays free of anchored components. The current page's button is filled. --}}
@php
$onUpload = request()->routeIs('upload', 'share.created');
$onDashboard = request()->routeIs('admin.dashboard');
$onAdminSettings = request()->routeIs('admin.settings');
@endphp
<nav aria-label="{{ __('Main') }}">
<x-toolbar :label="__('Main')" place="bottom" data-test="app-toolbar">
@auth
<x-button icon="upload" :tooltip="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
@if (auth()->user()->is_admin)
<x-button icon="dashboard" :tooltip="__('Admin dashboard')" :link="route('admin.dashboard')" :variant="$onDashboard ? 'filled' : 'text'" :aria-current="$onDashboard ? 'page' : null" />
<x-button icon="admin_panel_settings" :tooltip="__('Admin settings')" :link="route('admin.settings')" :variant="$onAdminSettings ? 'filled' : 'text'" :aria-current="$onAdminSettings ? 'page' : null" />
@endif
<span class="ms-1 inline-flex">
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item :label="__('Log out')" icon="logout" type="submit" data-test="logout-button" />
</form>
</x-slot:footer>
</x-account-menu>
</span>
@else
<x-button icon="upload" :aria-label="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
<x-theme-toggle />
@if (Route::has('login') && ! request()->routeIs('login'))
<x-button :label="__('Log in')" :link="route('login')" />
@endif
@endauth
</x-toolbar>
</nav>
@@ -87,3 +87,12 @@ test('the shares table sorts only by its own columns', function () {
expect(Share::query()->count())->toBe(2);
});
test('without shares the dashboard shows an empty state instead of the table', function () {
$admin = User::query()->where('is_admin', true)->first();
$this->actingAs($admin)->get(route('admin.dashboard'))
->assertOk()
->assertSee('No shares yet')
->assertDontSee('<table', false);
});
+58
View File
@@ -0,0 +1,58 @@
<?php
use App\Models\User;
/**
* The page's main navigation, the floating toolbar at the bottom.
*/
function toolbar(string $html): string
{
preg_match('/<nav aria-label="Main">.*?<\/nav>/s', $html, $matches);
return $matches[0] ?? '';
}
/**
* The opening tag of the toolbar's link to a URL.
*/
function toolbarLink(string $html, string $url): string
{
preg_match('/<a\s[^>]*href="'.preg_quote($url, '/').'"[^>]*>/', toolbar($html), $matches);
return $matches[0] ?? '';
}
test('pages have a floating toolbar at the bottom instead of a top app bar', function () {
$html = $this->get(route('upload'))->assertOk()->getContent();
expect($html)->not->toContain('data-app-bar')
->and(toolbar($html))->toContain('role="toolbar"')->toContain('data-toolbar-place="bottom"');
});
test('a guest gets the upload page, the theme toggle and a way to log in, without tooltips', function () {
$html = $this->get(route('upload'))->getContent();
expect(toolbarLink($html, route('upload')))->toContain('aria-current="page"')->toContain('aria-label="Upload"')
->and(toolbarLink($html, route('login')))->not->toBe('')
->and(toolbar($html))->toContain('Log in')->toContain('data-theme-toggle')->not->toContain('popover')->not->toContain('data-account-menu');
expect(toolbar($this->get(route('login'))->getContent()))->not->toContain(route('login').'"');
});
test('an admin gets the admin pages and the account menu, the current page marked', function () {
$admin = User::query()->where('is_admin', true)->first();
$html = $this->actingAs($admin)->get(route('admin.dashboard'))->assertOk()->getContent();
expect(toolbarLink($html, route('admin.dashboard')))->toContain('aria-current="page"')
->and(toolbarLink($html, route('upload')))->not->toContain('aria-current')
->and(toolbarLink($html, route('admin.settings')))->not->toContain('aria-current')
->and(toolbar($html))->toContain('data-account-menu')->toContain('data-test="logout-button"')->not->toContain('Log in');
});
test('a user who is not an admin gets no admin pages', function () {
$html = $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('profile.edit'))->assertOk()->getContent();
expect(toolbarLink($html, route('upload')))->not->toBe('')
->and(toolbar($html))->not->toContain(route('admin.dashboard'))->not->toContain(route('admin.settings'));
});