diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c419c..27d0cb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 675c514..14f65b6 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -3,13 +3,13 @@ @include('partials.head') - - @include('partials.app-bar') - -
+ +
{{ $slot }}
+ @include('partials.toolbar') + diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index 15c3406..e99c395 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -3,10 +3,8 @@ @include('partials.head') - - @include('partials.app-bar') - -
+ +
{{ $slot }} @@ -14,6 +12,8 @@
+ @include('partials.toolbar') + diff --git a/resources/views/livewire/admin/admin-dashboard.blade.php b/resources/views/livewire/admin/admin-dashboard.blade.php index e0094b9..8180292 100644 --- a/resources/views/livewire/admin/admin-dashboard.blade.php +++ b/resources/views/livewire/admin/admin-dashboard.blade.php @@ -11,51 +11,50 @@ -
- - - - {{ __('Token') }} - {{ __('Files') }} - {{ __('Size') }} - {{ __('Downloads') }} - {{ __('Expires') }} - {{ __('Created') }} - {{ __('Actions') }} - - - - @forelse ($shares as $share) - - {{ $share->token }} - {{ $share->files_count }} - {{ Number::fileSize($share->total_size) }} - {{ $share->download_count }} - - @if ($share->expires_at) - $share->isExpired()])>{{ $share->expires_at->diffForHumans() }} - @else - {{ __('Never') }} - @endif - - {{ $share->created_at->diffForHumans() }} - - - - - - @empty + {{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}} + @if ($shares->total() === 0) + + @else +
+ + - - - + {{ __('Token') }} + {{ __('Files') }} + {{ __('Size') }} + {{ __('Downloads') }} + {{ __('Expires') }} + {{ __('Created') }} + {{ __('Actions') }} - @endforelse - - -
+ + + @foreach ($shares as $share) + + {{ $share->token }} + {{ $share->files_count }} + {{ Number::fileSize($share->total_size) }} + {{ $share->download_count }} + + @if ($share->expires_at) + $share->isExpired()])>{{ $share->expires_at->diffForHumans() }} + @else + {{ __('Never') }} + @endif + + {{ $share->created_at->diffForHumans() }} + + + + + + @endforeach + +
+
-
{{ $shares->links() }}
+
{{ $shares->links() }}
+ @endif
diff --git a/resources/views/partials/app-bar.blade.php b/resources/views/partials/app-bar.blade.php deleted file mode 100644 index ebcf04f..0000000 --- a/resources/views/partials/app-bar.blade.php +++ /dev/null @@ -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 - - - - - @if ($siteLogo) - - @else - - @endif - {{ $siteTitle }} - - - - - @auth - - - - @if (auth()->user()->is_admin) - - - @endif - - - -
- @csrf - - -
-
-
- @else - - @if (Route::has('login') && ! request()->routeIs('login')) - - @endif - @endauth -
-
diff --git a/resources/views/partials/toolbar.blade.php b/resources/views/partials/toolbar.blade.php new file mode 100644 index 0000000..513f61f --- /dev/null +++ b/resources/views/partials/toolbar.blade.php @@ -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 + + diff --git a/tests/Feature/Admin/AdminDashboardTest.php b/tests/Feature/Admin/AdminDashboardTest.php index 1c07199..d905d69 100644 --- a/tests/Feature/Admin/AdminDashboardTest.php +++ b/tests/Feature/Admin/AdminDashboardTest.php @@ -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('.*?<\/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('/]*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')); +});