Move onto Livewire Material 2.0.0 and leave Tailwind behind

Livewire Material 2.0.0 aligns every component with Material 3
Expressive and carries no Tailwind anywhere, so SealShare drops
tailwindcss and its Vite plugin and writes its views in the package's
vocabulary: layout components (<x-pane>, <x-stack>, <x-row>, <x-grid>,
<x-form>) with M3's spacing tokens, the md-type-*/md-ink-* text classes,
and --md-sys-* tokens in its own small stylesheet.

- resources/css/app.css opens with the package's layer order, imports
  foundation.css and the stylesheet of each component the views render,
  then the scheme, regenerated with the 2025 colour rules at M3's three
  contrast levels. The app's own rules follow, one section per view,
  on tokens and on M3's breakpoints (600/840/1200/1600px) only.
- Every view is rewritten in that vocabulary while SealShare keeps the
  shape it had: the admin table, the admin settings, the recovery codes,
  the share options and the download page are cards, and their fields
  fill them rather than stopping at the 40rem bound a card already
  bounds. The user settings pages became cards too, to match the
  admin's, each with the sections that stand apart from its one subject
  — deleting the account, the recovery codes — in a card beside it.
  Material 3 decides how a component behaves, not whether a container
  survives: buttons keep their label's width, a form's actions end it,
  and each heading level keeps one type role.
- The layouts clear the floating toolbar by the --material-bottom-toolbar
  the package publishes, and the snackbar clears it by itself.
- The two-factor setup QR code comes from QrCodeService, so it keeps a
  white field and quiet zone in the dark theme and still scans.
- Browse Files is a real button that opens the file input, reachable
  and visibly focused from the keyboard.
- Tests: the design test scans views, JS, CSS and app/ and checks the
  CSS entry both ways (no missing, no unused import). New Chromium
  suites cover the frame, settings and admin, the share flow, and every
  page at M3's breakpoint edges (599/600, 839/840, 1199/1200, 1600px).
  The package's renamed data-md-* hooks replace the 1.x ones.
- Boost's update brings the material-3 guideline and skill and drops
  the Tailwind skill.

composer.json requires nonameweb/livewire-material ^2.0 from the Gitea
repository, resolved at the 2.0.0 tag. The CHANGELOG records the move as
2.1.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andreas Reinhold / reini
2026-09-15 22:30:51 +02:00
co-authored by Claude Opus 5
parent 461bc23f0a
commit a88a052d9a
48 changed files with 8311 additions and 2078 deletions
+393 -8
View File
@@ -1,22 +1,407 @@
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import '../../vendor/nonameweb/livewire-material/resources/css/foundation.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/grid.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/pane.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/row.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/stack.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/layout/surface.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/account-menu.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/alert.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/badge.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/button.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/card.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/checkbox.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/divider.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/empty-state.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/file.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/form.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/icon.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/input.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list-item.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/list.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/loading.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/menu-item.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/modal.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/pagination.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/password.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/progress.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/scheme-picker.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/shape.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/sort-header.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/stat.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/table.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/textarea.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/theme-toggle.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toast.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toggle.css';
@import '../../vendor/nonameweb/livewire-material/resources/css/components/toolbar.css';
@import './material-scheme.css';
@source '../views';
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
/*
* SealShare's own rules, unlayered so they outrank every package rule: one section per view, in
* the order a visitor meets them the two layouts, the share flow (upload, share created,
* download), the settings pages in their navigation's order, then admin.
*/
/* share-created: the check on its shape settles in once the link is ready. */
/*
* resources/views/layouts/app.blade.php: the signed-in and public pages' main column.
*
* `<x-pane as="main">` gives the column its horizontal M3 margin (16px below `medium`, 24px from
* it) and centres it; its cap is SealShare's own 64rem, margins included, which no `width` preset
* (40, 60, 80rem) matches. The vertical rhythm is the app's own. The bottom padding clears the
* floating toolbar in partials/toolbar.blade.php by what the toolbar publishes as
* `--material-bottom-toolbar` (its top edge's distance from the window's bottom, safe area
* included), plus 16px. Never set `--material-bottom-bar` here: the toolbar reads it to place
* itself.
*/
.app-main {
max-inline-size: 64rem;
padding-block-start: var(--md-sys-measurement-space400);
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
}
@media (width >= 600px) {
.app-main {
padding-block-start: var(--md-sys-measurement-space600);
}
}
/*
* resources/views/layouts/auth.blade.php: the centred sign-in card.
*
* The card sits in a full-height flex column that top-aligns it below `medium` and centres it
* from there. The bottom padding clears the floating toolbar exactly as .app-main's does (see
* above); the horizontal padding and top padding stay flat at every width, as the 1.x layout had.
*/
.auth-main {
display: flex;
justify-content: center;
align-items: flex-start;
min-block-size: 100dvh;
padding-inline: var(--md-sys-measurement-space200);
padding-block-start: var(--md-sys-measurement-space400);
padding-block-end: calc(var(--material-bottom-toolbar, 0px) + var(--md-sys-measurement-space200));
}
@media (width >= 600px) {
.auth-main {
align-items: center;
}
}
.auth-card {
inline-size: 100%;
max-inline-size: 28rem;
}
@media (width >= 600px) {
.auth-card {
padding: var(--md-sys-measurement-space400);
}
}
/*
* resources/views/livewire/file-uploader.blade.php: the upload page, kept to 1.x's centred 48rem
* measure (max-w-3xl) inside the 64rem main column, which no `<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;
}
/*
* resources/views/livewire/file-uploader.blade.php: the drop zone's dashed outline and its
* primary tint while dragging. `data-dragging` is Alpine's, not the package's, since no
* component tracks a native drag over an arbitrary drop target; disabled while an upload runs
* blocks pointer events and dims to M3's disabled-content opacity, as a code dims elsewhere while
* busy (.settings-recovery-code--loading).
*/
.upload-drop-zone {
padding: var(--md-sys-measurement-space400);
border: 2px dashed var(--md-sys-color-outline-variant);
border-radius: var(--md-sys-shape-corner-xl);
transition: border-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default), background-color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
.upload-drop-zone[data-dragging='true'] {
border-color: var(--md-sys-color-primary);
background-color: color-mix(in srgb, var(--md-sys-color-primary-container) 40%, transparent);
}
.upload-drop-zone[aria-disabled='true'] {
pointer-events: none;
opacity: var(--md-sys-state-disabled-content-opacity);
}
/*
* resources/views/livewire/file-uploader.blade.php: the drop zone's shape morphs into a burst
* while files are dragged over it SealShare's signature, kept from 1.x (docs/reference/m3/styles.md
* § Shape: "Shape morph should respond to user interaction"). Two `<x-shape>`s sit
* stacked (`inset: 0` on an absolutely positioned element sizes it to the box, no width/height
* class needed) and cross-fade/scale on the spatial-slow spring the shape's size warrants
* (docs/reference/m3/styles.md § Motion: "larger elements may use slow"); opacity rides the
* effects-slow spring beside it, since a colour or fade must never overshoot. Reduced motion needs
* no local override: the tokens themselves zero out under it (tokens/motion.css).
*/
.upload-drop-shapes {
position: relative;
display: grid;
place-items: center;
inline-size: 7rem;
block-size: 7rem;
}
.upload-drop-shape {
position: absolute;
inset: 0;
transition: scale var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), rotate var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow), opacity var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow);
}
.upload-drop-shape--idle {
scale: 1;
rotate: 0deg;
opacity: 1;
color: var(--md-sys-color-secondary-container);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--idle {
scale: 0.5;
rotate: 45deg;
opacity: 0;
}
.upload-drop-shape--burst {
scale: 0.5;
rotate: -45deg;
opacity: 0;
color: var(--md-sys-color-primary-container);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-shape--burst {
scale: 1.1;
rotate: 0deg;
opacity: 1;
}
.upload-drop-icon {
position: relative;
color: var(--md-sys-color-on-secondary-container);
transition: color var(--md-sys-motion-effects-default-duration) var(--md-sys-motion-effects-default);
}
.upload-drop-zone[data-dragging='true'] .upload-drop-icon {
color: var(--md-sys-color-on-primary-container);
}
/* resources/views/livewire/file-uploader.blade.php: the "Processing files..." indicator at 1.x's smaller size, beside its label. */
.upload-processing-indicator {
inline-size: 2rem;
block-size: 2rem;
}
/* resources/views/livewire/file-uploader.blade.php: the selected-files list scrolls on its own past 1.x's cap instead of pushing the options and the submit button down the page. */
.upload-file-list {
max-block-size: 18rem;
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
* shape once the link is ready (the `share-ready`/`share-ready-fade` keyframes after it). The shape
* sits at the box's edges (`inset: 0` on an absolutely positioned element sizes it, no width/height
* class needed); both colours are container roles `md-ink-*` has no class for, so they are the
* application's own CSS rather than a component prop.
*/
.share-check {
position: relative;
display: grid;
place-items: center;
inline-size: 6rem;
block-size: 6rem;
animation:
share-ready var(--md-sys-motion-spatial-slow-duration) var(--md-sys-motion-spatial-slow) both,
share-ready-fade var(--md-sys-motion-effects-slow-duration) var(--md-sys-motion-effects-slow) both;
}
.share-check-shape {
position: absolute;
inset: 0;
color: var(--md-sys-color-primary-container);
}
.share-check-icon {
/* Without this the icon, though later in the DOM, is a non-positioned in-flow child: it paints
before the absolutely positioned shape beside it (CSS's stacking order for z-index:auto) and
sits hidden underneath it, as .upload-drop-icon's own position: relative is there to avoid. */
position: relative;
color: var(--md-sys-color-on-primary-container);
}
/*
* resources/views/livewire/share-created.blade.php: the check settling onto its shape, run by
* .share-check rotate and scale on the spatial spring (shape motion), opacity on effects beside
* it, since M3 never lets a colour or fade overshoot; reduced motion needs no local override, the
* duration tokens themselves zero out under it.
*/
@keyframes share-ready {
from {
opacity: 0;
rotate: -90deg;
scale: 0.4;
}
to {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
@keyframes share-ready-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/*
* resources/views/livewire/share-created.blade.php: the QR code dialog. `App\Services\QrCodeService`
* already draws its SVG black on white with a four-module quiet zone, so the container adds no
* colour of its own no colour class or literal colour could give it one that also holds in dark
* mode. The corner only rounds the container that clips it, exactly as .settings-two-factor-qr's does.
*/
.share-qr {
display: grid;
place-items: center;
inline-size: 100%;
max-inline-size: 20rem;
aspect-ratio: 1;
margin-inline: auto;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-lg);
}
.share-qr svg {
inline-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
* twoFactorQrCodeSvg() draws no quiet zone, so the SVG comes from App\Services\QrCodeService
* against the same otpauth URL instead, which bakes in its own white field and four-module quiet
* zone the only way to guarantee one in dark mode, since no colour class or literal colour can
* paint it onto 2.0.0's foundation. Sized at 1.x's 16rem square, corners rounded and clipped to
* match the settings surfaces around it.
*/
.settings-two-factor-qr {
display: grid;
place-items: center;
inline-size: 16rem;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-lg);
}
.settings-two-factor-qr svg {
inline-size: 100%;
block-size: 100%;
}
/*
* resources/views/pages/settings/two-factor/recovery-codes.blade.php: a code dims to M3's disabled
* content opacity while regenerateRecoveryCodes() is in flight, and back, on the effects spring
* instead of Tailwind's animate-pulse loop 2.0.0 keeps no keyframe utility for it. The
* transition sits on the code itself so the way back eases too; under reduced motion the token's
* duration is 0ms.
*/
.settings-recovery-code {
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
.settings-recovery-code--loading {
opacity: var(--md-sys-state-disabled-content-opacity);
}
/*
* 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
* 24rem matches no `<x-pane>` preset.
*/
.settings-appearance-picker {
max-inline-size: 24rem;
}
/*
* resources/views/livewire/admin/admin-dashboard.blade.php: the shares table scrolls sideways on
* its own, on a window too narrow for every column, instead of the page around it.
*/
.admin-shares-table-scroll {
overflow-x: auto;
}
/*
* resources/views/livewire/admin/admin-settings.blade.php: the settings keep 1.x's own narrower
* measure inside the 64rem main column, 42rem, which no `<x-pane>` width preset (40, 60, 80rem)
* matches; the pane centres it.
*/
.admin-settings {
max-inline-size: 42rem;
}
/*
* resources/views/livewire/admin/admin-settings.blade.php: the current and previewed site logo,
* at 1.x's 4rem height with its width following the image's own ratio. M3 keeps no size scale for
* a plain <img>.
*/
.admin-settings-logo {
block-size: 4rem;
border-radius: var(--md-sys-shape-corner-sm);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
'description',
])
<div class="flex w-full flex-col text-center">
<h1 class="type-headline-sm">{{ $title }}</h1>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
</div>
<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>
+3 -3
View File
@@ -3,10 +3,10 @@
<head>
@include('partials.head')
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
<main class="mx-auto w-full max-w-5xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12">
<body>
<x-pane as="main" class="app-main">
{{ $slot }}
</main>
</x-pane>
@include('partials.toolbar')
+6 -6
View File
@@ -3,13 +3,13 @@
<head>
@include('partials.head')
</head>
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
<main class="flex flex-1 items-start justify-center px-4 pt-8 pb-32 sm:items-center">
<div class="w-full max-w-md rounded-corner-xl bg-surface-container-low p-6 sm:p-8">
<div class="flex flex-col gap-6">
<body>
<main class="auth-main">
<x-surface level="surface-container-low" padding="space300" corner="xl" class="auth-card">
<x-stack gap="space300">
{{ $slot }}
</div>
</div>
</x-stack>
</x-surface>
</main>
@include('partials.toolbar')
@@ -1,60 +1,61 @@
<div>
<h1 class="mb-6 type-headline-md">{{ __('Admin Dashboard') }}</h1>
<x-stack gap="space300">
<h1 class="md-type-headline-md">{{ __('Admin Dashboard') }}</h1>
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<x-grid :columns="['compact' => 2, 'expanded' => 4]" gap="space200">
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" class="mt-2" :label="__('Disk Usage')" />
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" :label="__('Disk Usage')" />
</x-stat>
</div>
</x-grid>
<x-card :title="__('All Shares')" variant="outlined">
{{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}}
@if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else
<div class="-mx-4 overflow-x-auto">
<x-table>
<thead>
<tr>
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
<x-sort-header column="files_count" :sort-by="$sortBy" class="text-end">{{ __('Files') }}</x-sort-header>
<x-sort-header column="total_size" :sort-by="$sortBy" class="text-end">{{ __('Size') }}</x-sort-header>
<x-sort-header column="download_count" :sort-by="$sortBy" class="text-end">{{ __('Downloads') }}</x-sort-header>
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
<th><span class="sr-only">{{ __('Actions') }}</span></th>
</tr>
</thead>
<tbody>
@foreach ($shares as $share)
<tr wire:key="share-{{ $share->id }}">
<td class="font-mono">{{ $share->token }}</td>
<td class="text-end tabular-nums">{{ $share->files_count }}</td>
<td class="text-end tabular-nums whitespace-nowrap">{{ Number::fileSize($share->total_size) }}</td>
<td class="text-end tabular-nums">{{ $share->download_count }}</td>
<td class="whitespace-nowrap">
@if ($share->expires_at)
<span @class(['text-error' => $share->isExpired()])>{{ $share->expires_at->diffForHumans() }}</span>
@else
<span class="text-on-surface-variant">{{ __('Never') }}</span>
@endif
</td>
<td class="whitespace-nowrap">{{ $share->created_at->diffForHumans() }}</td>
<td class="text-end whitespace-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>
<x-card :title="__('All Shares')" heading="h2" variant="outlined">
<x-stack gap="space200">
@if ($shares->total() === 0)
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
@else
<div class="admin-shares-table-scroll">
<x-table>
<thead>
<tr>
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
<x-sort-header column="files_count" :sort-by="$sortBy" class="md-text-end">{{ __('Files') }}</x-sort-header>
<x-sort-header column="total_size" :sort-by="$sortBy" class="md-text-end">{{ __('Size') }}</x-sort-header>
<x-sort-header column="download_count" :sort-by="$sortBy" class="md-text-end">{{ __('Downloads') }}</x-sort-header>
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
<th><span class="md-visually-hidden">{{ __('Actions') }}</span></th>
</tr>
@endforeach
</tbody>
</x-table>
</div>
</thead>
<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 class="mt-4">{{ $shares->links() }}</div>
@endif
{{ $shares->links() }}
@endif
</x-stack>
</x-card>
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
@@ -65,4 +66,4 @@
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
</x-slot:actions>
</x-modal>
</div>
</x-stack>
@@ -1,126 +1,134 @@
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
<x-pane class="admin-settings">
<x-stack gap="space300">
<h1 class="md-type-headline-md">{{ __('System Settings') }}</h1>
<form wire:submit="saveSettings" class="grid gap-6">
<x-card :title="__('Colour profile')" variant="outlined">
<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-card>
<x-form wire:submit="saveSettings">
<x-card :title="__('Colour profile')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
</x-stack>
</x-card>
<x-card :title="__('Branding')" variant="outlined">
<div class="grid gap-5">
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
<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 page.')" />
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
<x-textarea full wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
<div class="grid gap-3">
@if ($currentLogo)
<div class="flex flex-wrap items-center gap-4">
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</div>
@endif
<x-file 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="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<div>
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
</div>
<x-stack gap="space200">
@if ($currentLogo)
<x-row gap="space200" wrap>
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="admin-settings-logo" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</x-row>
@endif
@endif
</div>
</div>
</x-card>
<x-card :title="__('Upload Protection')" variant="outlined">
<div class="grid gap-3">
<x-password
wire:model="systemPassword"
:label="__('System Upload Password')"
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
autocomplete="new-password"
/>
<x-file full wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
@if ($hasSystemPassword)
<div>
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
</div>
@endif
</div>
</x-card>
@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 Limits')" variant="outlined">
<div class="grid gap-5">
<x-toggle
wire:model.live="allowNeverExpire"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<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"
/>
<x-select
wire:model="defaultExpiration"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/>
@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>
<x-input
wire:model="maxFileSize"
:label="__('Max file size (MB)')"
type="number"
min="1"
:max="$phpMaxUploadMb"
suffix="MB"
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
/>
<x-card :title="__('Upload Limits')" heading="h2" variant="outlined">
<x-stack gap="space200">
<x-toggle
wire:model.live="allowNeverExpire"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-select full
wire:model="defaultExpiration"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
['id' => '24h', 'name' => __('24 Hours')],
['id' => '48h', 'name' => __('48 Hours')],
['id' => '7d', 'name' => __('7 Days')],
['id' => '14d', 'name' => __('14 Days')],
['id' => '30d', 'name' => __('30 Days')],
]"
/>
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</div>
</x-card>
<x-input full
wire:model="maxFileSize"
:label="__('Max file size (MB)')"
type="number"
min="1"
:max="$phpMaxUploadMb"
suffix="MB"
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
/>
<x-card :title="__('Storage')" variant="outlined">
<x-input
wire:model="maxStorageQuota"
:label="__('Max storage quota (GB)')"
type="number"
min="1"
suffix="GB"
:hint="__('When reached, new uploads are blocked.')"
/>
</x-card>
<x-input full wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" data-test="save-settings" />
</form>
<x-input full wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</x-stack>
</x-card>
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload and download pages show the default mark again.') }}
<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 :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-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="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
{{ __('Anyone who can reach the upload page can upload files again.') }}
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload and download pages show the default mark 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>
</div>
<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-stack>
</x-pane>
+201 -205
View File
@@ -1,212 +1,208 @@
<div class="mx-auto max-w-3xl">
<div class="mb-8 text-center">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
@endif
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if ($isStorageFull)
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<form
wire:submit="createShare"
x-data="{
uploading: false,
progress: 0,
dragging: false,
handleDrop(e) {
this.dragging = false;
const items = e.dataTransfer.items;
const files = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) {
this.traverseEntry(entry, '', files);
} else if (items[i].kind === 'file') {
files.push({ file: items[i].getAsFile(), path: null });
}
}
setTimeout(() => {
if (! files.length) {
return;
}
const dt = new DataTransfer();
const paths = [];
files.forEach(f => {
dt.items.add(f.file);
paths.push(f.path);
});
$wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
this.uploading = true;
this.progress = 0;
$wire.uploadMultiple(
'files',
dt.files,
() => this.progress = 100,
() => this.resetUpload(),
(event) => this.progress = event.detail.progress,
() => this.resetUpload(),
);
}, 500);
},
resetUpload() {
this.uploading = false;
this.progress = 0;
},
traverseEntry(entry, path, files) {
if (entry.isFile) {
entry.file(file => {
files.push({ file, path: path ? path + '/' + file.name : null });
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
reader.readEntries(entries => {
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
});
}
}
}"
x-init="$wire.$on('files-processed', () => resetUpload())"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="progress = 100"
x-on:livewire-upload-cancel="resetUpload()"
x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="mb-6 rounded-corner-xl border-2 border-dashed p-8 text-center transition-colors duration-(--md-sys-motion-effects-default-duration) ease-effects-default"
x-bind:class="{
'border-primary bg-primary-container/40': dragging,
'border-outline-variant': ! dragging,
'pointer-events-none opacity-60': uploading,
}"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<div class="relative mx-auto mb-4 grid size-28 place-items-center">
<span
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
x-bind:class="dragging ? 'scale-50 rotate-45 opacity-0' : 'scale-100 rotate-0 opacity-100'"
><x-shape name="cookie-9" class="size-full text-secondary-container" /></span>
<span
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
x-bind:class="dragging ? 'scale-110 rotate-0 opacity-100' : 'scale-50 -rotate-45 opacity-0'"
><x-shape name="soft-burst" class="size-full text-primary-container" /></span>
<x-icon name="upload" class="relative size-12 text-on-secondary-container" x-bind:class="dragging && 'text-on-primary-container'" />
</div>
<p class="type-title-md">{{ __('Drag & drop files or folders here') }}</p>
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('or click to browse') }}</p>
<label
class="state-layer focus-ring mt-4 inline-flex h-10 cursor-pointer items-center gap-2 rounded-corner-full border border-outline-variant px-4 type-label-lg text-primary has-focus-visible:outline-3 has-focus-visible:outline-secondary"
x-bind:class="uploading && 'pointer-events-none opacity-38'"
>
<x-icon name="folder_open" class="size-5" />
{{ __('Browse Files') }}
<input type="file" wire:model="files" multiple class="sr-only" x-bind:disabled="uploading" />
</label>
</div>
{{-- Upload progress --}}
<div x-show="uploading" x-cloak class="mb-6" data-test="upload-progress">
<div x-show="progress < 100">
<div class="mb-2 flex items-center justify-between">
<span class="type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<x-button :label="__('Cancel')" size="xs" x-on:click="$wire.cancelUpload('files')" />
</div>
<x-progress bind="progress" wavy :label="__('Uploading')" />
</div>
<div x-show="progress >= 100" class="flex items-center gap-3 type-label-lg">
<x-loading class="size-8" :label="false" />
{{ __('Processing files...') }}
</div>
</div>
@error('files')
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
@enderror
{{-- Selected files --}}
@if (count($files))
<div class="mb-6">
<h2 class="mb-2 type-title-md">{{ __('Selected Files') }} ({{ count($files) }})</h2>
<div class="max-h-72 overflow-y-auto">
<x-list segmented :label="__('Selected Files')">
@foreach ($files as $index => $file)
<x-list-item
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
:description="Number::fileSize($file->getSize())"
icon="description"
wire:key="selected-file-{{ $index }}"
>
<x-slot:end>
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
</x-slot:end>
</x-list-item>
@endforeach
</x-list>
</div>
</div>
<x-pane class="upload-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="upload-site-logo" />
@endif
{{-- Options --}}
<x-card :title="__('Share Options')" variant="outlined" class="mb-6">
<div class="grid gap-5">
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
@if ($usePassword)
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
@endif
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</x-stack>
</x-stack>
<x-select
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')],
]"
/>
@if ($isStorageFull)
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<x-form
wire:submit="createShare"
x-data="{
uploading: false,
progress: 0,
dragging: false,
handleDrop(e) {
this.dragging = false;
const items = e.dataTransfer.items;
const files = [];
<x-input
wire:model="maxDownloads"
:label="__('Max downloads')"
type="number"
min="1"
:placeholder="__('Unlimited')"
/>
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) {
this.traverseEntry(entry, '', files);
} else if (items[i].kind === 'file') {
files.push({ file: items[i].getAsFile(), path: null });
}
}
setTimeout(() => {
if (! files.length) {
return;
}
const dt = new DataTransfer();
const paths = [];
files.forEach(f => {
dt.items.add(f.file);
paths.push(f.path);
});
$wire.relativePaths = [...($wire.relativePaths ?? []), ...paths];
this.uploading = true;
this.progress = 0;
$wire.uploadMultiple(
'files',
dt.files,
() => this.progress = 100,
() => this.resetUpload(),
(event) => this.progress = event.detail.progress,
() => this.resetUpload(),
);
}, 500);
},
resetUpload() {
this.uploading = false;
this.progress = 0;
},
traverseEntry(entry, path, files) {
if (entry.isFile) {
entry.file(file => {
files.push({ file, path: path ? path + '/' + file.name : null });
});
} else if (entry.isDirectory) {
const reader = entry.createReader();
reader.readEntries(entries => {
entries.forEach(e => this.traverseEntry(e, path ? path + '/' + entry.name : entry.name, files));
});
}
}
}"
x-init="$wire.$on('files-processed', () => resetUpload())"
x-on:livewire-upload-start="uploading = true; progress = 0"
x-on:livewire-upload-finish="progress = 100"
x-on:livewire-upload-cancel="resetUpload()"
x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
{{-- 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="uploading ? 'true' : 'false'"
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 upload. --}}
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="uploading" />
<input type="file" wire:model="files" multiple hidden x-ref="picker" x-bind:disabled="uploading" />
</x-stack>
</div>
</x-card>
<x-button
type="submit"
:label="__('Create Share Link')"
variant="filled"
size="md"
class="w-full"
icon="link"
spinner="createShare"
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
data-test="create-share"
/>
</form>
@endif
</div>
{{-- Upload progress --}}
<div x-show="uploading" x-cloak data-test="upload-progress">
<x-stack x-show="progress < 100" 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="$wire.cancelUpload('files')" />
</x-row>
<x-progress bind="progress" wavy :label="__('Uploading')" />
</x-stack>
<x-row x-show="progress >= 100" gap="space100">
<x-loading class="upload-processing-indicator" :label="false" />
<span class="md-type-label-lg">{{ __('Processing files...') }}</span>
</x-row>
</div>
@error('files')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
{{-- Selected files --}}
@if (count($files))
<x-stack gap="space100">
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ count($files) }})</h2>
<div class="upload-file-list">
<x-list segmented :label="__('Selected Files')">
@foreach ($files as $index => $file)
<x-list-item
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
icon="description"
wire:key="selected-file-{{ $index }}"
>
<x-slot:description><span class="md-tabular">{{ Number::fileSize($file->getSize()) }}</span></x-slot:description>
<x-slot:end>
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
</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-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
@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="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
data-test="create-share"
/>
</x-slot:actions>
</x-form>
@endif
</x-stack>
</x-pane>
@@ -1,7 +1,7 @@
<div class="flex flex-col gap-6">
<x-stack gap="space300">
<x-auth-header :title="__('Setup SealShare')" :description="__('Create your admin account to get started')" />
<form wire:submit="createAdmin" class="flex flex-col gap-6">
<x-form wire:submit="createAdmin">
<x-input
wire:model="name"
:label="__('Name')"
@@ -35,6 +35,8 @@
:placeholder="__('Confirm password')"
/>
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
</form>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" spinner="createAdmin" />
</x-slot:actions>
</x-form>
</x-stack>
@@ -1,75 +1,81 @@
<div class="mx-auto max-w-lg">
<div class="mb-8 text-center">
{{-- The link is ready: a check on an Expressive shape that settles in. --}}
<div class="relative mx-auto mb-4 grid size-24 place-items-center motion-safe:animate-[share-ready_var(--md-sys-motion-spatial-slow-duration)_var(--md-sys-motion-spatial-slow)_both]">
<x-shape name="soft-burst" class="absolute inset-0 size-full text-primary-container" />
<x-icon name="check" class="relative size-12 text-on-primary-container" />
</div>
<h1 class="type-headline-md">{{ __('Share Created!') }}</h1>
<p class="mt-1 type-body-lg text-on-surface-variant">{{ __('Your files are ready to share') }}</p>
</div>
<div class="grid gap-4">
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
<div
x-data="shareActions({
url: @js($shareUrl),
title: @js($siteTitle),
filename: @js('share-'.$share->token.'.png'),
messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]),
})"
class="grid gap-3"
data-test="share-actions"
>
<x-input
:label="__('Share Link')"
:value="$shareUrl"
readonly
copyable
icon="link"
data-test="share-link"
/>
<div class="flex flex-wrap gap-2">
<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 class="inline-flex">
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
</span>
<x-pane class="share-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
<div class="share-check">
<x-shape name="soft-burst" class="share-check-shape" />
<x-icon name="check" size="48" class="share-check-icon" />
</div>
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
{{-- White in either theme: a scanner needs the contrast. The SVG is drawn from the app's own URL. --}}
<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>
<x-stack align="center" gap="space50">
<h1 class="md-type-headline-md md-text-center">{{ __('Share Created!') }}</h1>
<p class="md-type-body-lg md-ink-variant md-text-center">{{ __('Your files are ready to share') }}</p>
</x-stack>
</x-stack>
@if ($share->isPasswordProtected())
<div class="mt-4">
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
</div>
@endif
<x-stack gap="space200">
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
<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-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>
</div>
<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" />
<div class="grid grid-cols-2 gap-3">
<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" />
</div>
<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>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<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>
<div class="flex justify-end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</div>
</div>
</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
<x-row justify="end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</x-row>
</x-stack>
</x-stack>
</x-pane>
@@ -1,63 +1,71 @@
{{-- 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. --}}
<div class="mx-auto w-full max-w-lg">
<div class="mb-8 text-center">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
@endif
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if (! $authenticated)
<form wire:submit="verifyPassword">
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" variant="outlined">
<x-password
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" class="w-full" />
</x-slot:actions>
</x-card>
</form>
@else
<x-card :title="__('Shared Files')" variant="outlined">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<x-list-item
:title="$file->relative_path ?: $file->original_name"
:description="Number::fileSize($file->file_size)"
icon="description"
wire:key="file-{{ $file->id }}"
>
<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="mt-2 type-body-sm text-on-surface-variant">
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
</p>
<x-pane class="download-column">
<x-stack gap="space400">
<x-stack align="center" gap="space200">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="download-site-logo" />
@endif
<x-slot:actions>
@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 class="w-full" />
@else
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
@endif
</x-slot:actions>
</x-card>
@endif
</div>
<x-stack align="center" gap="space100">
<h1 class="md-type-headline-lg md-text-center">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="md-type-body-lg md-ink-variant md-text-center">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</x-stack>
</x-stack>
{{-- Each state is one card under the page's h1: the card holds everything the recipient acts
on, and it is the shape SealShare has always shown them. --}}
@if (! $authenticated)
<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-card>
@endif
</x-stack>
</x-pane>
@@ -1,7 +1,7 @@
<div class="flex flex-col gap-6">
<x-stack gap="space300">
<x-auth-header :title="__('System Password Required')" :description="__('Enter the system password to access the upload page')" />
<form wire:submit="verify" class="flex flex-col gap-6">
<x-form wire:submit="verify">
<x-password
wire:model="password"
:label="__('Password')"
@@ -9,6 +9,8 @@
:placeholder="__('System password')"
/>
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
</form>
</div>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" spinner="verify" />
</x-slot:actions>
</x-form>
</x-stack>
@@ -6,7 +6,7 @@
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
<x-form method="POST" action="{{ route('password.confirm.store') }}">
@csrf
<x-password
@@ -17,6 +17,8 @@
autocomplete="current-password"
/>
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Confirm')" variant="filled" data-test="confirm-password-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
@@ -3,7 +3,7 @@
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
<x-form method="POST" action="{{ route('password.email') }}">
@csrf
<x-input
@@ -17,11 +17,13 @@
icon="mail"
/>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" data-test="email-password-reset-link-button" />
</x-slot:actions>
</x-form>
<p class="text-center type-body-md text-on-surface-variant">
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('Or, return to') }}
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
<a href="{{ route('login') }}" class="md-link" wire:navigate>{{ __('log in') }}</a>
</p>
</x-layouts::auth>
+12 -8
View File
@@ -3,7 +3,7 @@
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
<x-form method="POST" action="{{ route('login.store') }}">
@csrf
<x-input
@@ -18,7 +18,7 @@
icon="mail"
/>
<div class="grid gap-1">
<x-stack gap="space50">
<x-password
name="password"
:label="__('Password')"
@@ -27,14 +27,18 @@
/>
@if (Route::has('password.request'))
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
<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
</div>
</x-stack>
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Log in')" variant="filled" data-test="login-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
@@ -3,7 +3,7 @@
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
<x-form method="POST" action="{{ route('password.update') }}">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
@@ -31,6 +31,8 @@
autocomplete="new-password"
/>
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Reset password')" variant="filled" data-test="reset-password-button" />
</x-slot:actions>
</x-form>
</x-layouts::auth>
@@ -1,8 +1,8 @@
<x-layouts::auth :title="__('Two-factor authentication')">
<div
class="flex flex-col gap-6"
<x-stack
gap="space300"
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
showRecoveryInput: {{ \Illuminate\Support\Js::from($errors->has('recovery_code')) }},
toggleInput() {
this.showRecoveryInput = ! this.showRecoveryInput;
$nextTick(() => {
@@ -27,7 +27,7 @@
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}" class="flex flex-col gap-5">
<x-form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div x-ref="code" x-show="! showRecoveryInput">
@@ -53,13 +53,15 @@
/>
</div>
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Continue')" variant="filled" />
</x-slot:actions>
</x-form>
<p class="text-center type-body-md text-on-surface-variant">
<p class="md-type-body-md md-ink-variant md-text-center">
{{ __('or you can') }}
<button type="button" class="link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
<button type="button" class="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-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>
</p>
</div>
</x-stack>
</x-layouts::auth>
@@ -10,15 +10,19 @@
</x-alert>
@endif
<div class="flex flex-col items-stretch gap-3">
<form method="POST" action="{{ route('verification.send') }}">
<x-stack gap="space100">
<x-form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-button type="submit" :label="__('Resend verification email')" variant="filled" class="w-full" />
</form>
<x-slot:actions>
<x-button type="submit" :label="__('Resend verification email')" variant="filled" />
</x-slot:actions>
</x-form>
<form method="POST" action="{{ route('logout') }}" class="self-center">
{{-- Log out posts elsewhere, so it is a form of its own; it sits under Resend at the same end
edge, the page's two actions end-aligned below its content. --}}
<x-row as="form" justify="end" method="POST" action="{{ route('logout') }}">
@csrf
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
</form>
</div>
</x-row>
</x-stack>
</x-layouts::auth>
@@ -11,15 +11,17 @@
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
@endphp
<div class="w-full">
<x-stack gap="space400">
<x-section-nav :items="$items" :label="__('Settings')" />
<div class="mt-8">
<h2 class="type-title-lg">{{ $heading ?? '' }}</h2>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subheading ?? '' }}</p>
<div class="mt-6 w-full max-w-lg">
{{-- Every settings page is a card headed by its own title, as the admin's settings are. A page
with a section that stands apart from that one subject deleting the account, the recovery
codes puts it in `after`, where it becomes a card of its own beside this one. --}}
<x-stack gap="space300" class="settings-column">
<x-card :title="$heading ?? ''" :subtitle="$subheading ?? ''" heading="h2" variant="outlined">
{{ $slot }}
</div>
</div>
</div>
</x-card>
{{ $after ?? '' }}
</x-stack>
</x-stack>
@@ -45,48 +45,51 @@ new class extends Component {
}
}; ?>
{{--
Recovery codes are one more group within the two-factor settings page's single subject, not
content about a subject of their own: a heading and this stack's own spacing give the
hierarchy an outlined card would (M3 § Cards: "Don't force content into cards when simple
spacing, headlines and dividers would give a clearer hierarchy"). The enclosing page places
a divider on each side instead.
--}}
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
<div class="grid gap-4">
<div>
<div class="flex items-center gap-2">
<x-icon name="lock" class="size-5 text-on-surface-variant" />
<h3 class="type-title-md">{{ __('2FA Recovery Codes') }}</h3>
</div>
<p class="mt-1 type-body-md text-on-surface-variant">
<x-stack gap="space200">
<x-stack gap="space50">
<x-row gap="space100">
<x-icon name="lock" size="20" class="md-ink-variant" />
<h2 class="md-type-title-md">{{ __('2FA Recovery Codes') }}</h2>
</x-row>
<p class="md-type-body-md md-ink-variant">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</p>
</div>
</x-stack>
<div class="flex flex-wrap items-center gap-2">
<span x-show="! showRecoveryCodes" class="inline-flex">
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = true" />
</span>
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = false" />
</span>
<x-row gap="space100" wrap>
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-show="! showRecoveryCodes" x-on:click="showRecoveryCodes = true" />
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-show="showRecoveryCodes" x-cloak x-on:click="showRecoveryCodes = false" />
@if (filled($recoveryCodes))
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" wire:click="regenerateRecoveryCodes" />
</span>
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" x-show="showRecoveryCodes" x-cloak wire:click="regenerateRecoveryCodes" />
@endif
</div>
</x-row>
<div x-show="showRecoveryCodes" x-cloak id="recovery-codes-section" class="grid gap-3">
<x-stack gap="space100" x-show="showRecoveryCodes" x-cloak id="recovery-codes-section">
@error('recoveryCodes')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
@if (filled($recoveryCodes))
<div class="grid gap-1 rounded-corner-md bg-surface-container-highest p-4 font-mono type-body-md" role="list" aria-label="{{ __('Recovery codes') }}">
@foreach ($recoveryCodes as $code)
<div role="listitem" class="select-text" wire:loading.class="animate-pulse opacity-50">{{ $code }}</div>
@endforeach
</div>
<p class="type-body-sm text-on-surface-variant">
<x-surface level="surface-container-highest" padding="space200" corner="md" class="md-type-body-md" role="list" :aria-label="__('Recovery codes')">
<x-stack gap="space50">
@foreach ($recoveryCodes as $code)
<code role="listitem" class="settings-recovery-code" wire:loading.class="settings-recovery-code--loading">{{ $code }}</code>
@endforeach
</x-stack>
</x-surface>
<p class="md-type-body-sm md-ink-variant">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
</p>
@endif
</div>
</div>
</x-stack>
</x-stack>
</x-card>
@@ -6,10 +6,10 @@ new class extends Component {
//
}; ?>
<section class="w-full">
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<x-theme-toggle mode="picker" class="w-full max-w-sm" data-test="appearance-picker" />
<x-theme-toggle mode="picker" class="settings-appearance-picker" data-test="appearance-picker" />
</x-pages::settings.layout>
</section>
@@ -26,30 +26,23 @@ new class extends Component {
}
}; ?>
<section class="mt-12 grid gap-4">
<x-divider />
<div>
<h3 class="type-title-md">{{ __('Delete account') }}</h3>
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Delete your account and all of its resources') }}</p>
</div>
<div>
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
</div>
<x-card :title="__('Delete account')" :subtitle="__('Delete your account and all of its resources')" heading="h2" variant="outlined">
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
<p>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<x-stack gap="space200">
<p>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<form id="delete-user-form" wire:submit="deleteUser" class="mt-4">
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
</form>
<x-form id="delete-user-form" wire:submit="deleteUser">
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
</x-form>
</x-stack>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
</x-slot:actions>
</x-modal>
</section>
</x-card>
@@ -43,18 +43,18 @@ new class extends Component {
}
}; ?>
<section class="w-full">
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="grid gap-5">
<x-password wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
<x-password wire:model="password" :label="__('New password')" required autocomplete="new-password" />
<x-password wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
<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" />
<div>
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
</div>
</form>
</x-slot:actions>
</x-form>
</x-pages::settings.layout>
</section>
@@ -80,21 +80,21 @@ new class extends Component {
}
}; ?>
<section class="w-full">
<section>
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="grid w-full gap-5">
<x-input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<x-form wire:submit="updateProfileInformation">
<x-input full wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<div class="grid gap-3">
<x-input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
<x-stack gap="space100">
<x-input full wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
@if ($this->hasUnverifiedEmail)
<p class="type-body-md text-on-surface-variant">
<p class="md-type-body-md md-ink-variant">
{{ __('Your email address is unverified.') }}
<button type="button" class="link" wire:click.prevent="resendVerificationNotification">
<button type="button" class="md-link" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@@ -103,15 +103,17 @@ new class extends Component {
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
@endif
@endif
</div>
</x-stack>
<div>
<x-slot:actions>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
</div>
</form>
</x-slot:actions>
</x-form>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
<x-slot:after>
@if ($this->showDeleteUser)
<livewire:pages::settings.delete-user-form />
@endif
</x-slot:after>
</x-pages::settings.layout>
</section>
@@ -1,5 +1,6 @@
<?php
use App\Services\QrCodeService;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
@@ -69,7 +70,7 @@ new class extends Component {
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->qrCodeSvg = app(QrCodeService::class)->svg($user?->twoFactorQrCodeUrl());
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
@@ -177,82 +178,85 @@ new class extends Component {
}
} ?>
<section class="w-full">
<section>
@include('partials.settings-heading')
<x-pages::settings.layout
:heading="__('Two Factor Authentication')"
:subheading="__('Manage your two-factor authentication settings')"
>
<div class="grid w-full gap-6" wire:cloak>
<x-stack gap="space300" wire:cloak>
@if ($twoFactorEnabled)
<div class="grid justify-items-start gap-4">
<x-stack gap="space200" align="start">
<x-badge :value="__('Enabled')" tonal color="success" />
<p class="type-body-md text-on-surface-variant">
<p class="md-type-body-md md-ink-variant">
{{ __('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>
</div>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
<div>
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
</div>
</x-stack>
@else
<div class="grid justify-items-start gap-4">
<x-stack gap="space200" align="start">
<x-badge :value="__('Disabled')" tonal color="error" />
<p class="type-body-md text-on-surface-variant">
<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" />
</div>
</x-stack>
@endif
</div>
</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)
<div class="mt-2">
<x-input
name="code"
wire:model="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
/>
</div>
<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
@error('setupData')
<x-alert color="error" class="mt-2">{{ $message }}</x-alert>
@enderror
<x-stack gap="space300">
@error('setupData')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
<div class="mt-2 flex justify-center">
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
<div class="grid aspect-square w-64 place-items-center overflow-hidden rounded-corner-lg bg-white p-4">
@empty($qrCodeSvg)
<x-loading :label="__('Loading')" />
@else
{!! $qrCodeSvg !!}
@endempty
</div>
</div>
<x-row justify="center">
<div class="settings-two-factor-qr">
@empty($qrCodeSvg)
<x-loading :label="__('Loading')" />
@else
{!! $qrCodeSvg !!}
@endempty
</div>
</x-row>
<div class="mt-6 grid gap-3">
<p class="text-center type-label-lg text-on-surface-variant">{{ __('or, enter the code manually') }}</p>
<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 />
</div>
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
</x-stack>
</x-stack>
<x-slot:actions>
<x-button
@@ -1,4 +1,4 @@
<div class="mb-6 w-full">
<h1 class="type-headline-md">{{ __('Settings') }}</h1>
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Manage your profile and account settings') }}</p>
</div>
<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>
+9 -11
View File
@@ -20,18 +20,16 @@
<x-button icon="admin_panel_settings" :tooltip="__('Admin settings')" :link="route('admin.settings')" :variant="$onAdminSettings ? 'filled' : 'text'" :aria-current="$onAdminSettings ? 'page' : null" />
@endif
<span class="ms-1 inline-flex">
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item :label="__('Log out')" icon="logout" type="submit" data-test="logout-button" />
</form>
</x-slot:footer>
</x-account-menu>
</span>
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item :label="__('Log out')" icon="logout" type="submit" data-test="logout-button" />
</form>
</x-slot:footer>
</x-account-menu>
@else
<x-button icon="upload" :aria-label="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
<x-theme-toggle />