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
@@ -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>