diff --git a/.ai/rules/views.md b/.ai/rules/views.md index fdb7adc..a93b5dd 100644 --- a/.ai/rules/views.md +++ b/.ai/rules/views.md @@ -7,3 +7,6 @@ paths: ## `` drops data-test and other attributes `` (Livewire Material) keeps only class, style and wire:key on its fieldset and wire:model/x-model on its inputs; data-test, id and every other attribute are silently dropped. Tests reach a group through its binding instead, e.g. assertSeeHtml('wire:model.live="passwordGeneratorType"') or input[value="…"]. Rendering `` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it). + +## Every page renders +Every page (Livewire page, settings SFC via pages/settings/layout, Fortify auth view) has (resources/views/components/page.blade.php) at its root, inside layouts/app — the only layout. It draws the centred h1 header (`brand` for the site's logo/title/description on public and sign-in pages, or title/description, optional `mark` and `navigation` slots) over one centred column. Every page is the same 40rem column and has no width prop: content that needs more room is rearranged to fit (the admin dashboard's shares are a list with a sort select, not a table). Content goes in outlined cards (``). Never give a page its own width class, h1 or header stack. tests/Feature/PageTemplateTest.php lists every page. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6969dd9..02bf397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The colour scheme is regenerated with Material 3's 2025 colour rules, at all three contrast levels. The colour profile an admin chose, and each visitor's light or dark setting, carry over unchanged. - The settings pages — Profile, Update password, Two Factor Authentication and Appearance — are shown as cards, the way Admin settings already were. Deleting the account and the two-factor recovery codes each sit in a card of their own beside the page's. - A form field now fills the card that holds it instead of stopping short of its edge. +- Every page shares one layout, the one the share pages already had: a centred heading over one column of cards, the same width on every page. To fit it, the admin dashboard lists its shares instead of a table: each share's token opens it, its files, size, downloads and expiry sit underneath, and a "Sort by" select replaces the column headers. The sign-in, password reset, two-factor, setup and system password pages lose their separate tinted card: they are headed by the site's logo, title and description, like the upload and download pages, with the form in a card below. ### Fixed diff --git a/app/Livewire/Admin/AdminDashboard.php b/app/Livewire/Admin/AdminDashboard.php index 525f9a0..35fd92f 100644 --- a/app/Livewire/Admin/AdminDashboard.php +++ b/app/Livewire/Admin/AdminDashboard.php @@ -15,14 +15,20 @@ class AdminDashboard extends Component use WithPagination; /** - * The columns the table can be sorted by. + * The orders the shares list offers, each a column and a direction. * - * @var list + * @var array */ - public const SORTABLE = ['token', 'files_count', 'total_size', 'download_count', 'expires_at', 'created_at']; + public const SORTS = [ + 'newest' => ['created_at', 'desc'], + 'oldest' => ['created_at', 'asc'], + 'expiring' => ['expires_at', 'asc'], + 'largest' => ['total_size', 'desc'], + 'most-downloaded' => ['download_count', 'desc'], + 'most-files' => ['files_count', 'desc'], + ]; - /** @var array{column: string, direction: string} */ - public array $sortBy = ['column' => 'created_at', 'direction' => 'desc']; + public string $sort = 'newest'; /** The share the delete dialog is asking about, while it is open. */ public ?int $deletingShareId = null; @@ -35,19 +41,29 @@ class AdminDashboard extends Component $this->deletingShareId = null; } + /** + * A new order starts again from the first page. + */ + public function updatedSort(): void + { + $this->resetPage(); + } + public function render(): mixed { $shareService = app(ShareService::class); - // The sort comes from the browser: only a known column and direction reach the query. - $column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at'; - $direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc'; + // The sort comes from the browser: only a known order reaches the query. + [$column, $direction] = self::SORTS[$this->sort] ?? self::SORTS['newest']; // Shares whose files are still being uploaded are not shares yet; their bytes do count as used space. $shares = Share::query() ->whereNotNull('completed_at') ->withCount('files') + // Shares that never expire come after every share that does, whichever way expiry is sorted. + ->when($column === 'expires_at', fn ($query) => $query->orderByRaw('expires_at is null')) ->orderBy($column, $direction) + ->orderByDesc('id') ->paginate(15); return view('livewire.admin.admin-dashboard', [ diff --git a/app/Livewire/FileUploader.php b/app/Livewire/FileUploader.php index cc8430d..8b40c78 100644 --- a/app/Livewire/FileUploader.php +++ b/app/Livewire/FileUploader.php @@ -193,9 +193,6 @@ class FileUploader extends Component 'pendingFiles' => $pendingFiles, 'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null), 'isStorageFull' => $shareService->isStorageFull(), - 'siteTitle' => Setting::get('site_title'), - 'siteDescription' => Setting::get('site_description'), - 'siteLogo' => Setting::get('site_logo'), 'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false), 'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(), ]); diff --git a/app/Livewire/SetupWizard.php b/app/Livewire/SetupWizard.php index e603c4a..256b735 100644 --- a/app/Livewire/SetupWizard.php +++ b/app/Livewire/SetupWizard.php @@ -10,7 +10,7 @@ use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; use Livewire\Component; -#[Layout('layouts.auth')] +#[Layout('layouts.app')] class SetupWizard extends Component { #[Validate('required|string|max:255')] diff --git a/app/Livewire/ShareDownload.php b/app/Livewire/ShareDownload.php index 8aa58be..467853a 100644 --- a/app/Livewire/ShareDownload.php +++ b/app/Livewire/ShareDownload.php @@ -2,7 +2,6 @@ namespace App\Livewire; -use App\Models\Setting; use App\Models\Share; use App\Services\ShareService; use Illuminate\Support\Facades\RateLimiter; @@ -66,10 +65,6 @@ class ShareDownload extends Component public function render(): mixed { - return view('livewire.share-download', [ - 'siteTitle' => Setting::get('site_title'), - 'siteDescription' => Setting::get('site_description'), - 'siteLogo' => Setting::get('site_logo'), - ]); + return view('livewire.share-download'); } } diff --git a/app/Livewire/SystemPasswordPrompt.php b/app/Livewire/SystemPasswordPrompt.php index 29bdf34..38672bc 100644 --- a/app/Livewire/SystemPasswordPrompt.php +++ b/app/Livewire/SystemPasswordPrompt.php @@ -8,7 +8,7 @@ use Livewire\Attributes\Layout; use Livewire\Attributes\Validate; use Livewire\Component; -#[Layout('layouts.auth')] +#[Layout('layouts.app')] class SystemPasswordPrompt extends Component { #[Validate('required|string')] diff --git a/resources/css/app.css b/resources/css/app.css index 0e80a54..00afa2b 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -31,9 +31,7 @@ @import '../../vendor/nonameweb/livewire-material/resources/css/components/section-nav.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/select.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/shape.css'; -@import '../../vendor/nonameweb/livewire-material/resources/css/components/sort-header.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/stat.css'; -@import '../../vendor/nonameweb/livewire-material/resources/css/components/table.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/textarea.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css'; @import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.css'; @@ -43,23 +41,21 @@ /* * SealShare's own rules, unlayered so they outrank every package rule: one section per view, in - * the order a visitor meets them — the two layouts, the share flow (upload, share created, - * download), the settings pages in their navigation's order, then admin. + * the order a visitor meets them — the layout and the page template, the share flow (upload, + * share created, download), the settings pages in their navigation's order, then admin. */ /* - * resources/views/layouts/app.blade.php: the signed-in and public pages' main column. + * resources/views/layouts/app.blade.php: every page's main region. * - * `` gives the column its horizontal M3 margin (16px below `medium`, 24px from - * it) and centres it; its cap is SealShare's own 64rem, margins included, which no `width` preset - * (40, 60, 80rem) matches. The vertical rhythm is the app's own. The bottom padding clears the - * floating toolbar in partials/toolbar.blade.php by what the toolbar publishes as - * `--material-bottom-toolbar` (its top edge's distance from the window's bottom, safe area - * included), plus 16px. Never set `--material-bottom-bar` here: the toolbar reads it to place - * itself. + * `` gives the region its horizontal M3 margin (16px below `medium`, 24px from + * it); the page inside (components/page.blade.php) sets its own width and centres itself. The + * vertical rhythm is the app's own. The bottom padding clears the floating toolbar in + * partials/toolbar.blade.php by what the toolbar publishes as `--material-bottom-toolbar` (its top + * edge's distance from the window's bottom, safe area included), plus 16px. Never set + * `--material-bottom-bar` here: the toolbar reads it to place itself. */ .app-main { - max-inline-size: 64rem; padding-block-start: var(--md-sys-measurement-space400); padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200)); } @@ -70,52 +66,8 @@ } } -/* - * resources/views/layouts/auth.blade.php: the centred sign-in card. - * - * The card sits in a full-height flex column that top-aligns it below `medium` and centres it - * from there. The bottom padding clears the floating toolbar exactly as .app-main's does (see - * above); the horizontal padding and top padding stay flat at every width, as the 1.x layout had. - */ -.auth-main { - display: flex; - justify-content: center; - align-items: flex-start; - min-block-size: 100dvh; - padding-inline: var(--md-sys-measurement-space200); - padding-block-start: var(--md-sys-measurement-space400); - padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200)); -} - -@media (width >= 600px) { - .auth-main { - align-items: center; - } -} - -.auth-card { - inline-size: 100%; - max-inline-size: 28rem; -} - -@media (width >= 600px) { - .auth-card { - padding: var(--md-sys-measurement-space400); - } -} - -/* - * resources/views/livewire/file-uploader.blade.php: the upload page, kept to 1.x's centred 48rem - * measure (max-w-3xl) inside the 64rem main column, which no `` width preset (40, 60, 80rem) - * matches. Its text fields stop at 40rem from `medium`; at 64rem the drop zone, the divider and the - * end-aligned submit ran some 21rem past the fields' edge, at 48rem 8rem. - */ -.upload-column { - max-inline-size: 48rem; -} - -/* resources/views/livewire/file-uploader.blade.php: the site's own mark above the title, at 1.x's 5rem-tall size, its width following the image. */ -.upload-site-logo { +/* resources/views/components/page.blade.php: the site's own logo above the title on a `brand` page, at 1.x's 5rem-tall size, its width following the image. */ +.page-logo { block-size: 5rem; } @@ -209,14 +161,6 @@ overflow-y: auto; } -/* - * resources/views/livewire/share-created.blade.php: the page a share's link is ready on, kept to - * 1.x's centred 32rem measure, which no `` width preset (40, 60, 80rem) matches. - */ -.share-column { - max-inline-size: 32rem; -} - /* * resources/views/livewire/share-created.blade.php: the check that settles onto its Expressive * shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape @@ -299,35 +243,6 @@ block-size: 100%; } -/* - * resources/views/livewire/share-download.blade.php: the page a recipient opens, kept to the same - * centred 32rem measure as share-created.blade.php. - */ -.download-column { - max-inline-size: 32rem; -} - -/* resources/views/livewire/share-download.blade.php: the site's own mark above the title, at 1.x's 5rem-tall size. */ -.download-site-logo { - block-size: 5rem; -} - -/* resources/views/partials/settings-heading.blade.php: the settings pages' shared heading, spaced above their section navigation and content. */ -.settings-heading { - margin-block-end: var(--md-sys-measurement-space300); -} - -/* - * resources/views/pages/settings/layout.blade.php: the settings cards inside the 64rem main - * column. 1.x kept the form itself to 32rem (max-w-lg) under a full-width heading; the card now - * holds both, so the cap moves to the card and gains the card's own 16dp of side padding — 34rem, - * which leaves the fields at 1.x's measure. The section navigation above stays full width, as it - * was. - */ -.settings-column { - max-inline-size: 34rem; -} - /* * resources/views/pages/settings/two-factor.blade.php: the setup QR code. Fortify's own * twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService @@ -367,7 +282,7 @@ /* * resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable - * width instead of stretching the full settings column. No `` width prop caps it, and + * width instead of stretching across the settings card. No `` width prop caps it, and * 24rem matches no `` preset. */ .settings-appearance-picker { @@ -375,20 +290,27 @@ } /* - * resources/views/livewire/admin/admin-dashboard.blade.php: the shares table scrolls sideways on - * its own, on a window too narrow for every column, instead of the page around it. + * resources/views/livewire/admin/admin-dashboard.blade.php: the sort select above the shares list + * keeps to the width its longest option needs instead of spanning the card. No `` width + * prop caps it, and 20rem matches no `` preset. */ -.admin-shares-table-scroll { - overflow-x: auto; +.admin-shares-sort { + max-inline-size: 20rem; } /* - * resources/views/livewire/admin/admin-settings.blade.php: the settings keep 1.x's own narrower - * measure inside the 64rem main column, 42rem, which no `` width preset (40, 60, 80rem) - * matches; the pane centres it. + * resources/views/livewire/admin/admin-dashboard.blade.php: a share's details are two lines of + * their own (its files, size and downloads; its expiry), and they wrap rather than clip. The + * package clamps a list item's description at two lines with an ellipsis, which on a phone hid + * the expiry with no way to read it. */ -.admin-settings { - max-inline-size: 42rem; +.admin-shares [data-md-list-item-description] { + display: block; + overflow: visible; +} + +.admin-share-detail { + display: block; } /* diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php deleted file mode 100644 index d11af56..0000000 --- a/resources/views/components/auth-header.blade.php +++ /dev/null @@ -1,9 +0,0 @@ -@props([ - 'title', - 'description', -]) - - -

{{ $title }}

-

{{ $description }}

-
diff --git a/resources/views/components/page.blade.php b/resources/views/components/page.blade.php new file mode 100644 index 0000000..34fcaa2 --- /dev/null +++ b/resources/views/components/page.blade.php @@ -0,0 +1,57 @@ +{{-- Every page in SealShare: a centred header over one centred column of cards, the share pages' + shape carried to every other page, sign-in included. + + + + + + + `brand` heads the page with the site's own logo, title and description from Admin settings + instead of `title` and `description`, falling back to the app's name and SealShare's line. + `mark` is a visual above the title (share created's check). Every page is the same 40rem + column (``, M3's cap on a text field), so there is no width prop: content + that needs more room is rearranged to fit, as the admin dashboard's shares became a list. No + page sets a width or a heading of its own. The page's content stacks 24px apart under the + header and the optional `navigation`. --}} + +@props([ + 'title' => null, + 'description' => null, + 'brand' => false, +]) + +@php + $logo = null; + + if ($brand) { + $title = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare'); + $description = \App\Models\Setting::get('site_description') ?: __('Share your files safely and securely'); + $logo = \App\Models\Setting::get('site_logo'); + } +@endphp + + + + + @if ($logo) + + @endif + + {{ $mark ?? '' }} + + +

{{ $title }}

+ + @if (filled($description)) +

{{ $description }}

+ @endif +
+
+ + {{ $navigation ?? '' }} + + + {{ $slot }} + +
+
diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php deleted file mode 100644 index 9dde147..0000000 --- a/resources/views/layouts/auth.blade.php +++ /dev/null @@ -1,19 +0,0 @@ - - - - @include('partials.head') - - -
- - - {{ $slot }} - - -
- - @include('partials.toolbar') - - - - diff --git a/resources/views/livewire/admin/admin-dashboard.blade.php b/resources/views/livewire/admin/admin-dashboard.blade.php index a6dd1ea..b1922c5 100644 --- a/resources/views/livewire/admin/admin-dashboard.blade.php +++ b/resources/views/livewire/admin/admin-dashboard.blade.php @@ -1,7 +1,5 @@ - -

{{ __('Admin Dashboard') }}

- - + + @@ -10,49 +8,55 @@ + {{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and + every page keeps that one width. The sort is a select above the list instead of column headers. --}} @if ($shares->total() === 0) @else -
- - - - {{ __('Token') }} - {{ __('Files') }} - {{ __('Size') }} - {{ __('Downloads') }} - {{ __('Expires') }} - {{ __('Created') }} - {{ __('Actions') }} - - - - @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 - - +
+
+ {{-- Each share fits the column on a phone: the token opens it, so delete is the one button; + the details are two short lines that never clip (admin-shares, app.css). --}} + + @foreach ($shares as $share) + + {{ $share->token }} + + + {{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }} + + @if (! $share->expires_at) + {{ __('Never expires') }} + @elseif ($share->isExpired()) + {{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }} + @else + {{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }} + @endif + + + + + + + @endforeach + + {{ $shares->links() }} @endif @@ -66,4 +70,4 @@ - + diff --git a/resources/views/livewire/admin/admin-settings.blade.php b/resources/views/livewire/admin/admin-settings.blade.php index acd3cd0..f264936 100644 --- a/resources/views/livewire/admin/admin-settings.blade.php +++ b/resources/views/livewire/admin/admin-settings.blade.php @@ -1,227 +1,223 @@ - - -

{{ __('System Settings') }}

+ + + + + + + + + + + + + - - - - - + @if ($currentLogo) + + + + + @endif - - - + - - - - @if ($currentLogo) - - - - - @endif - - - - @if ($siteLogo && is_object($siteLogo)) - @if (str_contains($siteLogo->getMimeType(), 'svg')) -

{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}

- @else - -

{{ __('Preview:') }}

- -
- @endif - @endif -
-
-
- - - - - - - @if ($hasSystemPassword) - - @endif - - - - - {{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService). - The example is drawn from the form as it stands, before saving. --}} - - - - - @if ($passwordGeneratorMode !== 'off') - - - @if ($passwordGeneratorType === 'passphrase') - - - + @if ($siteLogo && is_object($siteLogo)) + @if (str_contains($siteLogo->getMimeType(), 'svg')) +

{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}

@else - - - - - - @endif - - @if ($passwordExample) - + +

{{ __('Preview:') }}

+ +
@endif @endif
-
+
+
- - - + + + - + @endif + + + + + {{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService). + The example is drawn from the form as it stands, before saving. --}} + + + + + @if ($passwordGeneratorMode !== 'off') + - + @if ($passwordGeneratorType === 'passphrase') + - + + @else + - - - + - - - - - + + @endif - - - -
+ @if ($passwordExample) + + @endif + @endif +
+ - - {{ __('The upload and download pages show the default mark again.') }} + + + - - - - - + - - {{ __('Anyone who can reach the upload page can upload files again.') }} + - - - - - - -
+ + + + + + + + + + + + + + + + + + + {{ __('The upload, download and sign-in pages show only the site title.') }} + + + + + + + + + {{ __('Anyone who can reach the upload page can upload files again.') }} + + + + + + + diff --git a/resources/views/livewire/file-uploader.blade.php b/resources/views/livewire/file-uploader.blade.php index 0104498..b7d5d4b 100644 --- a/resources/views/livewire/file-uploader.blade.php +++ b/resources/views/livewire/file-uploader.blade.php @@ -1,178 +1,164 @@ - - - - @if ($siteLogo) - + + {{-- Files this page already uploaded count towards the quota: they can still become a share. --}} + @if ($isStorageFull && $pendingFiles->isEmpty()) + + @else + + {{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}} +
+ +
+ + {{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}} +
+ +
+ + + +
+ + +

{{ __('Drag & drop files or folders here') }}

+

{{ __('or click to browse') }}

+
+ + {{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}} + + +
+
+ + {{-- Upload progress, over every file still to send --}} +
+ + + {{ __('Uploading...') }} % + + + + +
+ + @error('files') + {{ $message }} + @enderror + + {{-- Selected files --}} + @if ($pendingFiles->isNotEmpty()) + +

{{ __('Selected Files') }} ({{ $pendingFiles->count() }})

+ +
+ + @foreach ($pendingFiles as $file) + + + {{ Number::fileSize($file->file_size) }} + · + + + + + + + + + @endforeach + +
+
@endif - -

{{ $siteTitle ?: config('app.name', 'SealShare') }}

+ {{-- Options --}} + + + -

{{ $siteDescription ?: __('Share your files safely and securely') }}

-
-
+ @if ($usePassword) + + - {{-- Files this page already uploaded count towards the quota: they can still become a share. --}} - @if ($isStorageFull && $pendingFiles->isEmpty()) - - @else - - {{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}} -
- -
+ {{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes + whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}} + + @if ($passwordGeneratorMode !== 'off') + + @endif - {{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}} -
- -
- - - -
- - -

{{ __('Drag & drop files or folders here') }}

-

{{ __('or click to browse') }}

+ +
+ @endif - {{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}} - - -
-
- - {{-- Upload progress, over every file still to send --}} -
- - - {{ __('Uploading...') }} % - - - - -
- - @error('files') - {{ $message }} - @enderror - - {{-- Selected files --}} - @if ($pendingFiles->isNotEmpty()) - -

{{ __('Selected Files') }} ({{ $pendingFiles->count() }})

- -
- - @foreach ($pendingFiles as $file) - - - {{ Number::fileSize($file->file_size) }} - · - - - - - - - - - @endforeach - -
-
- @endif - - {{-- Options --}} - - - - - @if ($usePassword) - - - - {{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes - whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}} - - @if ($passwordGeneratorMode !== 'off') - - @endif - - - - - @endif - - - - - - - - - - -
- @endif -
-
+ + + + + + + + + + @endif + diff --git a/resources/views/livewire/setup-wizard.blade.php b/resources/views/livewire/setup-wizard.blade.php index 7c405e0..753bc20 100644 --- a/resources/views/livewire/setup-wizard.blade.php +++ b/resources/views/livewire/setup-wizard.blade.php @@ -1,42 +1,42 @@ - - + + + + - - + - + - + - - - - - - - + + + + + + diff --git a/resources/views/livewire/share-created.blade.php b/resources/views/livewire/share-created.blade.php index 4e4bf10..813bb97 100644 --- a/resources/views/livewire/share-created.blade.php +++ b/resources/views/livewire/share-created.blade.php @@ -1,97 +1,90 @@ - + diff --git a/resources/views/livewire/share-download.blade.php b/resources/views/livewire/share-download.blade.php index b390949..307ec32 100644 --- a/resources/views/livewire/share-download.blade.php +++ b/resources/views/livewire/share-download.blade.php @@ -1,71 +1,58 @@ {{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on iOS before Safari 18.4, which cannot position them. --}} - - - - @if ($siteLogo) - - @endif + + {{-- Each state is one card under the page's h1: the card holds everything the recipient acts + on, and it is the shape SealShare has always shown them. --}} + @if (! $authenticated) + + + - -

{{ $siteTitle ?: config('app.name', 'SealShare') }}

-

{{ $siteDescription ?: __('Share your files safely and securely') }}

-
-
+ + + + + + @else + + + + + @foreach ($share->files as $file) + + {{ Number::fileSize($file->file_size) }} + + + + + @endforeach + - {{-- Each state is one card under the page's h1: the card holds everything the recipient acts - on, and it is the shape SealShare has always shown them. --}} - @if (! $authenticated) - - - - - - - - - - @else - - - - - @foreach ($share->files as $file) - - {{ Number::fileSize($file->file_size) }} - - - - - @endforeach - - - @if ($share->expires_at) -

{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}

- @endif -
- - - @if ($share->files->count() > 1) - - @else - - @endif - + @if ($share->expires_at) +

{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}

+ @endif
-
- @endif -
-
+ + + @if ($share->files->count() > 1) + + @else + + @endif + + + + @endif + diff --git a/resources/views/livewire/system-password-prompt.blade.php b/resources/views/livewire/system-password-prompt.blade.php index 9a312ff..ff55441 100644 --- a/resources/views/livewire/system-password-prompt.blade.php +++ b/resources/views/livewire/system-password-prompt.blade.php @@ -1,16 +1,16 @@ - - + + + + - - - - - - - - + + + + + + diff --git a/resources/views/pages/auth/confirm-password.blade.php b/resources/views/pages/auth/confirm-password.blade.php index 2aab2fd..987c513 100644 --- a/resources/views/pages/auth/confirm-password.blade.php +++ b/resources/views/pages/auth/confirm-password.blade.php @@ -1,24 +1,30 @@ - - + + + + + - + + @csrf - - @csrf + - - - - - - - + + + + + + + + diff --git a/resources/views/pages/auth/forgot-password.blade.php b/resources/views/pages/auth/forgot-password.blade.php index 5a19ed3..4b9e9a2 100644 --- a/resources/views/pages/auth/forgot-password.blade.php +++ b/resources/views/pages/auth/forgot-password.blade.php @@ -1,29 +1,33 @@ - - + + + + + - + + @csrf - - @csrf + - + + + + - - - - - -

- {{ __('Or, return to') }} - {{ __('log in') }} -

-
+

+ {{ __('Or, return to') }} + {{ __('log in') }} +

+ + + + diff --git a/resources/views/pages/auth/login.blade.php b/resources/views/pages/auth/login.blade.php index 89f079a..58ef1f5 100644 --- a/resources/views/pages/auth/login.blade.php +++ b/resources/views/pages/auth/login.blade.php @@ -1,44 +1,48 @@ - - + + + + + - + + @csrf - - @csrf + - + + - - + @if (Route::has('password.request')) + + + {{ __('Forgot your password?') }} + + + @endif + - @if (Route::has('password.request')) - - - {{ __('Forgot your password?') }} - - - @endif - + - - - - - - - + + + + + + + + diff --git a/resources/views/pages/auth/reset-password.blade.php b/resources/views/pages/auth/reset-password.blade.php index 01f892b..632d868 100644 --- a/resources/views/pages/auth/reset-password.blade.php +++ b/resources/views/pages/auth/reset-password.blade.php @@ -1,38 +1,42 @@ - - + + + + + - + + @csrf + - - @csrf - + - + - + - - - - - - - + + + + + + + + diff --git a/resources/views/pages/auth/two-factor-challenge.blade.php b/resources/views/pages/auth/two-factor-challenge.blade.php index b1a1d29..1bc6541 100644 --- a/resources/views/pages/auth/two-factor-challenge.blade.php +++ b/resources/views/pages/auth/two-factor-challenge.blade.php @@ -1,67 +1,65 @@ - - -
- -
+ + + + +

+ {{ __('Enter the authentication code provided by your authenticator application.') }} +

-
- -
+

+ {{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }} +

- - @csrf + + @csrf -
- -
+
+ +
-
- -
+
+ +
- - - -
+ + + +
-

- {{ __('or you can') }} - - -

-
-
+

+ {{ __('or you can') }} + + +

+ + + + diff --git a/resources/views/pages/auth/verify-email.blade.php b/resources/views/pages/auth/verify-email.blade.php index da20664..f3fac66 100644 --- a/resources/views/pages/auth/verify-email.blade.php +++ b/resources/views/pages/auth/verify-email.blade.php @@ -1,28 +1,34 @@ - - + + + + + @if (session('status') == 'verification-link-sent') + + {{ __('A new verification link has been sent to the email address you provided during registration.') }} + + @endif - @if (session('status') == 'verification-link-sent') - - {{ __('A new verification link has been sent to the email address you provided during registration.') }} - - @endif + + + @csrf + + + + - - - @csrf - - - - - - {{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end - edge, the page's two actions end-aligned below its content. --}} - - @csrf - - - - + {{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end + edge, the card's two actions end-aligned below its content. --}} + + @csrf + + + + + + + diff --git a/resources/views/pages/settings/layout.blade.php b/resources/views/pages/settings/layout.blade.php index 41bed54..fed8063 100644 --- a/resources/views/pages/settings/layout.blade.php +++ b/resources/views/pages/settings/layout.blade.php @@ -11,17 +11,17 @@ $items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')]; @endphp - - + + + + {{-- Every settings page is a card headed by its own title, as the admin's settings are. A page with a section that stands apart from that one subject — deleting the account, the recovery - codes — puts it in `after`, where it becomes a card of its own beside this one. --}} - - - {{ $slot }} - + codes — puts it in `after`, where it becomes a card of its own under this one. --}} + + {{ $slot }} + - {{ $after ?? '' }} - - + {{ $after ?? '' }} + diff --git a/resources/views/pages/settings/⚡appearance.blade.php b/resources/views/pages/settings/⚡appearance.blade.php index 2566516..f55ddf3 100644 --- a/resources/views/pages/settings/⚡appearance.blade.php +++ b/resources/views/pages/settings/⚡appearance.blade.php @@ -6,10 +6,6 @@ new class extends Component { // }; ?> -
- @include('partials.settings-heading') - - - - -
+ + + diff --git a/resources/views/pages/settings/⚡password.blade.php b/resources/views/pages/settings/⚡password.blade.php index 68da93d..38eab65 100644 --- a/resources/views/pages/settings/⚡password.blade.php +++ b/resources/views/pages/settings/⚡password.blade.php @@ -43,18 +43,14 @@ new class extends Component { } }; ?> -
- @include('partials.settings-heading') + + + + + - - - - - - - - - - - -
+ + + + + diff --git a/resources/views/pages/settings/⚡profile.blade.php b/resources/views/pages/settings/⚡profile.blade.php index 025f45d..2ecafee 100644 --- a/resources/views/pages/settings/⚡profile.blade.php +++ b/resources/views/pages/settings/⚡profile.blade.php @@ -80,40 +80,36 @@ new class extends Component { } }; ?> -
- @include('partials.settings-heading') + + + - - - + + - - + @if ($this->hasUnverifiedEmail) +

+ {{ __('Your email address is unverified.') }} - @if ($this->hasUnverifiedEmail) -

- {{ __('Your email address is unverified.') }} + +

- -

- - @if (session('status') === 'verification-link-sent') - {{ __('A new verification link has been sent to your email address.') }} - @endif + @if (session('status') === 'verification-link-sent') + {{ __('A new verification link has been sent to your email address.') }} @endif -
- - - - -
- - - @if ($this->showDeleteUser) - @endif - -
-
+ + + + + + + + + @if ($this->showDeleteUser) + + @endif + + diff --git a/resources/views/pages/settings/⚡two-factor.blade.php b/resources/views/pages/settings/⚡two-factor.blade.php index e458d97..331a4cd 100644 --- a/resources/views/pages/settings/⚡two-factor.blade.php +++ b/resources/views/pages/settings/⚡two-factor.blade.php @@ -178,94 +178,90 @@ new class extends Component { } } ?> -
- @include('partials.settings-heading') + + + @if ($twoFactorEnabled) + + - - - @if ($twoFactorEnabled) - - +

+ {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} +

-

- {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} -

- - -
- @else - - - -

- {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} -

- - -
- @endif -
- - {{-- The recovery codes are their own subject, so they are their own card beside this one. --}} - - @if ($twoFactorEnabled) - - @endif - -
- - - @if ($showVerificationStep) - - - - - - - @else - - @error('setupData') - {{ $message }} - @enderror - - {{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}} - -
- @empty($qrCodeSvg) - - @else - {!! $qrCodeSvg !!} - @endempty -
-
- - -

{{ __('or, enter the code manually') }}

- - -
+
+ @else + + - - - +

+ {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} +

+ + +
@endif -
-
+ + + {{-- The recovery codes are their own subject, so they are their own card under this one. --}} + + @if ($twoFactorEnabled) + + @endif + + + @if ($showVerificationStep) + + + + + + + @else + + @error('setupData') + {{ $message }} + @enderror + + {{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}} + +
+ @empty($qrCodeSvg) + + @else + {!! $qrCodeSvg !!} + @endempty +
+
+ + +

{{ __('or, enter the code manually') }}

+ + +
+
+ + + + + @endif +
+
+ diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php deleted file mode 100644 index 7fe885c..0000000 --- a/resources/views/partials/settings-heading.blade.php +++ /dev/null @@ -1,4 +0,0 @@ - -

{{ __('Settings') }}

-

{{ __('Manage your profile and account settings') }}

-
diff --git a/tests/Browser/BreakpointsTest.php b/tests/Browser/BreakpointsTest.php index 17b501a..278603e 100644 --- a/tests/Browser/BreakpointsTest.php +++ b/tests/Browser/BreakpointsTest.php @@ -11,7 +11,7 @@ use Illuminate\Support\Facades\Storage; * The walk: every page's chrome and content across M3's breakpoint edges — 599, 600, 839, 840, * 1199, 1200, 1600px, height 900, light theme — one visit per page, reused across widths by * resizing the same page rather than revisiting it. FrameTest.php and SettingsAndAdminTest.php - * already assert the app-main margin, the auth card's alignment and the stat grid's column count + * already assert the app-main margin, the page column's width and the stat grid's column count * at their own boundary for the pages they cover; this file re-asserts those three plus the * section navigation's picker/tab-bar switch (untested until now), and walks every page group A/B/C * left unchecked at a breakpoint: the rest of the auth flow, the setup wizard, the system password @@ -243,9 +243,9 @@ test('the admin dashboard holds at every breakpoint', function () { } }); - // : 2 columns below `expanded` (840px), 4 from it. + // : two columns at every width, since the page's column is 40rem at all of them. expect($columns[839])->toBe(2); - expect($columns[840])->toBe(4); + expect($columns[840])->toBe(2); }); test('the admin settings page holds at every breakpoint', function () { diff --git a/tests/Browser/FrameTest.php b/tests/Browser/FrameTest.php index d24301d..4d0f1db 100644 --- a/tests/Browser/FrameTest.php +++ b/tests/Browser/FrameTest.php @@ -1,12 +1,12 @@ 'file']); @@ -21,50 +21,33 @@ test('the sign-in page has exactly one main landmark and never scrolls sideways' } }); -test('the sign-in card is capped at 28rem and sits 16px from each edge on a phone', function () { - $page = ready(visit('/login')->resize(393, 852)); +test('every page column is 40rem and centred on a wide window', function (string $url, bool $asAdmin) { + if ($asAdmin) { + $this->actingAs(User::factory()->admin()->create()); + } - $metrics = $page->script("(() => { - const rect = document.querySelector('.auth-card').getBoundingClientRect(); - return { left: rect.left, right: window.innerWidth - rect.right, width: rect.width }; + $metrics = ready(visit($url)->resize(1600, 900))->script("(() => { + const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); + const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect(); + return { width: rect.width, remPx, left: rect.left, right: window.innerWidth - rect.right }; + })()"); + + expect($metrics['width'])->toEqualWithDelta(40 * $metrics['remPx'], 0.5); + expect($metrics['left'])->toEqualWithDelta($metrics['right'], 1); +})->with([ + 'sign-in' => ['/login', false], + 'upload' => ['/upload', false], + 'admin dashboard' => ['/admin/dashboard', true], +]); + +test('the page column sits 16px from each edge on a phone', function () { + $metrics = ready(visit('/login')->resize(393, 852))->script("(() => { + const rect = document.querySelector('[data-test=\"page\"]').getBoundingClientRect(); + return { left: rect.left, right: window.innerWidth - rect.right }; })()"); expect($metrics['left'])->toEqualWithDelta(16, 1); expect($metrics['right'])->toEqualWithDelta(16, 1); - - $page->resize(1280, 800); - - $capped = $page->script("(() => { - const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - return document.querySelector('.auth-card').getBoundingClientRect().width <= 28 * remPx + 0.5; - })()"); - - expect($capped)->toBeTrue(); -}); - -test('the sign-in card top-aligns below 600px and centres from 600px', function () { - // The gap above the card and the gap below it, inside .auth-main's own content box (between - // its top and bottom padding) — equal gaps is the geometric definition of "centred", and it - // needs no assumption about the padding's pixel values, only that they exist on both edges. - $gaps = fn (int $width) => ready(visit('/login')->resize($width, 900))->script("(() => { - const main = document.querySelector('.auth-main'); - const mainStyle = getComputedStyle(main); - const mainRect = main.getBoundingClientRect(); - const cardRect = document.querySelector('.auth-card').getBoundingClientRect(); - const contentTop = mainRect.top + parseFloat(mainStyle.paddingTop); - const contentBottom = mainRect.bottom - parseFloat(mainStyle.paddingBottom); - return { above: cardRect.top - contentTop, below: contentBottom - cardRect.bottom }; - })()"); - - $narrow = $gaps(599); - // Below 600px the card is flush against the top of the content box: no extra gap above it. - expect($narrow['above'])->toBeLessThan(2); - expect($narrow['below'])->toBeGreaterThan($narrow['above'] + 10); - - $wide = $gaps(600); - // From 600px the gap above equals the gap below: the card is vertically centred. - expect($wide['above'])->toEqualWithDelta($wide['below'], 2); - expect($wide['above'])->toBeGreaterThan($narrow['above'] + 10); }); test('the sign-in submit button is end-aligned at its own width, not stretched', function () { @@ -86,7 +69,7 @@ test('the floating toolbar never covers the sign-in card once scrolled to the bo $page->script('window.scrollTo(0, document.body.scrollHeight)'); $overlap = $page->script("(() => { - const card = document.querySelector('.auth-card').getBoundingClientRect(); + const card = document.querySelector('[data-test=\"page\"] [data-md-card]').getBoundingClientRect(); const toolbar = document.querySelector('[data-test=\"app-toolbar\"]').getBoundingClientRect(); return card.bottom - toolbar.top; })()"); @@ -109,27 +92,9 @@ test("a snackbar clears SealShare's floating toolbar", function () { expect($gap)->toBeGreaterThanOrEqual(16 - 0.5); }); -test("the app layout's main column is capped at 64rem and centred on a wide window", function () { - $page = ready(visit('/upload')->resize(1600, 900)); - - $metrics = $page->script("(() => { - const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); - const rect = document.querySelector('.app-main').getBoundingClientRect(); - return { - width: rect.width, - cap: 64 * remPx, - left: rect.left, - right: window.innerWidth - rect.right, - }; - })()"); - - expect($metrics['width'])->toBeLessThanOrEqual($metrics['cap'] + 0.5); - expect($metrics['left'])->toEqualWithDelta($metrics['right'], 1); -}); - test("the app layout's content keeps M3's margin at 599px and 600px", function () { - // Below the 64rem cap the pane spans the full window (no centring offset), so the padding - // it declares on its body is exactly the gap between its content and the window's edge. + // The main region spans the full window at every width (the page inside sets its own width), + // so the padding it declares on its body is exactly the gap between its content and the edge. $margins = fn (int $width) => ready(visit('/upload')->resize($width, 900))->script("(() => { const main = document.querySelector('.app-main').getBoundingClientRect(); const style = getComputedStyle(document.querySelector('[data-md-pane-body]')); diff --git a/tests/Browser/SealShareTest.php b/tests/Browser/SealShareTest.php index 9ac2bf5..ff0a9d1 100644 --- a/tests/Browser/SealShareTest.php +++ b/tests/Browser/SealShareTest.php @@ -172,17 +172,19 @@ test('a recipient on a phone unlocks a password-protected share and sees its fil ->assertScript('document.documentElement.scrollWidth <= window.innerWidth'); }); -test('an admin sorts the shares table and deletes a share through its dialog', function () { +test('an admin sorts the shares list and deletes a share through its dialog', function () { $admin = User::factory()->admin()->create(); - Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]); - $doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]); + Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]); + $doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]); $this->actingAs($admin); $page = ready(visit('/admin/dashboard')); - $page->click('th button:has-text("Downloads")') - ->assertScript("document.querySelector('tbody tr td').textContent.trim() === 'zzzzzzzzzzzzzzzz'"); + $page->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'aaaaaaaaaaaaaaaa'") + ->select('[data-test="shares-sort"]', 'most-downloaded') + ->wait(0.5) + ->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'zzzzzzzzzzzzzzzz'"); $page->click("[data-test=\"delete-share-{$doomed->id}\"]") ->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)") diff --git a/tests/Browser/SettingsAndAdminTest.php b/tests/Browser/SettingsAndAdminTest.php index 225e6b4..3c0e43b 100644 --- a/tests/Browser/SettingsAndAdminTest.php +++ b/tests/Browser/SettingsAndAdminTest.php @@ -11,9 +11,9 @@ use App\Models\User; * * Out of scope by a later decision (M3's guidance over 1.x's look, reworked in this batch): form * actions' placement/width, the shares table's relation to its card, and the admin settings - * cards' grouping — now end-aligned actions, a card-free shares section and headed/divided - * settings sections respectively. Nothing here asserts any of those; a browser run follows this - * review. + * cards' grouping — now end-aligned actions, the shares table in its card and each settings + * section in a card of its own respectively (tests/Browser/ShareFlowTest.php asserts the last two). + * Nothing here asserts any of those. */ beforeEach(function () { config(['session.driver' => 'file']); @@ -72,7 +72,7 @@ test('two-factor setup draws a scannable QR code and a visible manual key in dar expect($metrics['keyFilled'])->toBeTrue(); }); -test('the admin dashboard sizes its stat grid by width and never scrolls sideways on a phone', function () { +test('the admin dashboard keeps its stats two by two and never scrolls sideways on a phone', function () { $admin = User::factory()->admin()->create(); Share::factory()->count(3)->create(); $this->actingAs($admin); @@ -89,24 +89,20 @@ test('the admin dashboard sizes its stat grid by width and never scrolls sideway expect($narrow['rows'])->toBe(2); expect($narrow['cols'])->toBe(2); - $wide = $columns(840); - expect($wide['rows'])->toBe(1); - expect($wide['cols'])->toBe(4); + $wide = $columns(1600); + expect($wide['rows'])->toBe(2); + expect($wide['cols'])->toBe(2); - // The table is wide enough on a phone to need its own horizontal scroll (so this is not a - // vacuous check); the page itself must never pick that scroll up. + // The shares are a list that wraps within the page's column, so nothing scrolls sideways. $phone = ready(visit('/admin/dashboard')->resize(393, 852)); - $overflow = $phone->script("(() => { - const scroller = document.querySelector('.admin-shares-table-scroll'); - return { - tableNeedsScroll: scroller.scrollWidth > scroller.clientWidth + 1, - pageScrollWidth: document.documentElement.scrollWidth, - windowWidth: window.innerWidth, - }; - })()"); + $overflow = $phone->script("(() => ({ + rows: document.querySelectorAll('[data-test=\"share-row\"]').length, + pageScrollWidth: document.documentElement.scrollWidth, + windowWidth: window.innerWidth, + }))()"); - expect($overflow['tableNeedsScroll'])->toBeTrue(); + expect($overflow['rows'])->toBe(3); expect($overflow['pageScrollWidth'])->toBeLessThanOrEqual($overflow['windowWidth']); }); diff --git a/tests/Browser/ShareFlowTest.php b/tests/Browser/ShareFlowTest.php index 5f23fa8..b5e0570 100644 --- a/tests/Browser/ShareFlowTest.php +++ b/tests/Browser/ShareFlowTest.php @@ -175,7 +175,7 @@ test('the settings Save button is end-aligned at less than the form\'s width', f ['/admin/settings', '[data-test="save-settings"]'], ]); -test('the admin dashboard heads its shares table with an h2 and drops the card', function () { +test('the admin dashboard heads its shares list with an h2, both in one card', function () { $admin = User::factory()->admin()->create(); Share::factory()->count(2)->create(); $this->actingAs($admin); @@ -184,34 +184,39 @@ test('the admin dashboard heads its shares table with an h2 and drops the card', $result = $page->script("(() => { const heading = [...document.querySelectorAll('h2')].find((h) => h.textContent.trim() === 'All Shares'); - const table = document.querySelector('table'); + const list = document.querySelector('[data-test=\"share-row\"]'); + const card = heading?.closest('[data-md-card]'); return { - headingIsH2: heading?.tagName === 'H2', - headingBeforeTable: !!heading && !!table - && !!(heading.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING), - noCard: document.querySelectorAll('[data-md-card]').length === 0, + headingIsCardTitle: heading?.matches('[data-md-card-title]') ?? false, + listInSameCard: !!card && card.contains(list), + headingBeforeList: !!heading && !!list + && !!(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING), }; })()"); - expect($result['headingIsH2'])->toBeTrue(); - expect($result['headingBeforeTable'])->toBeTrue(); - expect($result['noCard'])->toBeTrue(); + expect($result['headingIsCardTitle'])->toBeTrue(); + expect($result['listInSameCard'])->toBeTrue(); + expect($result['headingBeforeList'])->toBeTrue(); }); -test('the admin settings page has six headed sections and no card', function () { +test('the admin settings page has six sections, each a card headed by an h2', function () { $this->actingAs(User::factory()->admin()->create()); $page = ready(visit('/admin/settings')); $result = $page->script("(() => { + // Every dialog's own title is an h2 too (components/modal.blade.php), whether open or + // not: exclude those to count only the page's own section headings. + const headings = [...document.querySelectorAll('h2:not([data-md-modal-title])')]; + const cards = [...document.querySelectorAll('[data-md-card]')]; return { - // Every dialog's own title is an h2 too (components/modal.blade.php), whether open or - // not: exclude those to count only the page's own section headings. - h2Count: document.querySelectorAll('h2:not([data-md-modal-title])').length, - noCard: document.querySelectorAll('[data-md-card]').length === 0, + headings: headings.map((h) => h.textContent.trim()), + cardCount: cards.length, + everyCardHeaded: cards.every((card) => card.querySelector('h2[data-md-card-title]') !== null), }; })()"); - expect($result['h2Count'])->toBe(6); - expect($result['noCard'])->toBeTrue(); + expect($result['headings'])->toBe(['Colour profile', 'Branding', 'Upload Protection', 'Share Passwords', 'Upload Limits', 'Storage']); + expect($result['cardCount'])->toBe(6); + expect($result['everyCardHeaded'])->toBeTrue(); }); diff --git a/tests/Feature/Admin/AdminDashboardTest.php b/tests/Feature/Admin/AdminDashboardTest.php index 5f8964d..300dbc2 100644 --- a/tests/Feature/Admin/AdminDashboardTest.php +++ b/tests/Feature/Admin/AdminDashboardTest.php @@ -61,7 +61,7 @@ test('admin can delete share', function () { expect(Share::query()->find($shareId))->toBeNull(); }); -test('admin dashboard shows shares table', function () { +test('admin dashboard lists the shares', function () { $admin = User::query()->where('is_admin', true)->first(); $share = Share::factory()->create(['token' => 'testtoken12345678']); @@ -70,31 +70,47 @@ test('admin dashboard shows shares table', function () { $response->assertOk(); $response->assertSee('testtoken12345678'); + $response->assertSee('href="'.route('share.download', $share).'"', false); }); -test('the shares table sorts only by its own columns', function () { +test('the shares list sorts only by its own orders', function () { $admin = User::query()->where('is_admin', true)->first(); - Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]); - Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]); + Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]); + Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]); Livewire::actingAs($admin) ->test(AdminDashboard::class) - ->set('sortBy', ['column' => 'download_count', 'direction' => 'asc']) + ->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz']) + ->set('sort', 'most-downloaded') ->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa']) - ->set('sortBy', ['column' => 'token; drop table shares', 'direction' => 'sideways']) - ->assertOk(); + ->set('sort', 'token; drop table shares') + ->assertOk() + ->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz']); expect(Share::query()->count())->toBe(2); }); -test('without shares the dashboard shows an empty state instead of the table', function () { +test('sorting by expiry puts shares that never expire last', function () { + $admin = User::query()->where('is_admin', true)->first(); + + Share::factory()->create(['token' => 'neverexpires0000', 'expires_at' => null]); + Share::factory()->create(['token' => 'expireslater0000', 'expires_at' => now()->addWeek()]); + Share::factory()->create(['token' => 'expiressoon00000', 'expires_at' => now()->addHour()]); + + Livewire::actingAs($admin) + ->test(AdminDashboard::class) + ->set('sort', 'expiring') + ->assertSeeInOrder(['expiressoon00000', 'expireslater0000', 'neverexpires0000']); +}); + +test('without shares the dashboard shows an empty state instead of the list', function () { $admin = User::query()->where('is_admin', true)->first(); $this->actingAs($admin)->get(route('admin.dashboard')) ->assertOk() ->assertSee('No shares yet') - ->assertDontSee('assertDontSee('data-test="share-row"', false); }); test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () { diff --git a/tests/Feature/PageTemplateTest.php b/tests/Feature/PageTemplateTest.php new file mode 100644 index 0000000..8099720 --- /dev/null +++ b/tests/Feature/PageTemplateTest.php @@ -0,0 +1,76 @@ +]*data-test="page"[^>]*>/', $html, $matches); + + return $matches[0] ?? ''; +} + +test('every page is one page template, one h1 and the same width', function (Closure $visit) { + $html = $visit($this)->assertOk()->getContent(); + + expect(substr_count($html, 'data-test="page"'))->toBe(1) + ->and(preg_match_all('/]/', $html))->toBe(1) + ->and(pageTag($html))->toContain('data-md-width="narrow"'); +})->with([ + 'upload' => [fn (TestCase $test): TestResponse => $test->get(route('upload'))], + 'download' => [fn (TestCase $test): TestResponse => $test->get(route('share.download', Share::factory()->withPassword()->create()))], + 'share created' => [fn (TestCase $test): TestResponse => $test->get(route('share.created', Share::factory()->create()))], + 'login' => [fn (TestCase $test): TestResponse => $test->get(route('login'))], + 'forgot password' => [fn (TestCase $test): TestResponse => $test->get(route('password.request'))], + 'reset password' => [fn (TestCase $test): TestResponse => $test->get(route('password.reset', 'token'))], + 'two-factor challenge' => [fn (TestCase $test): TestResponse => $test->withSession(['login.id' => User::factory()->withTwoFactor()->create()->id])->get(route('two-factor.login'))], + 'setup' => [function (TestCase $test): TestResponse { + User::query()->where('is_admin', true)->delete(); + + return $test->get(route('setup')); + }], + 'system password' => [function (TestCase $test): TestResponse { + Setting::set('system_password', bcrypt('system-secret')); + + return $test->get(route('system-password')); + }], + 'verify email' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->unverified()->create())->get(route('verification.notice'))], + 'confirm password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('password.confirm'))], + 'profile' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('profile.edit'))], + 'password' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('user-password.edit'))], + 'two-factor settings' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->withSession(['auth.password_confirmed_at' => time()])->get(route('two-factor.show'))], + 'appearance' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->create())->get(route('appearance.edit'))], + 'admin settings' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->admin()->create())->get(route('admin.settings'))], + 'admin dashboard' => [fn (TestCase $test): TestResponse => $test->actingAs(User::factory()->admin()->create())->get(route('admin.dashboard'))], +]); + +test("a brand page is headed by the site's own logo, title and description", function () { + Setting::set('site_title', 'Acme Files'); + Setting::set('site_description', 'Send files to Acme Engineering.'); + Setting::set('site_logo', 'branding/acme.png'); + + $html = $this->get(route('login')) + ->assertOk() + ->assertSeeInOrder(['data-test="page-logo"', 'assertSee(Storage::disk('public')->url('branding/acme.png'), false) + ->getContent(); + + expect($html)->toMatch('/]*>\s*Acme Files\s*<\/h1>/'); +}); + +test('a brand page falls back to the app name and SealShare\'s own line without branding', function () { + $html = $this->get(route('upload')) + ->assertOk() + ->assertSeeInOrder(['assertDontSee('data-test="page-logo"', false) + ->getContent(); + + expect($html)->toMatch('/]*>\s*'.preg_quote(config('app.name'), '/').'\s*<\/h1>/'); +}); diff --git a/website/img/screenshots/desktop/dark/01-upload-1600.webp b/website/img/screenshots/desktop/dark/01-upload-1600.webp index aa3b483..95a9e2d 100644 Binary files a/website/img/screenshots/desktop/dark/01-upload-1600.webp and b/website/img/screenshots/desktop/dark/01-upload-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/01-upload-800.webp b/website/img/screenshots/desktop/dark/01-upload-800.webp index b757fd4..68fbcdc 100644 Binary files a/website/img/screenshots/desktop/dark/01-upload-800.webp and b/website/img/screenshots/desktop/dark/01-upload-800.webp differ diff --git a/website/img/screenshots/desktop/dark/02-share-created-1600.webp b/website/img/screenshots/desktop/dark/02-share-created-1600.webp index b685a7e..85b2b76 100644 Binary files a/website/img/screenshots/desktop/dark/02-share-created-1600.webp and b/website/img/screenshots/desktop/dark/02-share-created-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/02-share-created-800.webp b/website/img/screenshots/desktop/dark/02-share-created-800.webp index ee69435..a1b0227 100644 Binary files a/website/img/screenshots/desktop/dark/02-share-created-800.webp and b/website/img/screenshots/desktop/dark/02-share-created-800.webp differ diff --git a/website/img/screenshots/desktop/dark/03-qr-code-1600.webp b/website/img/screenshots/desktop/dark/03-qr-code-1600.webp index 26b1d55..a0b51f6 100644 Binary files a/website/img/screenshots/desktop/dark/03-qr-code-1600.webp and b/website/img/screenshots/desktop/dark/03-qr-code-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/03-qr-code-800.webp b/website/img/screenshots/desktop/dark/03-qr-code-800.webp index 401b83c..466588f 100644 Binary files a/website/img/screenshots/desktop/dark/03-qr-code-800.webp and b/website/img/screenshots/desktop/dark/03-qr-code-800.webp differ diff --git a/website/img/screenshots/desktop/dark/04-download-1600.webp b/website/img/screenshots/desktop/dark/04-download-1600.webp index 91a22d6..e73f895 100644 Binary files a/website/img/screenshots/desktop/dark/04-download-1600.webp and b/website/img/screenshots/desktop/dark/04-download-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/04-download-800.webp b/website/img/screenshots/desktop/dark/04-download-800.webp index 6353806..ab2f5b0 100644 Binary files a/website/img/screenshots/desktop/dark/04-download-800.webp and b/website/img/screenshots/desktop/dark/04-download-800.webp differ diff --git a/website/img/screenshots/desktop/dark/05-admin-dashboard-1600.webp b/website/img/screenshots/desktop/dark/05-admin-dashboard-1600.webp index 8250d2a..f97e66a 100644 Binary files a/website/img/screenshots/desktop/dark/05-admin-dashboard-1600.webp and b/website/img/screenshots/desktop/dark/05-admin-dashboard-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/05-admin-dashboard-800.webp b/website/img/screenshots/desktop/dark/05-admin-dashboard-800.webp index ad47e64..5aaf08c 100644 Binary files a/website/img/screenshots/desktop/dark/05-admin-dashboard-800.webp and b/website/img/screenshots/desktop/dark/05-admin-dashboard-800.webp differ diff --git a/website/img/screenshots/desktop/dark/06-admin-settings-1600.webp b/website/img/screenshots/desktop/dark/06-admin-settings-1600.webp index b327ade..e495e64 100644 Binary files a/website/img/screenshots/desktop/dark/06-admin-settings-1600.webp and b/website/img/screenshots/desktop/dark/06-admin-settings-1600.webp differ diff --git a/website/img/screenshots/desktop/dark/06-admin-settings-800.webp b/website/img/screenshots/desktop/dark/06-admin-settings-800.webp index aaa65d4..c105707 100644 Binary files a/website/img/screenshots/desktop/dark/06-admin-settings-800.webp and b/website/img/screenshots/desktop/dark/06-admin-settings-800.webp differ diff --git a/website/img/screenshots/desktop/light/01-upload-1600.webp b/website/img/screenshots/desktop/light/01-upload-1600.webp index a22b938..55490c6 100644 Binary files a/website/img/screenshots/desktop/light/01-upload-1600.webp and b/website/img/screenshots/desktop/light/01-upload-1600.webp differ diff --git a/website/img/screenshots/desktop/light/01-upload-800.webp b/website/img/screenshots/desktop/light/01-upload-800.webp index dc45fda..5c96ebb 100644 Binary files a/website/img/screenshots/desktop/light/01-upload-800.webp and b/website/img/screenshots/desktop/light/01-upload-800.webp differ diff --git a/website/img/screenshots/desktop/light/02-share-created-1600.webp b/website/img/screenshots/desktop/light/02-share-created-1600.webp index 9bc75bd..e8893f8 100644 Binary files a/website/img/screenshots/desktop/light/02-share-created-1600.webp and b/website/img/screenshots/desktop/light/02-share-created-1600.webp differ diff --git a/website/img/screenshots/desktop/light/02-share-created-800.webp b/website/img/screenshots/desktop/light/02-share-created-800.webp index 8a7131d..8c10007 100644 Binary files a/website/img/screenshots/desktop/light/02-share-created-800.webp and b/website/img/screenshots/desktop/light/02-share-created-800.webp differ diff --git a/website/img/screenshots/desktop/light/03-qr-code-1600.webp b/website/img/screenshots/desktop/light/03-qr-code-1600.webp index 2941a1d..368c92c 100644 Binary files a/website/img/screenshots/desktop/light/03-qr-code-1600.webp and b/website/img/screenshots/desktop/light/03-qr-code-1600.webp differ diff --git a/website/img/screenshots/desktop/light/03-qr-code-800.webp b/website/img/screenshots/desktop/light/03-qr-code-800.webp index 7fd78a6..3bff3ce 100644 Binary files a/website/img/screenshots/desktop/light/03-qr-code-800.webp and b/website/img/screenshots/desktop/light/03-qr-code-800.webp differ diff --git a/website/img/screenshots/desktop/light/04-download-1600.webp b/website/img/screenshots/desktop/light/04-download-1600.webp index ea706d2..4c7fde3 100644 Binary files a/website/img/screenshots/desktop/light/04-download-1600.webp and b/website/img/screenshots/desktop/light/04-download-1600.webp differ diff --git a/website/img/screenshots/desktop/light/04-download-800.webp b/website/img/screenshots/desktop/light/04-download-800.webp index bf95f29..1b24d21 100644 Binary files a/website/img/screenshots/desktop/light/04-download-800.webp and b/website/img/screenshots/desktop/light/04-download-800.webp differ diff --git a/website/img/screenshots/desktop/light/05-admin-dashboard-1600.webp b/website/img/screenshots/desktop/light/05-admin-dashboard-1600.webp index d17e798..699ccff 100644 Binary files a/website/img/screenshots/desktop/light/05-admin-dashboard-1600.webp and b/website/img/screenshots/desktop/light/05-admin-dashboard-1600.webp differ diff --git a/website/img/screenshots/desktop/light/05-admin-dashboard-800.webp b/website/img/screenshots/desktop/light/05-admin-dashboard-800.webp index 78eaf34..f5b34bd 100644 Binary files a/website/img/screenshots/desktop/light/05-admin-dashboard-800.webp and b/website/img/screenshots/desktop/light/05-admin-dashboard-800.webp differ diff --git a/website/img/screenshots/desktop/light/06-admin-settings-1600.webp b/website/img/screenshots/desktop/light/06-admin-settings-1600.webp index 4ff67fc..0fc3327 100644 Binary files a/website/img/screenshots/desktop/light/06-admin-settings-1600.webp and b/website/img/screenshots/desktop/light/06-admin-settings-1600.webp differ diff --git a/website/img/screenshots/desktop/light/06-admin-settings-800.webp b/website/img/screenshots/desktop/light/06-admin-settings-800.webp index 4557520..07dc5c5 100644 Binary files a/website/img/screenshots/desktop/light/06-admin-settings-800.webp and b/website/img/screenshots/desktop/light/06-admin-settings-800.webp differ diff --git a/website/img/screenshots/phone/dark/01-upload-1080.webp b/website/img/screenshots/phone/dark/01-upload-1080.webp index d4fcff0..14ff3b6 100644 Binary files a/website/img/screenshots/phone/dark/01-upload-1080.webp and b/website/img/screenshots/phone/dark/01-upload-1080.webp differ diff --git a/website/img/screenshots/phone/dark/01-upload-540.webp b/website/img/screenshots/phone/dark/01-upload-540.webp index 5fbe8a5..85bb10b 100644 Binary files a/website/img/screenshots/phone/dark/01-upload-540.webp and b/website/img/screenshots/phone/dark/01-upload-540.webp differ diff --git a/website/img/screenshots/phone/dark/02-password-1080.webp b/website/img/screenshots/phone/dark/02-password-1080.webp index e1f763a..0c46cdd 100644 Binary files a/website/img/screenshots/phone/dark/02-password-1080.webp and b/website/img/screenshots/phone/dark/02-password-1080.webp differ diff --git a/website/img/screenshots/phone/dark/02-password-540.webp b/website/img/screenshots/phone/dark/02-password-540.webp index a354439..22514b5 100644 Binary files a/website/img/screenshots/phone/dark/02-password-540.webp and b/website/img/screenshots/phone/dark/02-password-540.webp differ diff --git a/website/img/screenshots/phone/dark/03-download-1080.webp b/website/img/screenshots/phone/dark/03-download-1080.webp index f2f12fa..05d56b8 100644 Binary files a/website/img/screenshots/phone/dark/03-download-1080.webp and b/website/img/screenshots/phone/dark/03-download-1080.webp differ diff --git a/website/img/screenshots/phone/dark/03-download-540.webp b/website/img/screenshots/phone/dark/03-download-540.webp index 14bfc08..aef5876 100644 Binary files a/website/img/screenshots/phone/dark/03-download-540.webp and b/website/img/screenshots/phone/dark/03-download-540.webp differ diff --git a/website/img/screenshots/phone/dark/04-qr-code-1080.webp b/website/img/screenshots/phone/dark/04-qr-code-1080.webp index 3943b2a..f4ec84e 100644 Binary files a/website/img/screenshots/phone/dark/04-qr-code-1080.webp and b/website/img/screenshots/phone/dark/04-qr-code-1080.webp differ diff --git a/website/img/screenshots/phone/dark/04-qr-code-540.webp b/website/img/screenshots/phone/dark/04-qr-code-540.webp index 832ff57..c32454b 100644 Binary files a/website/img/screenshots/phone/dark/04-qr-code-540.webp and b/website/img/screenshots/phone/dark/04-qr-code-540.webp differ diff --git a/website/img/screenshots/phone/light/01-upload-1080.webp b/website/img/screenshots/phone/light/01-upload-1080.webp index 056fb50..65cd83c 100644 Binary files a/website/img/screenshots/phone/light/01-upload-1080.webp and b/website/img/screenshots/phone/light/01-upload-1080.webp differ diff --git a/website/img/screenshots/phone/light/01-upload-540.webp b/website/img/screenshots/phone/light/01-upload-540.webp index 43eb736..42a7f84 100644 Binary files a/website/img/screenshots/phone/light/01-upload-540.webp and b/website/img/screenshots/phone/light/01-upload-540.webp differ diff --git a/website/img/screenshots/phone/light/02-password-1080.webp b/website/img/screenshots/phone/light/02-password-1080.webp index 6425941..ed05dc7 100644 Binary files a/website/img/screenshots/phone/light/02-password-1080.webp and b/website/img/screenshots/phone/light/02-password-1080.webp differ diff --git a/website/img/screenshots/phone/light/02-password-540.webp b/website/img/screenshots/phone/light/02-password-540.webp index eeddc02..4c1d2f3 100644 Binary files a/website/img/screenshots/phone/light/02-password-540.webp and b/website/img/screenshots/phone/light/02-password-540.webp differ diff --git a/website/img/screenshots/phone/light/03-download-1080.webp b/website/img/screenshots/phone/light/03-download-1080.webp index 038a9ef..37d9db0 100644 Binary files a/website/img/screenshots/phone/light/03-download-1080.webp and b/website/img/screenshots/phone/light/03-download-1080.webp differ diff --git a/website/img/screenshots/phone/light/03-download-540.webp b/website/img/screenshots/phone/light/03-download-540.webp index 1eb9972..a5c888b 100644 Binary files a/website/img/screenshots/phone/light/03-download-540.webp and b/website/img/screenshots/phone/light/03-download-540.webp differ diff --git a/website/img/screenshots/phone/light/04-qr-code-1080.webp b/website/img/screenshots/phone/light/04-qr-code-1080.webp index 22c709d..97c25b1 100644 Binary files a/website/img/screenshots/phone/light/04-qr-code-1080.webp and b/website/img/screenshots/phone/light/04-qr-code-1080.webp differ diff --git a/website/img/screenshots/phone/light/04-qr-code-540.webp b/website/img/screenshots/phone/light/04-qr-code-540.webp index 242e0db..794f755 100644 Binary files a/website/img/screenshots/phone/light/04-qr-code-540.webp and b/website/img/screenshots/phone/light/04-qr-code-540.webp differ