Give every page the share pages' layout, at one width
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>
@@ -7,3 +7,6 @@ paths:
|
|||||||
|
|
||||||
## `<x-group>` drops data-test and other attributes
|
## `<x-group>` drops data-test and other attributes
|
||||||
`<x-group>` (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 `<x-group>` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it).
|
`<x-group>` (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 `<x-group>` also needs components/group.css imported in resources/css/app.css (DesignLanguageTest's missingStylesheets guards it).
|
||||||
|
|
||||||
|
## Every page renders <x-page>
|
||||||
|
Every page (Livewire page, settings SFC via pages/settings/layout, Fortify auth view) has <x-page> (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 <x-page> 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 (`<x-card variant="outlined" heading="h2">`). Never give a page its own width class, h1 or header stack. tests/Feature/PageTemplateTest.php lists every page.
|
||||||
|
|||||||
@@ -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 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.
|
- 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.
|
- 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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -15,14 +15,20 @@ class AdminDashboard extends Component
|
|||||||
use WithPagination;
|
use WithPagination;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The columns the table can be sorted by.
|
* The orders the shares list offers, each a column and a direction.
|
||||||
*
|
*
|
||||||
* @var list<string>
|
* @var array<string, array{0: string, 1: string}>
|
||||||
*/
|
*/
|
||||||
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 string $sort = 'newest';
|
||||||
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
|
|
||||||
|
|
||||||
/** The share the delete dialog is asking about, while it is open. */
|
/** The share the delete dialog is asking about, while it is open. */
|
||||||
public ?int $deletingShareId = null;
|
public ?int $deletingShareId = null;
|
||||||
@@ -35,19 +41,29 @@ class AdminDashboard extends Component
|
|||||||
$this->deletingShareId = null;
|
$this->deletingShareId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new order starts again from the first page.
|
||||||
|
*/
|
||||||
|
public function updatedSort(): void
|
||||||
|
{
|
||||||
|
$this->resetPage();
|
||||||
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$shareService = app(ShareService::class);
|
||||||
|
|
||||||
// The sort comes from the browser: only a known column and direction reach the query.
|
// The sort comes from the browser: only a known order reaches the query.
|
||||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
[$column, $direction] = self::SORTS[$this->sort] ?? self::SORTS['newest'];
|
||||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
|
||||||
|
|
||||||
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
// Shares whose files are still being uploaded are not shares yet; their bytes do count as used space.
|
||||||
$shares = Share::query()
|
$shares = Share::query()
|
||||||
->whereNotNull('completed_at')
|
->whereNotNull('completed_at')
|
||||||
->withCount('files')
|
->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)
|
->orderBy($column, $direction)
|
||||||
|
->orderByDesc('id')
|
||||||
->paginate(15);
|
->paginate(15);
|
||||||
|
|
||||||
return view('livewire.admin.admin-dashboard', [
|
return view('livewire.admin.admin-dashboard', [
|
||||||
|
|||||||
@@ -193,9 +193,6 @@ class FileUploader extends Component
|
|||||||
'pendingFiles' => $pendingFiles,
|
'pendingFiles' => $pendingFiles,
|
||||||
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
'allFilesUploaded' => $pendingFiles->isNotEmpty() && $pendingFiles->every(fn ($file): bool => $file->completed_at !== null),
|
||||||
'isStorageFull' => $shareService->isStorageFull(),
|
'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),
|
'allowNeverExpire' => (bool) Setting::get('allow_never_expire', false),
|
||||||
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
'passwordGeneratorMode' => app(PasswordGeneratorService::class)->mode(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use Livewire\Attributes\Layout;
|
|||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
#[Layout('layouts.app')]
|
||||||
class SetupWizard extends Component
|
class SetupWizard extends Component
|
||||||
{
|
{
|
||||||
#[Validate('required|string|max:255')]
|
#[Validate('required|string|max:255')]
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\Share;
|
use App\Models\Share;
|
||||||
use App\Services\ShareService;
|
use App\Services\ShareService;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
@@ -66,10 +65,6 @@ class ShareDownload extends Component
|
|||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
return view('livewire.share-download', [
|
return view('livewire.share-download');
|
||||||
'siteTitle' => Setting::get('site_title'),
|
|
||||||
'siteDescription' => Setting::get('site_description'),
|
|
||||||
'siteLogo' => Setting::get('site_logo'),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use Livewire\Attributes\Layout;
|
|||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
#[Layout('layouts.auth')]
|
#[Layout('layouts.app')]
|
||||||
class SystemPasswordPrompt extends Component
|
class SystemPasswordPrompt extends Component
|
||||||
{
|
{
|
||||||
#[Validate('required|string')]
|
#[Validate('required|string')]
|
||||||
|
|||||||
@@ -31,9 +31,7 @@
|
|||||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/section-nav.css';
|
@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/select.css';
|
||||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/shape.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/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/textarea.css';
|
||||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
|
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
|
||||||
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.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
|
* 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,
|
* the order a visitor meets them — the layout and the page template, the share flow (upload,
|
||||||
* download), the settings pages in their navigation's order, then admin.
|
* 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.
|
||||||
*
|
*
|
||||||
* `<x-pane as="main">` gives the column its horizontal M3 margin (16px below `medium`, 24px from
|
* `<x-pane as="main">` gives the region 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
|
* it); the page inside (components/page.blade.php) sets its own width and centres itself. The
|
||||||
* (40, 60, 80rem) matches. The vertical rhythm is the app's own. The bottom padding clears the
|
* vertical rhythm is the app's own. The bottom padding clears the floating toolbar in
|
||||||
* floating toolbar in partials/toolbar.blade.php by what the toolbar publishes as
|
* partials/toolbar.blade.php by what the toolbar publishes as `--material-bottom-toolbar` (its top
|
||||||
* `--material-bottom-toolbar` (its top edge's distance from the window's bottom, safe area
|
* edge's distance from the window's bottom, safe area included), plus 16px. Never set
|
||||||
* included), plus 16px. Never set `--material-bottom-bar` here: the toolbar reads it to place
|
* `--material-bottom-bar` here: the toolbar reads it to place itself.
|
||||||
* itself.
|
|
||||||
*/
|
*/
|
||||||
.app-main {
|
.app-main {
|
||||||
max-inline-size: 64rem;
|
|
||||||
padding-block-start: var(--md-sys-measurement-space400);
|
padding-block-start: var(--md-sys-measurement-space400);
|
||||||
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
|
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
|
||||||
}
|
}
|
||||||
@@ -70,52 +66,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/* 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. */
|
||||||
* resources/views/layouts/auth.blade.php: the centred sign-in card.
|
.page-logo {
|
||||||
*
|
|
||||||
* 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 `<x-pane>` 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 {
|
|
||||||
block-size: 5rem;
|
block-size: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,14 +161,6 @@
|
|||||||
overflow-y: auto;
|
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 `<x-pane>` 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
|
* 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
|
* shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape
|
||||||
@@ -299,35 +243,6 @@
|
|||||||
block-size: 100%;
|
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
|
* 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
|
* 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
|
* resources/views/pages/settings/appearance.blade.php: the theme picker stays a comfortable
|
||||||
* width instead of stretching the full settings column. No `<x-group>` width prop caps it, and
|
* width instead of stretching across the settings card. No `<x-group>` width prop caps it, and
|
||||||
* 24rem matches no `<x-pane>` preset.
|
* 24rem matches no `<x-pane>` preset.
|
||||||
*/
|
*/
|
||||||
.settings-appearance-picker {
|
.settings-appearance-picker {
|
||||||
@@ -375,20 +290,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* resources/views/livewire/admin/admin-dashboard.blade.php: the shares table scrolls sideways on
|
* resources/views/livewire/admin/admin-dashboard.blade.php: the sort select above the shares list
|
||||||
* its own, on a window too narrow for every column, instead of the page around it.
|
* keeps to the width its longest option needs instead of spanning the card. No `<x-select>` width
|
||||||
|
* prop caps it, and 20rem matches no `<x-pane>` preset.
|
||||||
*/
|
*/
|
||||||
.admin-shares-table-scroll {
|
.admin-shares-sort {
|
||||||
overflow-x: auto;
|
max-inline-size: 20rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* resources/views/livewire/admin/admin-settings.blade.php: the settings keep 1.x's own narrower
|
* resources/views/livewire/admin/admin-dashboard.blade.php: a share's details are two lines of
|
||||||
* measure inside the 64rem main column, 42rem, which no `<x-pane>` width preset (40, 60, 80rem)
|
* their own (its files, size and downloads; its expiry), and they wrap rather than clip. The
|
||||||
* matches; the pane centres it.
|
* 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 {
|
.admin-shares [data-md-list-item-description] {
|
||||||
max-inline-size: 42rem;
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-share-detail {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@props([
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
])
|
|
||||||
|
|
||||||
<x-stack gap="space50" class="md-text-center">
|
|
||||||
<h1 class="md-type-headline-sm">{{ $title }}</h1>
|
|
||||||
<p class="md-type-body-md md-ink-variant">{{ $description }}</p>
|
|
||||||
</x-stack>
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
<x-page :title="__('Settings')" :description="__('…')">
|
||||||
|
<x-slot:navigation><x-section-nav :items="$items" /></x-slot:navigation>
|
||||||
|
<x-card variant="outlined" heading="h2" …>…</x-card>
|
||||||
|
</x-page>
|
||||||
|
|
||||||
|
`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 (`<x-pane width="narrow">`, 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
|
||||||
|
|
||||||
|
<x-pane width="narrow" data-test="page" {{ $attributes }}>
|
||||||
|
<x-stack gap="space400">
|
||||||
|
<x-stack as="header" align="center" gap="space200">
|
||||||
|
@if ($logo)
|
||||||
|
<img src="{{ Storage::disk('public')->url($logo) }}" alt="{{ $title }}" class="page-logo" data-test="page-logo" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{ $mark ?? '' }}
|
||||||
|
|
||||||
|
<x-stack align="center" gap="space100">
|
||||||
|
<h1 class="md-type-headline-lg md-text-center">{{ $title }}</h1>
|
||||||
|
|
||||||
|
@if (filled($description))
|
||||||
|
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $description }}</p>
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
{{ $navigation ?? '' }}
|
||||||
|
|
||||||
|
<x-stack gap="space300">
|
||||||
|
{{ $slot }}
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
</x-pane>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="auth-main">
|
|
||||||
<x-surface level="surface-container-low" padding="space300" corner="xl" class="auth-card">
|
|
||||||
<x-stack gap="space300">
|
|
||||||
{{ $slot }}
|
|
||||||
</x-stack>
|
|
||||||
</x-surface>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
@include('partials.toolbar')
|
|
||||||
|
|
||||||
<x-toast />
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
<x-stack gap="space300">
|
<x-page :title="__('Admin Dashboard')" :description="__('Shares, files and storage at a glance')">
|
||||||
<h1 class="md-type-headline-md">{{ __('Admin Dashboard') }}</h1>
|
<x-grid :columns="2" gap="space200">
|
||||||
|
|
||||||
<x-grid :columns="['compact' => 2, 'expanded' => 4]" gap="space200">
|
|
||||||
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
||||||
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
||||||
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
||||||
@@ -10,49 +8,55 @@
|
|||||||
</x-stat>
|
</x-stat>
|
||||||
</x-grid>
|
</x-grid>
|
||||||
|
|
||||||
|
{{-- 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. --}}
|
||||||
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
|
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
|
||||||
<x-stack gap="space200">
|
<x-stack gap="space200">
|
||||||
@if ($shares->total() === 0)
|
@if ($shares->total() === 0)
|
||||||
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
||||||
@else
|
@else
|
||||||
<div class="admin-shares-table-scroll">
|
<div class="admin-shares-sort">
|
||||||
<x-table>
|
<x-select
|
||||||
<thead>
|
wire:model.live="sort"
|
||||||
<tr>
|
:label="__('Sort by')"
|
||||||
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
|
:options="[
|
||||||
<x-sort-header column="files_count" :sort-by="$sortBy" class="md-text-end">{{ __('Files') }}</x-sort-header>
|
['id' => 'newest', 'name' => __('Newest first')],
|
||||||
<x-sort-header column="total_size" :sort-by="$sortBy" class="md-text-end">{{ __('Size') }}</x-sort-header>
|
['id' => 'oldest', 'name' => __('Oldest first')],
|
||||||
<x-sort-header column="download_count" :sort-by="$sortBy" class="md-text-end">{{ __('Downloads') }}</x-sort-header>
|
['id' => 'expiring', 'name' => __('Expiring soonest')],
|
||||||
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
|
['id' => 'largest', 'name' => __('Largest')],
|
||||||
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
|
['id' => 'most-downloaded', 'name' => __('Most downloads')],
|
||||||
<th><span class="md-visually-hidden">{{ __('Actions') }}</span></th>
|
['id' => 'most-files', 'name' => __('Most files')],
|
||||||
</tr>
|
]"
|
||||||
</thead>
|
data-test="shares-sort"
|
||||||
<tbody>
|
/>
|
||||||
@foreach ($shares as $share)
|
|
||||||
<tr wire:key="share-{{ $share->id }}">
|
|
||||||
<td><code>{{ $share->token }}</code></td>
|
|
||||||
<td class="md-text-end md-tabular">{{ $share->files_count }}</td>
|
|
||||||
<td class="md-text-end md-tabular md-nowrap">{{ Number::fileSize($share->total_size) }}</td>
|
|
||||||
<td class="md-text-end md-tabular">{{ $share->download_count }}</td>
|
|
||||||
<td class="md-nowrap">
|
|
||||||
@if ($share->expires_at)
|
|
||||||
<span @class(['md-ink-error' => $share->isExpired()])>{{ $share->expires_at->diffForHumans() }}</span>
|
|
||||||
@else
|
|
||||||
<span class="md-ink-variant">{{ __('Never') }}</span>
|
|
||||||
@endif
|
|
||||||
</td>
|
|
||||||
<td class="md-nowrap">{{ $share->created_at->diffForHumans() }}</td>
|
|
||||||
<td class="md-text-end md-nowrap">
|
|
||||||
<x-button icon="open_in_new" :tooltip="__('Open')" :link="route('share.download', $share)" external />
|
|
||||||
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforeach
|
|
||||||
</tbody>
|
|
||||||
</x-table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{-- 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). --}}
|
||||||
|
<x-list dividers :label="__('All Shares')" class="admin-shares">
|
||||||
|
@foreach ($shares as $share)
|
||||||
|
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
|
||||||
|
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||||
|
|
||||||
|
<x-slot:description>
|
||||||
|
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||||
|
|
||||||
|
@if (! $share->expires_at)
|
||||||
|
<span class="admin-share-detail">{{ __('Never expires') }}</span>
|
||||||
|
@elseif ($share->isExpired())
|
||||||
|
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
|
@else
|
||||||
|
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
||||||
|
@endif
|
||||||
|
</x-slot:description>
|
||||||
|
|
||||||
|
<x-slot:end>
|
||||||
|
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
|
||||||
{{ $shares->links() }}
|
{{ $shares->links() }}
|
||||||
@endif
|
@endif
|
||||||
</x-stack>
|
</x-stack>
|
||||||
@@ -66,4 +70,4 @@
|
|||||||
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-modal>
|
</x-modal>
|
||||||
</x-stack>
|
</x-page>
|
||||||
|
|||||||
@@ -1,227 +1,223 @@
|
|||||||
<x-pane class="admin-settings">
|
<x-page :title="__('System Settings')" :description="__('How the site looks and what uploaders may do')">
|
||||||
<x-stack gap="space300">
|
<x-form wire:submit="saveSettings">
|
||||||
<h1 class="md-type-headline-md">{{ __('System Settings') }}</h1>
|
<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-form wire:submit="saveSettings">
|
|
||||||
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
|
|
||||||
<x-stack gap="space200">
|
<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" />
|
@if ($currentLogo)
|
||||||
</x-stack>
|
<x-row gap="space200" wrap>
|
||||||
</x-card>
|
<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-card :title="__('Branding')" heading="h2" variant="outlined">
|
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||||||
<x-stack gap="space200">
|
|
||||||
<x-input full wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
|
||||||
|
|
||||||
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
@if ($siteLogo && is_object($siteLogo))
|
||||||
|
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||||
<x-stack gap="space200">
|
<p class="md-type-body-md md-ink-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||||
@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
|
@else
|
||||||
<x-input full
|
<x-stack gap="space50">
|
||||||
wire:model.live.blur="passwordLength"
|
<p class="md-type-label-lg md-ink-variant">{{ __('Preview:') }}</p>
|
||||||
:label="__('Length')"
|
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="admin-settings-logo" />
|
||||||
type="number"
|
</x-stack>
|
||||||
: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
|
||||||
@endif
|
@endif
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</x-card>
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
<x-card :title="__('Upload Protection')" heading="h2" variant="outlined">
|
||||||
<x-stack gap="space200">
|
<x-stack gap="space200">
|
||||||
<x-toggle
|
<x-stack gap="space100">
|
||||||
wire:model.live="allowNeverExpire"
|
<x-password full
|
||||||
:label="__('Allow shares to never expire')"
|
wire:model="systemPassword"
|
||||||
:hint="__('When disabled, users must select an expiration time.')"
|
:label="__('System Upload Password')"
|
||||||
right
|
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||||||
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-select full
|
@if ($hasSystemPassword)
|
||||||
wire:model="defaultExpiration"
|
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||||||
:label="__('Default Expiration')"
|
@endif
|
||||||
:placeholder="$allowNeverExpire ? __('None') : null"
|
</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="[
|
:options="[
|
||||||
['id' => '1h', 'name' => __('1 Hour')],
|
['id' => 'characters', 'name' => __('Characters')],
|
||||||
['id' => '24h', 'name' => __('24 Hours')],
|
['id' => 'passphrase', 'name' => __('Passphrase')],
|
||||||
['id' => '48h', 'name' => __('48 Hours')],
|
|
||||||
['id' => '7d', 'name' => __('7 Days')],
|
|
||||||
['id' => '14d', 'name' => __('14 Days')],
|
|
||||||
['id' => '30d', 'name' => __('30 Days')],
|
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-input full
|
@if ($passwordGeneratorType === 'passphrase')
|
||||||
wire:model="maxFileSize"
|
<x-input full
|
||||||
:label="__('Max file size (MB)')"
|
wire:model.live.blur="passphraseWords"
|
||||||
type="number"
|
:label="__('Words')"
|
||||||
min="1"
|
type="number"
|
||||||
suffix="MB"
|
: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-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
<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-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
<x-group
|
||||||
</x-stack>
|
multiple
|
||||||
</x-card>
|
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-card :title="__('Storage')" heading="h2" variant="outlined">
|
<x-checkbox
|
||||||
<x-stack gap="space200">
|
wire:model.live="passwordAvoidAmbiguous"
|
||||||
<x-input full
|
:label="__('Avoid look-alike characters')"
|
||||||
wire:model="maxStorageQuota"
|
:hint="__('Leaves out 0, O, 1, l and I.')"
|
||||||
:label="__('Max storage quota (GB)')"
|
/>
|
||||||
type="number"
|
@endif
|
||||||
min="1"
|
|
||||||
suffix="GB"
|
|
||||||
:hint="__('When reached, new uploads are blocked.')"
|
|
||||||
/>
|
|
||||||
</x-stack>
|
|
||||||
</x-card>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
@if ($passwordExample)
|
||||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" data-test="save-settings" />
|
<x-input full
|
||||||
</x-slot:actions>
|
:label="__('Example')"
|
||||||
</x-form>
|
:value="$passwordExample"
|
||||||
|
:hint="__('About :bits bits of entropy.', ['bits' => $passwordEntropy])"
|
||||||
|
readonly
|
||||||
|
mono
|
||||||
|
data-test="password-example"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
|
||||||
{{ __('The upload and download pages show the default mark again.') }}
|
<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-slot:actions>
|
<x-select full
|
||||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
wire:model="defaultExpiration"
|
||||||
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
|
:label="__('Default Expiration')"
|
||||||
</x-slot:actions>
|
:placeholder="$allowNeverExpire ? __('None') : null"
|
||||||
</x-modal>
|
: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-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
|
<x-input full
|
||||||
{{ __('Anyone who can reach the upload page can upload files again.') }}
|
wire:model="maxFileSize"
|
||||||
|
:label="__('Max file size (MB)')"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
suffix="MB"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
|
||||||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||||||
</x-slot:actions>
|
</x-stack>
|
||||||
</x-modal>
|
</x-card>
|
||||||
</x-stack>
|
|
||||||
</x-pane>
|
<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>
|
||||||
|
|||||||
@@ -1,178 +1,164 @@
|
|||||||
<x-pane class="upload-column">
|
<x-page brand>
|
||||||
<x-stack gap="space400">
|
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
||||||
<x-stack align="center" gap="space200">
|
@if ($isStorageFull && $pendingFiles->isEmpty())
|
||||||
@if ($siteLogo)
|
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="upload-site-logo" />
|
@else
|
||||||
|
<x-form
|
||||||
|
wire:submit="createShare"
|
||||||
|
x-data="shareUploader({
|
||||||
|
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||||
|
messages: {{ \Illuminate\Support\Js::from([
|
||||||
|
'queued' => __('Waiting'),
|
||||||
|
'uploaded' => __('Uploaded'),
|
||||||
|
'failed' => __('Upload failed'),
|
||||||
|
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
||||||
|
]) }},
|
||||||
|
})"
|
||||||
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
||||||
|
>
|
||||||
|
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
||||||
|
<div x-show="! secure" x-cloak data-test="insecure-context">
|
||||||
|
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
||||||
|
<div
|
||||||
|
class="upload-drop-zone"
|
||||||
|
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
||||||
|
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
||||||
|
x-on:dragover.prevent="dragging = true"
|
||||||
|
x-on:dragleave.prevent="dragging = false"
|
||||||
|
x-on:drop.prevent="handleDrop($event)"
|
||||||
|
data-test="drop-zone"
|
||||||
|
>
|
||||||
|
<x-stack align="center" gap="space200">
|
||||||
|
<div class="upload-drop-shapes">
|
||||||
|
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
||||||
|
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
||||||
|
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-stack align="center" gap="space50">
|
||||||
|
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
||||||
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
||||||
|
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
||||||
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
||||||
|
</x-stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Upload progress, over every file still to send --}}
|
||||||
|
<div x-show="busy" x-cloak data-test="upload-progress">
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<x-row justify="between">
|
||||||
|
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||||
|
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
||||||
|
</x-row>
|
||||||
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
||||||
|
</x-stack>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@error('files')
|
||||||
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
{{-- Selected files --}}
|
||||||
|
@if ($pendingFiles->isNotEmpty())
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
||||||
|
|
||||||
|
<div class="upload-file-list">
|
||||||
|
<x-list segmented :label="__('Selected Files')">
|
||||||
|
@foreach ($pendingFiles as $file)
|
||||||
|
<x-list-item
|
||||||
|
:title="$file->relative_path ?? $file->original_name"
|
||||||
|
icon="description"
|
||||||
|
wire:key="selected-file-{{ $file->id }}"
|
||||||
|
data-test="selected-file"
|
||||||
|
>
|
||||||
|
<x-slot:description>
|
||||||
|
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
||||||
|
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
||||||
|
</x-slot:description>
|
||||||
|
<x-slot:end>
|
||||||
|
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
||||||
|
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
||||||
|
</span>
|
||||||
|
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
</div>
|
||||||
|
</x-stack>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-stack align="center" gap="space100">
|
{{-- Options --}}
|
||||||
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
||||||
|
|
||||||
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
@if ($usePassword)
|
||||||
</x-stack>
|
<x-stack gap="space100">
|
||||||
</x-stack>
|
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
||||||
|
|
||||||
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
|
||||||
@if ($isStorageFull && $pendingFiles->isEmpty())
|
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
|
||||||
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
<x-row gap="space100" wrap>
|
||||||
@else
|
@if ($passwordGeneratorMode !== 'off')
|
||||||
<x-form
|
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
||||||
wire:submit="createShare"
|
@endif
|
||||||
x-data="shareUploader({
|
|
||||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
|
||||||
messages: {{ \Illuminate\Support\Js::from([
|
|
||||||
'queued' => __('Waiting'),
|
|
||||||
'uploaded' => __('Uploaded'),
|
|
||||||
'failed' => __('Upload failed'),
|
|
||||||
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
|
||||||
]) }},
|
|
||||||
})"
|
|
||||||
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
|
||||||
>
|
|
||||||
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
|
||||||
<div x-show="! secure" x-cloak data-test="insecure-context">
|
|
||||||
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
<x-button
|
||||||
<div
|
:label="__('Copy')"
|
||||||
class="upload-drop-zone"
|
icon="content_copy"
|
||||||
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
variant="tonal"
|
||||||
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||||
x-on:dragover.prevent="dragging = true"
|
x-bind:disabled="! $wire.password"
|
||||||
x-on:dragleave.prevent="dragging = false"
|
data-test="copy-password"
|
||||||
x-on:drop.prevent="handleDrop($event)"
|
/>
|
||||||
data-test="drop-zone"
|
</x-row>
|
||||||
>
|
|
||||||
<x-stack align="center" gap="space200">
|
|
||||||
<div class="upload-drop-shapes">
|
|
||||||
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
|
||||||
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
|
||||||
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-stack align="center" gap="space50">
|
|
||||||
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
|
||||||
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
@endif
|
||||||
|
|
||||||
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
<x-select full
|
||||||
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
wire:model="expiration"
|
||||||
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
:label="__('Expiration')"
|
||||||
</x-stack>
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
||||||
</div>
|
:options="[
|
||||||
|
['id' => '1h', 'name' => __('1 Hour')],
|
||||||
{{-- Upload progress, over every file still to send --}}
|
['id' => '24h', 'name' => __('24 Hours')],
|
||||||
<div x-show="busy" x-cloak data-test="upload-progress">
|
['id' => '48h', 'name' => __('48 Hours')],
|
||||||
<x-stack gap="space100">
|
['id' => '7d', 'name' => __('7 Days')],
|
||||||
<x-row justify="between">
|
['id' => '14d', 'name' => __('14 Days')],
|
||||||
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
['id' => '30d', 'name' => __('30 Days')],
|
||||||
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
]"
|
||||||
</x-row>
|
|
||||||
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
|
||||||
</x-stack>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@error('files')
|
|
||||||
<x-alert color="error">{{ $message }}</x-alert>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
{{-- Selected files --}}
|
|
||||||
@if ($pendingFiles->isNotEmpty())
|
|
||||||
<x-stack gap="space100">
|
|
||||||
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
|
||||||
|
|
||||||
<div class="upload-file-list">
|
|
||||||
<x-list segmented :label="__('Selected Files')">
|
|
||||||
@foreach ($pendingFiles as $file)
|
|
||||||
<x-list-item
|
|
||||||
:title="$file->relative_path ?? $file->original_name"
|
|
||||||
icon="description"
|
|
||||||
wire:key="selected-file-{{ $file->id }}"
|
|
||||||
data-test="selected-file"
|
|
||||||
>
|
|
||||||
<x-slot:description>
|
|
||||||
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
|
||||||
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
|
||||||
</x-slot:description>
|
|
||||||
<x-slot:end>
|
|
||||||
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
|
||||||
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
|
||||||
</span>
|
|
||||||
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
|
||||||
</x-slot:end>
|
|
||||||
</x-list-item>
|
|
||||||
@endforeach
|
|
||||||
</x-list>
|
|
||||||
</div>
|
|
||||||
</x-stack>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
{{-- Options --}}
|
|
||||||
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
|
||||||
<x-stack gap="space200">
|
|
||||||
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
|
||||||
|
|
||||||
@if ($usePassword)
|
|
||||||
<x-stack gap="space100">
|
|
||||||
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
|
||||||
|
|
||||||
{{-- 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. --}}
|
|
||||||
<x-row gap="space100" wrap>
|
|
||||||
@if ($passwordGeneratorMode !== 'off')
|
|
||||||
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-button
|
|
||||||
:label="__('Copy')"
|
|
||||||
icon="content_copy"
|
|
||||||
variant="tonal"
|
|
||||||
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
|
||||||
x-bind:disabled="! $wire.password"
|
|
||||||
data-test="copy-password"
|
|
||||||
/>
|
|
||||||
</x-row>
|
|
||||||
</x-stack>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-select full
|
|
||||||
wire:model="expiration"
|
|
||||||
:label="__('Expiration')"
|
|
||||||
:placeholder="$allowNeverExpire ? __('Never') : 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="maxDownloads"
|
|
||||||
:label="__('Max downloads')"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:placeholder="__('Unlimited')"
|
|
||||||
/>
|
|
||||||
</x-stack>
|
|
||||||
</x-card>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button
|
|
||||||
type="submit"
|
|
||||||
:label="__('Create Share Link')"
|
|
||||||
variant="filled"
|
|
||||||
size="md"
|
|
||||||
icon="link"
|
|
||||||
spinner="createShare"
|
|
||||||
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
|
||||||
data-test="create-share"
|
|
||||||
/>
|
/>
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
<x-input full
|
||||||
@endif
|
wire:model="maxDownloads"
|
||||||
</x-stack>
|
:label="__('Max downloads')"
|
||||||
</x-pane>
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:placeholder="__('Unlimited')"
|
||||||
|
/>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-button
|
||||||
|
type="submit"
|
||||||
|
:label="__('Create Share Link')"
|
||||||
|
variant="filled"
|
||||||
|
size="md"
|
||||||
|
icon="link"
|
||||||
|
spinner="createShare"
|
||||||
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
||||||
|
data-test="create-share"
|
||||||
|
/>
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
@endif
|
||||||
|
</x-page>
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
<x-stack gap="space300">
|
<x-page brand>
|
||||||
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
|
<x-card :title="__('Set up SealShare')" :subtitle="__('Create your admin account to get started')" heading="h2" variant="outlined">
|
||||||
|
<x-form wire:submit="createAdmin">
|
||||||
|
<x-input
|
||||||
|
wire:model="name"
|
||||||
|
:label="__('Name')"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
:placeholder="__('Admin name')"
|
||||||
|
icon="person"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-form wire:submit="createAdmin">
|
<x-input
|
||||||
<x-input
|
wire:model="email"
|
||||||
wire:model="name"
|
:label="__('Email address')"
|
||||||
:label="__('Name')"
|
type="email"
|
||||||
type="text"
|
required
|
||||||
required
|
placeholder="admin@example.com"
|
||||||
autofocus
|
icon="mail"
|
||||||
:placeholder="__('Admin name')"
|
/>
|
||||||
icon="person"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-input
|
<x-password
|
||||||
wire:model="email"
|
wire:model="password"
|
||||||
:label="__('Email address')"
|
:label="__('Password')"
|
||||||
type="email"
|
required
|
||||||
required
|
:placeholder="__('Password')"
|
||||||
placeholder="admin@example.com"
|
/>
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password_confirmation"
|
||||||
:label="__('Password')"
|
:label="__('Confirm password')"
|
||||||
required
|
required
|
||||||
:placeholder="__('Password')"
|
:placeholder="__('Confirm password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
wire:model="password_confirmation"
|
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
|
||||||
:label="__('Confirm password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
:placeholder="__('Confirm password')"
|
</x-card>
|
||||||
/>
|
</x-page>
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-stack>
|
|
||||||
|
|||||||
@@ -1,97 +1,90 @@
|
|||||||
<x-pane class="share-column">
|
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
|
||||||
<x-stack gap="space400">
|
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
|
||||||
<x-stack align="center" gap="space200">
|
<x-slot:mark>
|
||||||
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
|
<div class="share-check">
|
||||||
<div class="share-check">
|
<x-shape name="soft-burst" class="share-check-shape" />
|
||||||
<x-shape name="soft-burst" class="share-check-shape" />
|
<x-icon name="check" size="48" class="share-check-icon" />
|
||||||
<x-icon name="check" size="48" class="share-check-icon" />
|
</div>
|
||||||
</div>
|
</x-slot:mark>
|
||||||
|
|
||||||
<x-stack align="center" gap="space50">
|
<x-stack gap="space200">
|
||||||
<h1 class="md-type-headline-md md-text-center">{{ __('Share Created!') }}</h1>
|
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
|
||||||
<p class="md-type-body-lg md-ink-variant md-text-center">{{ __('Your files are ready to share') }}</p>
|
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
|
||||||
</x-stack>
|
<x-stack
|
||||||
</x-stack>
|
gap="space100"
|
||||||
|
x-data="shareActions({
|
||||||
|
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
|
||||||
|
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
|
||||||
|
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
|
||||||
|
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
|
||||||
|
})"
|
||||||
|
data-test="share-actions"
|
||||||
|
>
|
||||||
|
<x-input
|
||||||
|
:label="__('Share Link')"
|
||||||
|
:value="$shareUrl"
|
||||||
|
readonly
|
||||||
|
copyable
|
||||||
|
mono
|
||||||
|
icon="link"
|
||||||
|
data-test="share-link"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-stack gap="space200">
|
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
|
||||||
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
|
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
|
||||||
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
|
@if ($password)
|
||||||
<x-stack
|
|
||||||
gap="space100"
|
|
||||||
x-data="shareActions({
|
|
||||||
url: {{ \Illuminate\Support\Js::from($shareUrl) }},
|
|
||||||
title: {{ \Illuminate\Support\Js::from($siteTitle) }},
|
|
||||||
filename: {{ \Illuminate\Support\Js::from('share-'.$share->token.'.png') }},
|
|
||||||
messages: {{ \Illuminate\Support\Js::from(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) }},
|
|
||||||
})"
|
|
||||||
data-test="share-actions"
|
|
||||||
>
|
|
||||||
<x-input
|
<x-input
|
||||||
:label="__('Share Link')"
|
type="password"
|
||||||
:value="$shareUrl"
|
:label="__('Password')"
|
||||||
|
:value="$password"
|
||||||
|
:hint="__('Available only this once. Send it separately from the link.')"
|
||||||
readonly
|
readonly
|
||||||
copyable
|
copyable
|
||||||
mono
|
icon="key"
|
||||||
icon="link"
|
autocomplete="off"
|
||||||
data-test="share-link"
|
data-test="share-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{-- Only on the visit the upload redirects to: the password is flashed once (FileUploader::createShare).
|
|
||||||
Masked, with the link's copy button at its end, so it is copied without reaching the screen. --}}
|
|
||||||
@if ($password)
|
|
||||||
<x-input
|
|
||||||
type="password"
|
|
||||||
:label="__('Password')"
|
|
||||||
:value="$password"
|
|
||||||
:hint="__('Available only this once. Send it separately from the link.')"
|
|
||||||
readonly
|
|
||||||
copyable
|
|
||||||
icon="key"
|
|
||||||
autocomplete="off"
|
|
||||||
data-test="share-password"
|
|
||||||
/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-row gap="space100" wrap>
|
|
||||||
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
|
|
||||||
|
|
||||||
<span x-show="canShare" x-cloak>
|
|
||||||
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
|
|
||||||
</span>
|
|
||||||
</x-row>
|
|
||||||
|
|
||||||
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
|
|
||||||
<x-stack gap="space200">
|
|
||||||
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
|
|
||||||
either theme so a scanner keeps its contrast; the container adds no colour. --}}
|
|
||||||
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
|
|
||||||
|
|
||||||
@if ($share->isPasswordProtected())
|
|
||||||
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
|
|
||||||
@endif
|
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button :label="__('Close')" x-on:click="close()" />
|
|
||||||
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-modal>
|
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
<x-grid :columns="2" gap="space200">
|
|
||||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
|
||||||
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
|
||||||
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
|
||||||
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
|
||||||
</x-grid>
|
|
||||||
|
|
||||||
@if ($share->isPasswordProtected())
|
|
||||||
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-row justify="end">
|
<x-row gap="space100" wrap>
|
||||||
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
|
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
|
||||||
|
|
||||||
|
<span x-show="canShare" x-cloak>
|
||||||
|
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
|
||||||
|
</span>
|
||||||
</x-row>
|
</x-row>
|
||||||
|
|
||||||
|
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
{{-- The quiet zone is baked into the SVG (App\Services\QrCodeService), white in
|
||||||
|
either theme so a scanner keeps its contrast; the container adds no colour. --}}
|
||||||
|
<div data-qr-code class="share-qr">{!! $qrCodeSvg !!}</div>
|
||||||
|
|
||||||
|
@if ($share->isPasswordProtected())
|
||||||
|
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
|
||||||
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-button :label="__('Close')" x-on:click="close()" />
|
||||||
|
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-modal>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
|
||||||
|
<x-grid :columns="2" gap="space200">
|
||||||
|
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
||||||
|
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
||||||
|
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
||||||
|
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
||||||
|
</x-grid>
|
||||||
|
|
||||||
|
@if ($share->isPasswordProtected())
|
||||||
|
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-row justify="end">
|
||||||
|
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
|
||||||
|
</x-row>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</x-pane>
|
</x-page>
|
||||||
|
|||||||
@@ -1,71 +1,58 @@
|
|||||||
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
|
{{-- 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. --}}
|
iOS before Safari 18.4, which cannot position them. --}}
|
||||||
|
|
||||||
<x-pane class="download-column">
|
<x-page brand>
|
||||||
<x-stack gap="space400">
|
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
|
||||||
<x-stack align="center" gap="space200">
|
on, and it is the shape SealShare has always shown them. --}}
|
||||||
@if ($siteLogo)
|
@if (! $authenticated)
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="download-site-logo" />
|
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
|
||||||
@endif
|
<x-form wire:submit="verifyPassword">
|
||||||
|
<x-password
|
||||||
|
full
|
||||||
|
wire:model="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
:placeholder="__('Enter share password')"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-stack align="center" gap="space100">
|
<x-slot:actions>
|
||||||
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
|
||||||
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
</x-slot:actions>
|
||||||
</x-stack>
|
</x-form>
|
||||||
</x-stack>
|
</x-card>
|
||||||
|
@else
|
||||||
|
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<x-stack gap="space100">
|
||||||
|
<x-list :label="__('Shared Files')">
|
||||||
|
@foreach ($share->files as $file)
|
||||||
|
<x-list-item
|
||||||
|
:title="$file->relative_path ?: $file->original_name"
|
||||||
|
icon="description"
|
||||||
|
wire:key="file-{{ $file->id }}"
|
||||||
|
>
|
||||||
|
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
||||||
|
<x-slot:end>
|
||||||
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
||||||
|
</x-slot:end>
|
||||||
|
</x-list-item>
|
||||||
|
@endforeach
|
||||||
|
</x-list>
|
||||||
|
|
||||||
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
|
@if ($share->expires_at)
|
||||||
on, and it is the shape SealShare has always shown them. --}}
|
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
||||||
@if (! $authenticated)
|
@endif
|
||||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
|
|
||||||
<x-form wire:submit="verifyPassword">
|
|
||||||
<x-password
|
|
||||||
full
|
|
||||||
wire:model="password"
|
|
||||||
:label="__('Password')"
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
:placeholder="__('Enter share password')"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-card>
|
|
||||||
@else
|
|
||||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
|
||||||
<x-stack gap="space200">
|
|
||||||
<x-stack gap="space100">
|
|
||||||
<x-list :label="__('Shared Files')">
|
|
||||||
@foreach ($share->files as $file)
|
|
||||||
<x-list-item
|
|
||||||
:title="$file->relative_path ?: $file->original_name"
|
|
||||||
icon="description"
|
|
||||||
wire:key="file-{{ $file->id }}"
|
|
||||||
>
|
|
||||||
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span></x-slot:description>
|
|
||||||
<x-slot:end>
|
|
||||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
|
||||||
</x-slot:end>
|
|
||||||
</x-list-item>
|
|
||||||
@endforeach
|
|
||||||
</x-list>
|
|
||||||
|
|
||||||
@if ($share->expires_at)
|
|
||||||
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
|
||||||
@endif
|
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
<x-row justify="end">
|
|
||||||
@if ($share->files->count() > 1)
|
|
||||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
|
|
||||||
@else
|
|
||||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
|
|
||||||
@endif
|
|
||||||
</x-row>
|
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</x-card>
|
|
||||||
@endif
|
<x-row justify="end">
|
||||||
</x-stack>
|
@if ($share->files->count() > 1)
|
||||||
</x-pane>
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate />
|
||||||
|
@else
|
||||||
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate />
|
||||||
|
@endif
|
||||||
|
</x-row>
|
||||||
|
</x-stack>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
</x-page>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<x-stack gap="space300">
|
<x-page brand>
|
||||||
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
|
<x-card :title="__('System password required')" :subtitle="__('Enter the system password to access the upload page')" heading="h2" variant="outlined">
|
||||||
|
<x-form wire:submit="verify">
|
||||||
|
<x-password
|
||||||
|
wire:model="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
required
|
||||||
|
:placeholder="__('System password')"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-form wire:submit="verify">
|
<x-slot:actions>
|
||||||
<x-password
|
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
|
||||||
wire:model="password"
|
</x-slot:actions>
|
||||||
:label="__('Password')"
|
</x-form>
|
||||||
required
|
</x-card>
|
||||||
:placeholder="__('System password')"
|
</x-page>
|
||||||
/>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-stack>
|
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
<x-layouts::auth :title="__('Confirm password')">
|
<x-layouts::app :title="__('Confirm password')">
|
||||||
<x-auth-header
|
<x-page brand>
|
||||||
:title="__('Confirm password')"
|
<x-card
|
||||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
:title="__('Confirm password')"
|
||||||
/>
|
:subtitle="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||||
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.confirm.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<x-form method="POST" action="{{ route('password.confirm.store') }}">
|
<x-password
|
||||||
@csrf
|
name="password"
|
||||||
|
:label="__('Password')"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
name="password"
|
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
|
||||||
:label="__('Password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
autofocus
|
</x-stack>
|
||||||
autocomplete="current-password"
|
</x-card>
|
||||||
/>
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
<x-layouts::auth :title="__('Forgot password')">
|
<x-layouts::app :title="__('Forgot password')">
|
||||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Forgot password')" :subtitle="__('Enter your email to receive a password reset link')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.email') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<x-form method="POST" action="{{ route('password.email') }}">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
|
:label="__('Email Address')"
|
||||||
|
:value="old('email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
placeholder="email@example.com"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-slot:actions>
|
||||||
name="email"
|
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
|
||||||
:label="__('Email Address')"
|
</x-slot:actions>
|
||||||
:value="old('email')"
|
</x-form>
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
autofocus
|
|
||||||
placeholder="email@example.com"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
<p class="md-type-body-md md-ink-variant md-text-center">
|
||||||
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
|
{{ __('Or, return to') }}
|
||||||
</x-slot:actions>
|
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
|
||||||
</x-form>
|
</p>
|
||||||
|
</x-stack>
|
||||||
<p class="md-type-body-md md-ink-variant md-text-center">
|
</x-card>
|
||||||
{{ __('Or, return to') }}
|
</x-page>
|
||||||
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
|
</x-layouts::app>
|
||||||
</p>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -1,44 +1,48 @@
|
|||||||
<x-layouts::auth :title="__('Log in')">
|
<x-layouts::app :title="__('Log in')">
|
||||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Log in')" :subtitle="__('Enter your email and password below to log in')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('login.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
<x-form method="POST" action="{{ route('login.store') }}">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
|
:label="__('Email address')"
|
||||||
|
:value="old('email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-stack gap="space50">
|
||||||
name="email"
|
<x-password
|
||||||
:label="__('Email address')"
|
name="password"
|
||||||
:value="old('email')"
|
:label="__('Password')"
|
||||||
type="email"
|
required
|
||||||
required
|
autocomplete="current-password"
|
||||||
autofocus
|
/>
|
||||||
autocomplete="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-stack gap="space50">
|
@if (Route::has('password.request'))
|
||||||
<x-password
|
<x-row justify="end">
|
||||||
name="password"
|
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
||||||
:label="__('Password')"
|
{{ __('Forgot your password?') }}
|
||||||
required
|
</a>
|
||||||
autocomplete="current-password"
|
</x-row>
|
||||||
/>
|
@endif
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
@if (Route::has('password.request'))
|
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
||||||
<x-row justify="end">
|
|
||||||
<a class="md-link md-type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
|
||||||
{{ __('Forgot your password?') }}
|
|
||||||
</a>
|
|
||||||
</x-row>
|
|
||||||
@endif
|
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
<x-slot:actions>
|
||||||
|
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
|
||||||
<x-slot:actions>
|
</x-slot:actions>
|
||||||
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
|
</x-form>
|
||||||
</x-slot:actions>
|
</x-stack>
|
||||||
</x-form>
|
</x-card>
|
||||||
</x-layouts::auth>
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,38 +1,42 @@
|
|||||||
<x-layouts::auth :title="__('Reset password')">
|
<x-layouts::app :title="__('Reset password')">
|
||||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
<x-page brand>
|
||||||
|
<x-card :title="__('Reset password')" :subtitle="__('Please enter your new password below')" heading="h2" variant="outlined">
|
||||||
|
<x-stack gap="space300">
|
||||||
|
<x-auth-session-status :status="session('status')" />
|
||||||
|
|
||||||
<x-auth-session-status :status="session('status')" />
|
<x-form method="POST" action="{{ route('password.update') }}">
|
||||||
|
@csrf
|
||||||
|
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||||
|
|
||||||
<x-form method="POST" action="{{ route('password.update') }}">
|
<x-input
|
||||||
@csrf
|
name="email"
|
||||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
:value="old('email', request('email'))"
|
||||||
|
:label="__('Email')"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
icon="mail"
|
||||||
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-password
|
||||||
name="email"
|
name="password"
|
||||||
:value="old('email', request('email'))"
|
:label="__('Password')"
|
||||||
:label="__('Email')"
|
required
|
||||||
type="email"
|
autocomplete="new-password"
|
||||||
required
|
/>
|
||||||
autocomplete="email"
|
|
||||||
icon="mail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
name="password"
|
name="password_confirmation"
|
||||||
:label="__('Password')"
|
:label="__('Confirm password')"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-slot:actions>
|
||||||
name="password_confirmation"
|
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
|
||||||
:label="__('Confirm password')"
|
</x-slot:actions>
|
||||||
required
|
</x-form>
|
||||||
autocomplete="new-password"
|
</x-stack>
|
||||||
/>
|
</x-card>
|
||||||
|
</x-page>
|
||||||
<x-slot:actions>
|
</x-layouts::app>
|
||||||
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -1,67 +1,65 @@
|
|||||||
<x-layouts::auth :title="__('Two-factor authentication')">
|
<x-layouts::app :title="__('Two-factor authentication')">
|
||||||
<x-stack
|
<x-page brand>
|
||||||
gap="space300"
|
<x-card :title="__('Two-factor authentication')" heading="h2" variant="outlined">
|
||||||
x-data="{
|
<x-stack
|
||||||
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
|
gap="space300"
|
||||||
toggleInput() {
|
x-data="{
|
||||||
this.showRecoveryInput = ! this.showRecoveryInput;
|
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
|
||||||
$nextTick(() => {
|
toggleInput() {
|
||||||
requestAnimationFrame(() => {
|
this.showRecoveryInput = ! this.showRecoveryInput;
|
||||||
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
$nextTick(() => {
|
||||||
});
|
requestAnimationFrame(() => {
|
||||||
});
|
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
||||||
},
|
});
|
||||||
}"
|
});
|
||||||
>
|
},
|
||||||
<div x-show="! showRecoveryInput">
|
}"
|
||||||
<x-auth-header
|
>
|
||||||
:title="__('Authentication Code')"
|
<p class="md-type-body-md md-ink-variant" x-show="! showRecoveryInput">
|
||||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
{{ __('Enter the authentication code provided by your authenticator application.') }}
|
||||||
/>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div x-show="showRecoveryInput" x-cloak>
|
<p class="md-type-body-md md-ink-variant" x-show="showRecoveryInput" x-cloak>
|
||||||
<x-auth-header
|
{{ __('Please confirm access to your account by entering one of your emergency recovery codes.') }}
|
||||||
:title="__('Recovery Code')"
|
</p>
|
||||||
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
|
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<div x-ref="code" x-show="! showRecoveryInput">
|
<div x-ref="code" x-show="! showRecoveryInput">
|
||||||
<x-input
|
<x-input
|
||||||
name="code"
|
name="code"
|
||||||
:label="__('Code')"
|
:label="__('Code')"
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
mono
|
mono
|
||||||
autofocus
|
autofocus
|
||||||
x-bind:disabled="showRecoveryInput"
|
x-bind:disabled="showRecoveryInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
|
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
|
||||||
<x-input
|
<x-input
|
||||||
name="recovery_code"
|
name="recovery_code"
|
||||||
:label="__('Recovery code')"
|
:label="__('Recovery code')"
|
||||||
autocomplete="one-time-code"
|
autocomplete="one-time-code"
|
||||||
mono
|
mono
|
||||||
x-bind:disabled="! showRecoveryInput"
|
x-bind:disabled="! showRecoveryInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button type="submit" :label="__('Continue')" variant="filled" />
|
<x-button type="submit" :label="__('Continue')" variant="filled" />
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-form>
|
</x-form>
|
||||||
|
|
||||||
<p class="md-type-body-md md-ink-variant md-text-center">
|
<p class="md-type-body-md md-ink-variant md-text-center">
|
||||||
{{ __('or you can') }}
|
{{ __('or you can') }}
|
||||||
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
|
<button type="button" class="md-link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
|
||||||
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
|
<button type="button" class="md-link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
|
||||||
</p>
|
</p>
|
||||||
</x-stack>
|
</x-stack>
|
||||||
</x-layouts::auth>
|
</x-card>
|
||||||
|
</x-page>
|
||||||
|
</x-layouts::app>
|
||||||
|
|||||||
@@ -1,28 +1,34 @@
|
|||||||
<x-layouts::auth :title="__('Verify email')">
|
<x-layouts::app :title="__('Verify email')">
|
||||||
<x-auth-header
|
<x-page brand>
|
||||||
:title="__('Verify your email')"
|
<x-card
|
||||||
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
:title="__('Verify your email')"
|
||||||
/>
|
:subtitle="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
||||||
|
heading="h2"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300">
|
||||||
|
@if (session('status') == 'verification-link-sent')
|
||||||
|
<x-alert color="success">
|
||||||
|
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||||
|
</x-alert>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if (session('status') == 'verification-link-sent')
|
<x-stack gap="space100">
|
||||||
<x-alert color="success">
|
<x-form method="POST" action="{{ route('verification.send') }}">
|
||||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
@csrf
|
||||||
</x-alert>
|
<x-slot:actions>
|
||||||
@endif
|
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
|
||||||
<x-stack gap="space100">
|
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
|
||||||
<x-form method="POST" action="{{ route('verification.send') }}">
|
edge, the card's two actions end-aligned below its content. --}}
|
||||||
@csrf
|
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
|
||||||
<x-slot:actions>
|
@csrf
|
||||||
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
|
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
||||||
</x-slot:actions>
|
</x-row>
|
||||||
</x-form>
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
|
</x-card>
|
||||||
edge, the page's two actions end-aligned below its content. --}}
|
</x-page>
|
||||||
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
|
</x-layouts::app>
|
||||||
@csrf
|
|
||||||
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
|
||||||
</x-row>
|
|
||||||
</x-stack>
|
|
||||||
</x-layouts::auth>
|
|
||||||
|
|||||||
@@ -11,17 +11,17 @@
|
|||||||
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-stack gap="space400">
|
<x-page :title="__('Settings')" :description="__('Manage your profile and account settings')">
|
||||||
<x-section-nav :items="$items" :label="__('Settings')" />
|
<x-slot:navigation>
|
||||||
|
<x-section-nav :items="$items" :label="__('Settings')" />
|
||||||
|
</x-slot:navigation>
|
||||||
|
|
||||||
{{-- Every settings page is a card headed by its own title, as the admin's settings are. A page
|
{{-- 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
|
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. --}}
|
codes — puts it in `after`, where it becomes a card of its own under this one. --}}
|
||||||
<x-stack gap="space300" class="settings-column">
|
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
|
||||||
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
|
{{ $slot }}
|
||||||
{{ $slot }}
|
</x-card>
|
||||||
</x-card>
|
|
||||||
|
|
||||||
{{ $after ?? '' }}
|
{{ $after ?? '' }}
|
||||||
</x-stack>
|
</x-page>
|
||||||
</x-stack>
|
|
||||||
|
|||||||
@@ -6,10 +6,6 @@ new class extends Component {
|
|||||||
//
|
//
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section>
|
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
||||||
@include('partials.settings-heading')
|
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
|
||||||
|
</x-pages::settings.layout>
|
||||||
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
|
||||||
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
|
|
||||||
</x-pages::settings.layout>
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -43,18 +43,14 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section>
|
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
||||||
@include('partials.settings-heading')
|
<x-form method="POST" wire:submit="updatePassword">
|
||||||
|
<x-password full wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
||||||
|
<x-password full wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
||||||
|
<x-password full wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
<x-slot:actions>
|
||||||
<x-form method="POST" wire:submit="updatePassword">
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
||||||
<x-password full wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
</x-slot:actions>
|
||||||
<x-password full wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
</x-form>
|
||||||
<x-password full wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
|
</x-pages::settings.layout>
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
</x-pages::settings.layout>
|
|
||||||
</section>
|
|
||||||
|
|||||||
@@ -80,40 +80,36 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section>
|
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
||||||
@include('partials.settings-heading')
|
<x-form wire:submit="updateProfileInformation">
|
||||||
|
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
<x-stack gap="space100">
|
||||||
<x-form wire:submit="updateProfileInformation">
|
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
||||||
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
|
||||||
|
|
||||||
<x-stack gap="space100">
|
@if ($this->hasUnverifiedEmail)
|
||||||
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
<p class="md-type-body-md md-ink-variant">
|
||||||
|
{{ __('Your email address is unverified.') }}
|
||||||
|
|
||||||
@if ($this->hasUnverifiedEmail)
|
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
|
||||||
<p class="md-type-body-md md-ink-variant">
|
{{ __('Click here to re-send the verification email.') }}
|
||||||
{{ __('Your email address is unverified.') }}
|
</button>
|
||||||
|
</p>
|
||||||
|
|
||||||
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
|
@if (session('status') === 'verification-link-sent')
|
||||||
{{ __('Click here to re-send the verification email.') }}
|
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (session('status') === 'verification-link-sent')
|
|
||||||
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
|
||||||
@endif
|
|
||||||
@endif
|
@endif
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
|
||||||
</x-slot:actions>
|
|
||||||
</x-form>
|
|
||||||
|
|
||||||
<x-slot:after>
|
|
||||||
@if ($this->showDeleteUser)
|
|
||||||
<livewire:pages::settings.delete-user-form />
|
|
||||||
@endif
|
@endif
|
||||||
</x-slot:after>
|
</x-stack>
|
||||||
</x-pages::settings.layout>
|
|
||||||
</section>
|
<x-slot:actions>
|
||||||
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||||
|
</x-slot:actions>
|
||||||
|
</x-form>
|
||||||
|
|
||||||
|
<x-slot:after>
|
||||||
|
@if ($this->showDeleteUser)
|
||||||
|
<livewire:pages::settings.delete-user-form />
|
||||||
|
@endif
|
||||||
|
</x-slot:after>
|
||||||
|
</x-pages::settings.layout>
|
||||||
|
|||||||
@@ -178,94 +178,90 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
} ?>
|
} ?>
|
||||||
|
|
||||||
<section>
|
<x-pages::settings.layout
|
||||||
@include('partials.settings-heading')
|
:heading="__('Two Factor Authentication')"
|
||||||
|
:subheading="__('Manage your two-factor authentication settings')"
|
||||||
|
>
|
||||||
|
<x-stack gap="space300" wire:cloak>
|
||||||
|
@if ($twoFactorEnabled)
|
||||||
|
<x-stack gap="space200" align="start">
|
||||||
|
<x-badge :value="__('Enabled')" tonal color="success" />
|
||||||
|
|
||||||
<x-pages::settings.layout
|
<p class="md-type-body-md md-ink-variant">
|
||||||
:heading="__('Two Factor Authentication')"
|
{{ __('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.') }}
|
||||||
:subheading="__('Manage your two-factor authentication settings')"
|
</p>
|
||||||
>
|
|
||||||
<x-stack gap="space300" wire:cloak>
|
|
||||||
@if ($twoFactorEnabled)
|
|
||||||
<x-stack gap="space200" align="start">
|
|
||||||
<x-badge :value="__('Enabled')" tonal color="success" />
|
|
||||||
|
|
||||||
<p class="md-type-body-md md-ink-variant">
|
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
||||||
{{ __('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.') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
|
||||||
</x-stack>
|
|
||||||
@else
|
|
||||||
<x-stack gap="space200" align="start">
|
|
||||||
<x-badge :value="__('Disabled')" tonal color="error" />
|
|
||||||
|
|
||||||
<p class="md-type-body-md md-ink-variant">
|
|
||||||
{{ __('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.') }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
|
||||||
</x-stack>
|
|
||||||
@endif
|
|
||||||
</x-stack>
|
|
||||||
|
|
||||||
{{-- The recovery codes are their own subject, so they are their own card beside this one. --}}
|
|
||||||
<x-slot:after>
|
|
||||||
@if ($twoFactorEnabled)
|
|
||||||
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
|
||||||
@endif
|
|
||||||
</x-slot:after>
|
|
||||||
</x-pages::settings.layout>
|
|
||||||
|
|
||||||
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
|
||||||
@if ($showVerificationStep)
|
|
||||||
<x-input
|
|
||||||
name="code"
|
|
||||||
wire:model="code"
|
|
||||||
:label="__('Code')"
|
|
||||||
inputmode="numeric"
|
|
||||||
autocomplete="one-time-code"
|
|
||||||
maxlength="6"
|
|
||||||
mono
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-slot:actions>
|
|
||||||
<x-button :label="__('Back')" wire:click="resetVerification" />
|
|
||||||
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
|
|
||||||
</x-slot:actions>
|
|
||||||
@else
|
|
||||||
<x-stack gap="space300">
|
|
||||||
@error('setupData')
|
|
||||||
<x-alert color="error">{{ $message }}</x-alert>
|
|
||||||
@enderror
|
|
||||||
|
|
||||||
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
|
|
||||||
<x-row justify="center">
|
|
||||||
<div class="settings-two-factor-qr">
|
|
||||||
@empty($qrCodeSvg)
|
|
||||||
<x-loading :label="__('Loading')" />
|
|
||||||
@else
|
|
||||||
{!! $qrCodeSvg !!}
|
|
||||||
@endempty
|
|
||||||
</div>
|
|
||||||
</x-row>
|
|
||||||
|
|
||||||
<x-stack gap="space200">
|
|
||||||
<p class="md-type-label-lg md-ink-variant md-text-center">{{ __('or, enter the code manually') }}</p>
|
|
||||||
|
|
||||||
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
|
||||||
</x-stack>
|
|
||||||
</x-stack>
|
</x-stack>
|
||||||
|
@else
|
||||||
|
<x-stack gap="space200" align="start">
|
||||||
|
<x-badge :value="__('Disabled')" tonal color="error" />
|
||||||
|
|
||||||
<x-slot:actions>
|
<p class="md-type-body-md md-ink-variant">
|
||||||
<x-button
|
{{ __('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.') }}
|
||||||
:disabled="$errors->has('setupData')"
|
</p>
|
||||||
:label="$this->modalConfig['buttonText']"
|
|
||||||
variant="filled"
|
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
||||||
wire:click="showVerificationIfNecessary"
|
</x-stack>
|
||||||
/>
|
|
||||||
</x-slot:actions>
|
|
||||||
@endif
|
@endif
|
||||||
</x-modal>
|
</x-stack>
|
||||||
</section>
|
|
||||||
|
{{-- The recovery codes are their own subject, so they are their own card under this one. --}}
|
||||||
|
<x-slot:after>
|
||||||
|
@if ($twoFactorEnabled)
|
||||||
|
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
||||||
|
@if ($showVerificationStep)
|
||||||
|
<x-input
|
||||||
|
name="code"
|
||||||
|
wire:model="code"
|
||||||
|
:label="__('Code')"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
mono
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-button :label="__('Back')" wire:click="resetVerification" />
|
||||||
|
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
|
||||||
|
</x-slot:actions>
|
||||||
|
@else
|
||||||
|
<x-stack gap="space300">
|
||||||
|
@error('setupData')
|
||||||
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
|
@enderror
|
||||||
|
|
||||||
|
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
|
||||||
|
<x-row justify="center">
|
||||||
|
<div class="settings-two-factor-qr">
|
||||||
|
@empty($qrCodeSvg)
|
||||||
|
<x-loading :label="__('Loading')" />
|
||||||
|
@else
|
||||||
|
{!! $qrCodeSvg !!}
|
||||||
|
@endempty
|
||||||
|
</div>
|
||||||
|
</x-row>
|
||||||
|
|
||||||
|
<x-stack gap="space200">
|
||||||
|
<p class="md-type-label-lg md-ink-variant md-text-center">{{ __('or, enter the code manually') }}</p>
|
||||||
|
|
||||||
|
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
||||||
|
</x-stack>
|
||||||
|
</x-stack>
|
||||||
|
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-button
|
||||||
|
:disabled="$errors->has('setupData')"
|
||||||
|
:label="$this->modalConfig['buttonText']"
|
||||||
|
variant="filled"
|
||||||
|
wire:click="showVerificationIfNecessary"
|
||||||
|
/>
|
||||||
|
</x-slot:actions>
|
||||||
|
@endif
|
||||||
|
</x-modal>
|
||||||
|
</x-slot:after>
|
||||||
|
</x-pages::settings.layout>
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<x-stack gap="space50" class="settings-heading">
|
|
||||||
<h1 class="md-type-headline-md">{{ __('Settings') }}</h1>
|
|
||||||
<p class="md-type-body-md md-ink-variant">{{ __('Manage your profile and account settings') }}</p>
|
|
||||||
</x-stack>
|
|
||||||
@@ -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,
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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
|
* 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 () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// <x-grid :columns="['compact' => 2, 'expanded' => 4]">: 2 columns below `expanded` (840px), 4 from it.
|
// <x-grid :columns="2">: two columns at every width, since the page's column is 40rem at all of them.
|
||||||
expect($columns[839])->toBe(2);
|
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 () {
|
test('the admin settings page holds at every breakpoint', function () {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Group A's frame: the auth layout's centred card and the app layout's main column
|
* The frame every page shares: the app layout's main region (resources/views/layouts/app.blade.php,
|
||||||
* (resources/views/layouts/*, partials/*, components/*), regardless of which page renders inside
|
* partials/toolbar.blade.php) and the page template's centred column inside it
|
||||||
* them — a page's own content may still be unstyled (a later batch), but the chrome around it is
|
* (resources/views/components/page.blade.php), whichever page renders there.
|
||||||
* group A's and must hold its geometry.
|
|
||||||
*/
|
*/
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
config(['session.driver' => 'file']);
|
config(['session.driver' => '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 () {
|
test('every page column is 40rem and centred on a wide window', function (string $url, bool $asAdmin) {
|
||||||
$page = ready(visit('/login')->resize(393, 852));
|
if ($asAdmin) {
|
||||||
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
}
|
||||||
|
|
||||||
$metrics = $page->script("(() => {
|
$metrics = ready(visit($url)->resize(1600, 900))->script("(() => {
|
||||||
const rect = document.querySelector('.auth-card').getBoundingClientRect();
|
const remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
return { left: rect.left, right: window.innerWidth - rect.right, width: rect.width };
|
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['left'])->toEqualWithDelta(16, 1);
|
||||||
expect($metrics['right'])->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 () {
|
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)');
|
$page->script('window.scrollTo(0, document.body.scrollHeight)');
|
||||||
|
|
||||||
$overlap = $page->script("(() => {
|
$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();
|
const toolbar = document.querySelector('[data-test=\"app-toolbar\"]').getBoundingClientRect();
|
||||||
return card.bottom - toolbar.top;
|
return card.bottom - toolbar.top;
|
||||||
})()");
|
})()");
|
||||||
@@ -109,27 +92,9 @@ test("a snackbar clears SealShare's floating toolbar", function () {
|
|||||||
expect($gap)->toBeGreaterThanOrEqual(16 - 0.5);
|
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 () {
|
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
|
// The main region spans the full window at every width (the page inside sets its own width),
|
||||||
// it declares on its body is exactly the gap between its content and the window's edge.
|
// 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("(() => {
|
$margins = fn (int $width) => ready(visit('/upload')->resize($width, 900))->script("(() => {
|
||||||
const main = document.querySelector('.app-main').getBoundingClientRect();
|
const main = document.querySelector('.app-main').getBoundingClientRect();
|
||||||
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
|
const style = getComputedStyle(document.querySelector('[data-md-pane-body]'));
|
||||||
|
|||||||
@@ -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');
|
->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();
|
$admin = User::factory()->admin()->create();
|
||||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
||||||
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
||||||
|
|
||||||
$this->actingAs($admin);
|
$this->actingAs($admin);
|
||||||
|
|
||||||
$page = ready(visit('/admin/dashboard'));
|
$page = ready(visit('/admin/dashboard'));
|
||||||
|
|
||||||
$page->click('th button:has-text("Downloads")')
|
$page->assertScript("document.querySelector('[data-test=\"share-row\"] code').textContent.trim() === 'aaaaaaaaaaaaaaaa'")
|
||||||
->assertScript("document.querySelector('tbody tr td').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
->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}\"]")
|
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
||||||
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
||||||
|
|||||||
@@ -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
|
* 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
|
* 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
|
* cards' grouping — now end-aligned actions, the shares table in its card and each settings
|
||||||
* settings sections respectively. Nothing here asserts any of those; a browser run follows this
|
* section in a card of its own respectively (tests/Browser/ShareFlowTest.php asserts the last two).
|
||||||
* review.
|
* Nothing here asserts any of those.
|
||||||
*/
|
*/
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
config(['session.driver' => 'file']);
|
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();
|
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();
|
$admin = User::factory()->admin()->create();
|
||||||
Share::factory()->count(3)->create();
|
Share::factory()->count(3)->create();
|
||||||
$this->actingAs($admin);
|
$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['rows'])->toBe(2);
|
||||||
expect($narrow['cols'])->toBe(2);
|
expect($narrow['cols'])->toBe(2);
|
||||||
|
|
||||||
$wide = $columns(840);
|
$wide = $columns(1600);
|
||||||
expect($wide['rows'])->toBe(1);
|
expect($wide['rows'])->toBe(2);
|
||||||
expect($wide['cols'])->toBe(4);
|
expect($wide['cols'])->toBe(2);
|
||||||
|
|
||||||
// The table is wide enough on a phone to need its own horizontal scroll (so this is not a
|
// The shares are a list that wraps within the page's column, so nothing scrolls sideways.
|
||||||
// vacuous check); the page itself must never pick that scroll up.
|
|
||||||
$phone = ready(visit('/admin/dashboard')->resize(393, 852));
|
$phone = ready(visit('/admin/dashboard')->resize(393, 852));
|
||||||
|
|
||||||
$overflow = $phone->script("(() => {
|
$overflow = $phone->script("(() => ({
|
||||||
const scroller = document.querySelector('.admin-shares-table-scroll');
|
rows: document.querySelectorAll('[data-test=\"share-row\"]').length,
|
||||||
return {
|
pageScrollWidth: document.documentElement.scrollWidth,
|
||||||
tableNeedsScroll: scroller.scrollWidth > scroller.clientWidth + 1,
|
windowWidth: window.innerWidth,
|
||||||
pageScrollWidth: document.documentElement.scrollWidth,
|
}))()");
|
||||||
windowWidth: window.innerWidth,
|
|
||||||
};
|
|
||||||
})()");
|
|
||||||
|
|
||||||
expect($overflow['tableNeedsScroll'])->toBeTrue();
|
expect($overflow['rows'])->toBe(3);
|
||||||
expect($overflow['pageScrollWidth'])->toBeLessThanOrEqual($overflow['windowWidth']);
|
expect($overflow['pageScrollWidth'])->toBeLessThanOrEqual($overflow['windowWidth']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"]'],
|
['/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();
|
$admin = User::factory()->admin()->create();
|
||||||
Share::factory()->count(2)->create();
|
Share::factory()->count(2)->create();
|
||||||
$this->actingAs($admin);
|
$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("(() => {
|
$result = $page->script("(() => {
|
||||||
const heading = [...document.querySelectorAll('h2')].find((h) => h.textContent.trim() === 'All Shares');
|
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 {
|
return {
|
||||||
headingIsH2: heading?.tagName === 'H2',
|
headingIsCardTitle: heading?.matches('[data-md-card-title]') ?? false,
|
||||||
headingBeforeTable: !!heading && !!table
|
listInSameCard: !!card && card.contains(list),
|
||||||
&& !!(heading.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING),
|
headingBeforeList: !!heading && !!list
|
||||||
noCard: document.querySelectorAll('[data-md-card]').length === 0,
|
&& !!(heading.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||||||
};
|
};
|
||||||
})()");
|
})()");
|
||||||
|
|
||||||
expect($result['headingIsH2'])->toBeTrue();
|
expect($result['headingIsCardTitle'])->toBeTrue();
|
||||||
expect($result['headingBeforeTable'])->toBeTrue();
|
expect($result['listInSameCard'])->toBeTrue();
|
||||||
expect($result['noCard'])->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());
|
$this->actingAs(User::factory()->admin()->create());
|
||||||
|
|
||||||
$page = ready(visit('/admin/settings'));
|
$page = ready(visit('/admin/settings'));
|
||||||
|
|
||||||
$result = $page->script("(() => {
|
$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 {
|
return {
|
||||||
// Every dialog's own title is an h2 too (components/modal.blade.php), whether open or
|
headings: headings.map((h) => h.textContent.trim()),
|
||||||
// not: exclude those to count only the page's own section headings.
|
cardCount: cards.length,
|
||||||
h2Count: document.querySelectorAll('h2:not([data-md-modal-title])').length,
|
everyCardHeaded: cards.every((card) => card.querySelector('h2[data-md-card-title]') !== null),
|
||||||
noCard: document.querySelectorAll('[data-md-card]').length === 0,
|
|
||||||
};
|
};
|
||||||
})()");
|
})()");
|
||||||
|
|
||||||
expect($result['h2Count'])->toBe(6);
|
expect($result['headings'])->toBe(['Colour profile', 'Branding', 'Upload Protection', 'Share Passwords', 'Upload Limits', 'Storage']);
|
||||||
expect($result['noCard'])->toBeTrue();
|
expect($result['cardCount'])->toBe(6);
|
||||||
|
expect($result['everyCardHeaded'])->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ test('admin can delete share', function () {
|
|||||||
expect(Share::query()->find($shareId))->toBeNull();
|
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();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$share = Share::factory()->create(['token' => 'testtoken12345678']);
|
$share = Share::factory()->create(['token' => 'testtoken12345678']);
|
||||||
@@ -70,31 +70,47 @@ test('admin dashboard shows shares table', function () {
|
|||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertSee('testtoken12345678');
|
$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();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
||||||
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminDashboard::class)
|
->test(AdminDashboard::class)
|
||||||
->set('sortBy', ['column' => 'download_count', 'direction' => 'asc'])
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz'])
|
||||||
|
->set('sort', 'most-downloaded')
|
||||||
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
||||||
->set('sortBy', ['column' => 'token; drop table shares', 'direction' => 'sideways'])
|
->set('sort', 'token; drop table shares')
|
||||||
->assertOk();
|
->assertOk()
|
||||||
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz']);
|
||||||
|
|
||||||
expect(Share::query()->count())->toBe(2);
|
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();
|
$admin = User::query()->where('is_admin', true)->first();
|
||||||
|
|
||||||
$this->actingAs($admin)->get(route('admin.dashboard'))
|
$this->actingAs($admin)->get(route('admin.dashboard'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('No shares yet')
|
->assertSee('No shares yet')
|
||||||
->assertDontSee('<table', false);
|
->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 () {
|
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Share;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Testing\TestResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The opening tag of the page template's pane (resources/views/components/page.blade.php).
|
||||||
|
*/
|
||||||
|
function pageTag(string $html): string
|
||||||
|
{
|
||||||
|
preg_match('/<div\s[^>]*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('/<h1[\s>]/', $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"', '<h1', 'Send files to Acme Engineering.'], false)
|
||||||
|
->assertSee(Storage::disk('public')->url('branding/acme.png'), false)
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<h1[^>]*>\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(['<h1', 'Share your files safely and securely'], false)
|
||||||
|
->assertDontSee('data-test="page-logo"', false)
|
||||||
|
->getContent();
|
||||||
|
|
||||||
|
expect($html)->toMatch('/<h1[^>]*>\s*'.preg_quote(config('app.name'), '/').'\s*<\/h1>/');
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |