The pages were built five ways: two layouts, seven widths from 28 to 64rem and four heading styles. Every page now looks like the share pages: a centred heading over one 40rem column of outlined cards, with the floating toolbar below. - <x-page> (components/page.blade.php) is the root of every page. It draws the h1 and its line (`brand` takes the site's logo, title and description from Admin settings), an optional `mark` and `navigation` slot, then the content. It has no width prop: every page is the same <x-pane width="narrow">. - The sign-in, password reset, confirm, verify email, two-factor challenge, setup and system password pages move onto layouts/app with the brand heading and their form in a card titled with the task. layouts/auth, auth-header and the settings heading partial are gone, and so is the per-page width CSS. - Settings put their section nav under the heading; the admin pages get a description line each. FileUploader and ShareDownload no longer pass the branding to their views. - The admin dashboard's table needed about 49rem, so its shares are a list: created above the token, which opens the share, then files, size, downloads and expiry on two lines that wrap instead of clipping, and one delete button. A "Sort by" select replaces the column headers (newest, oldest, expiring soonest with never-expiring last, largest, most downloads, most files) and resets the page. The stats stay two by two. table.css and sort-header.css are no longer imported. - Branding hints in Admin settings name every page the title shows on. - Tests: PageTemplateTest renders every page once and checks one page template, one h1 and the width, and the brand heading with its fallbacks. FrameTest measures the page column instead of the auth card and the 64rem main; dashboard tests follow the list and the sort select, including expiry order. .ai/rules/views.md records <x-page>, the CHANGELOG notes the change and the website screenshots are regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
224 lines
11 KiB
PHP
224 lines
11 KiB
PHP
<x-page :title="__('System Settings')" :description="__('How the site looks and what uploaders may do')">
|
||
<x-form wire:submit="saveSettings">
|
||
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Branding')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload, download and sign-in pages.')" />
|
||
|
||
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload, download and sign-in pages.')" rows="3" />
|
||
|
||
<x-stack gap="space200">
|
||
@if ($currentLogo)
|
||
<x-row gap="space200" wrap>
|
||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
|
||
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||
</x-row>
|
||
@endif
|
||
|
||
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||
|
||
@if ($siteLogo && is_object($siteLogo))
|
||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||
@else
|
||
<x-stack gap="space50">
|
||
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
|
||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
|
||
</x-stack>
|
||
@endif
|
||
@endif
|
||
</x-stack>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-stack gap="space100">
|
||
<x-password full
|
||
wire:model="systemPassword"
|
||
:label="__('System Upload Password')"
|
||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||
autocomplete="new-password"
|
||
/>
|
||
|
||
@if ($hasSystemPassword)
|
||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||
@endif
|
||
</x-stack>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
{{-- How the upload page offers random share passwords (App\Services\PasswordGeneratorService).
|
||
The example is drawn from the form as it stands, before saving. --}}
|
||
<x-card :title="__('Share Passwords')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-group
|
||
wire:model.live="passwordGeneratorMode"
|
||
:label="__('Password generator')"
|
||
:hint="match ($passwordGeneratorMode) {
|
||
'off' => __('Uploaders type a password themselves.'),
|
||
'prefill' => __('A random password is filled in as soon as Password protect is switched on. Generate draws a new one.'),
|
||
default => __('A Generate button under the password field fills in a random password.'),
|
||
}"
|
||
:options="[
|
||
['id' => 'off', 'name' => __('Off')],
|
||
['id' => 'button', 'name' => __('On request')],
|
||
['id' => 'prefill', 'name' => __('Prefilled')],
|
||
]"
|
||
/>
|
||
|
||
@if ($passwordGeneratorMode !== 'off')
|
||
<x-group
|
||
wire:model.live="passwordGeneratorType"
|
||
:label="__('Kind')"
|
||
:hint="$passwordGeneratorType === 'passphrase' ? __('Random words, easy to read out or type on a phone.') : __('Random characters, the most secure for their length.')"
|
||
:options="[
|
||
['id' => 'characters', 'name' => __('Characters')],
|
||
['id' => 'passphrase', 'name' => __('Passphrase')],
|
||
]"
|
||
/>
|
||
|
||
@if ($passwordGeneratorType === 'passphrase')
|
||
<x-input full
|
||
wire:model.live.blur="passphraseWords"
|
||
:label="__('Words')"
|
||
type="number"
|
||
:min="\App\Services\PasswordGeneratorService::MIN_WORDS"
|
||
:max="\App\Services\PasswordGeneratorService::MAX_WORDS"
|
||
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_WORDS, 'max' => \App\Services\PasswordGeneratorService::MAX_WORDS])"
|
||
/>
|
||
|
||
<x-select full
|
||
wire:model.live="passphraseSeparator"
|
||
:label="__('Separator')"
|
||
:options="[
|
||
['id' => 'hyphen', 'name' => __('Hyphen (-)')],
|
||
['id' => 'dot', 'name' => __('Dot (.)')],
|
||
['id' => 'underscore', 'name' => __('Underscore (_)')],
|
||
['id' => 'space', 'name' => __('Space')],
|
||
]"
|
||
/>
|
||
@else
|
||
<x-input full
|
||
wire:model.live.blur="passwordLength"
|
||
:label="__('Length')"
|
||
type="number"
|
||
:min="\App\Services\PasswordGeneratorService::MIN_LENGTH"
|
||
:max="\App\Services\PasswordGeneratorService::MAX_LENGTH"
|
||
:suffix="__('characters')"
|
||
:hint="__('Between :min and :max.', ['min' => \App\Services\PasswordGeneratorService::MIN_LENGTH, 'max' => \App\Services\PasswordGeneratorService::MAX_LENGTH])"
|
||
/>
|
||
|
||
<x-group
|
||
multiple
|
||
wire:model.live="passwordCharacterSets"
|
||
:label="__('Include')"
|
||
:hint="__('Uppercase letters, lowercase letters, numbers and symbols.')"
|
||
:options="[
|
||
['id' => 'uppercase', 'name' => 'A–Z'],
|
||
['id' => 'lowercase', 'name' => 'a–z'],
|
||
['id' => 'numbers', 'name' => '0–9'],
|
||
['id' => 'symbols', 'name' => '#$%'],
|
||
]"
|
||
/>
|
||
|
||
<x-checkbox
|
||
wire:model.live="passwordAvoidAmbiguous"
|
||
:label="__('Avoid look-alike characters')"
|
||
:hint="__('Leaves out 0, O, 1, l and I.')"
|
||
/>
|
||
@endif
|
||
|
||
@if ($passwordExample)
|
||
<x-input full
|
||
:label="__('Example')"
|
||
:value="$passwordExample"
|
||
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
|
||
readonly
|
||
mono
|
||
data-test="password-example"
|
||
/>
|
||
@endif
|
||
@endif
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-toggle
|
||
wire:model.live="allowNeverExpire"
|
||
:label="__('Allow shares to never expire')"
|
||
:hint="__('When disabled, users must select an expiration time.')"
|
||
right
|
||
/>
|
||
|
||
<x-select full
|
||
wire:model="defaultExpiration"
|
||
:label="__('Default Expiration')"
|
||
:placeholder="$allowNeverExpire ? __('None') : null"
|
||
:options="[
|
||
['id' => '1h', 'name' => __('1 Hour')],
|
||
['id' => '24h', 'name' => __('24 Hours')],
|
||
['id' => '48h', 'name' => __('48 Hours')],
|
||
['id' => '7d', 'name' => __('7 Days')],
|
||
['id' => '14d', 'name' => __('14 Days')],
|
||
['id' => '30d', 'name' => __('30 Days')],
|
||
]"
|
||
/>
|
||
|
||
<x-input full
|
||
wire:model="maxFileSize"
|
||
:label="__('Max file size (MB)')"
|
||
type="number"
|
||
min="1"
|
||
suffix="MB"
|
||
/>
|
||
|
||
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||
|
||
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-card :title="__('Storage')" heading="h2" variant="outlined">
|
||
<x-stack gap="space200">
|
||
<x-input full
|
||
wire:model="maxStorageQuota"
|
||
:label="__('Max storage quota (GB)')"
|
||
type="number"
|
||
min="1"
|
||
suffix="GB"
|
||
:hint="__('When reached, new uploads are blocked.')"
|
||
/>
|
||
</x-stack>
|
||
</x-card>
|
||
|
||
<x-slot:actions>
|
||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
|
||
</x-slot:actions>
|
||
</x-form>
|
||
|
||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||
{{ __('The upload, download and sign-in pages show only the site title.') }}
|
||
|
||
<x-slot:actions>
|
||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
|
||
</x-slot:actions>
|
||
</x-modal>
|
||
|
||
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
|
||
{{ __('Anyone who can reach the upload page can upload files again.') }}
|
||
|
||
<x-slot:actions>
|
||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
||
</x-slot:actions>
|
||
</x-modal>
|
||
</x-page>
|