Move SealShare onto Livewire Material

Replaces maryUI and daisyUI with nonameweb/livewire-material: the Vibrant
indigo scheme, a system/light/dark theme under sealshare-theme, one top
app bar with the account menu, the upload drop zone and link-ready
moments, M3 fields, dialogs instead of wire:confirm, snackbars instead of
flashed messages, a sortable admin table, and the starter-kit cleanup.
Docker builds assets after Composer; CI drops the Flux step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 10:49:38 +02:00
co-authored by Claude Opus 5
parent 6d7120e8e2
commit c4a17b65c8
70 changed files with 3043 additions and 1680 deletions
@@ -1,70 +1,69 @@
<div>
<h1 class="text-2xl font-bold mb-6">{{ __('Admin Dashboard') }}</h1>
<h1 class="mb-6 type-headline-md">{{ __('Admin Dashboard') }}</h1>
{{-- Stats --}}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
<p class="text-2xl font-bold">{{ $totalShares }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Active Shares') }}</p>
<p class="text-2xl font-bold">{{ $activeShares }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Total Files') }}</p>
<p class="text-2xl font-bold">{{ $totalFiles }}</p>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<p class="text-sm opacity-60">{{ __('Disk Usage') }}</p>
<p class="text-2xl font-bold">{{ Number::fileSize($usedSpace) }}</p>
<progress class="progress progress-primary w-full mt-1" value="{{ $maxQuota > 0 ? ($usedSpace / $maxQuota) * 100 : 0 }}" max="100"></progress>
<p class="text-xs opacity-50">{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}</p>
</div>
</div>
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
<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-stat>
</div>
{{-- Shares Table --}}
<x-card title="{{ __('All Shares') }}" shadow>
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
@scope('cell_total_size', $share)
{{ Number::fileSize($share->total_size) }}
@endscope
<x-card :title="__('All Shares')" variant="outlined">
<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>
@forelse ($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>
</tr>
@empty
<tr>
<td colspan="7">
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
</td>
</tr>
@endforelse
</tbody>
</x-table>
</div>
@scope('cell_expires_at', $share)
@if ($share->expires_at)
<span class="{{ $share->isExpired() ? 'text-error' : '' }}">
{{ $share->expires_at->diffForHumans() }}
</span>
@else
<span class="opacity-50">{{ __('Never') }}</span>
@endif
@endscope
@scope('cell_created_at', $share)
{{ $share->created_at->diffForHumans() }}
@endscope
@scope('actions', $share)
<div class="flex gap-1">
<a href="{{ route('share.download', $share) }}" class="btn btn-ghost btn-xs" target="_blank">
<x-icon name="o-eye" class="w-4 h-4" />
</a>
<x-button
icon="o-trash"
class="btn-ghost btn-xs text-error"
wire:click="deleteShare({{ $share->id }})"
wire:confirm="{{ __('Are you sure you want to delete this share?') }}"
/>
</div>
@endscope
</x-table>
<div class="mt-4">{{ $shares->links() }}</div>
</x-card>
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
{{ __('Are you sure you want to delete this share?') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
</x-slot:actions>
</x-modal>
</div>
@@ -1,100 +1,66 @@
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
@if (session('message'))
<div class="alert alert-success mb-6">
<x-icon name="o-check-circle" class="w-5 h-5" />
<span>{{ session('message') }}</span>
</div>
@endif
<form wire:submit="saveSettings" class="grid gap-6">
<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.')" />
<form wire:submit="saveSettings">
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
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"
/>
<div>
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
<x-textarea 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 items-center gap-4 mb-3">
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
<x-button
label="{{ __('Remove Logo') }}"
class="btn-sm btn-ghost text-error"
wire:click="removeLogo"
wire:confirm="{{ __('Remove the logo?') }}"
/>
<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
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
<x-file wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
@if ($siteLogo && is_object($siteLogo))
<div class="mt-2">
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
@endif
</div>
@endif
@error('siteLogo')
<p class="text-error text-sm mt-1">{{ $message }}</p>
@enderror
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
</div>
</div>
</x-card>
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
<div class="space-y-4">
<div>
<x-password
wire:model="systemPassword"
label="{{ __('System Upload Password') }}"
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
/>
@if ($hasSystemPassword)
<div class="mt-2">
<x-button
label="{{ __('Clear System Password') }}"
class="btn-sm btn-ghost text-error"
wire:click="clearSystemPassword"
wire:confirm="{{ __('Remove the system password?') }}"
/>
</div>
@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>
@endif
@endif
</div>
</div>
</x-card>
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
<div class="space-y-4">
<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"
/>
@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>
<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.') }}"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<x-select
wire:model="defaultExpiration"
label="{{ __('Default Expiration') }}"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -108,44 +74,49 @@
<x-input
wire:model="maxFileSize"
label="{{ __('Max file size (MB)') }}"
:label="__('Max file size (MB)')"
type="number"
min="1"
max="{{ $phpMaxUploadMb }}"
:max="$phpMaxUploadMb"
suffix="MB"
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
/>
<x-input
wire:model="maxFilesPerShare"
label="{{ __('Max files per share') }}"
type="number"
min="1"
/>
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-input
wire:model="maxSizePerShare"
label="{{ __('Max total size per share (GB)') }}"
type="number"
min="1"
suffix="GB"
/>
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</div>
</x-card>
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
wire:model="maxStorageQuota"
label="{{ __('Max storage quota (GB)') }}"
type="number"
min="1"
suffix="GB"
hint="{{ __('When reached, new uploads are blocked.') }}"
/>
</div>
<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-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" />
</form>
<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="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>
</div>
@@ -1,21 +1,16 @@
<div class="max-w-3xl mx-auto">
<div class="text-center mb-6">
<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="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
<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="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if ($isStorageFull)
<div class="alert alert-warning mb-6">
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
</div>
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<form
wire:submit="createShare"
@@ -88,97 +83,98 @@
x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
{{-- Drop Zone --}}
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
:class="{
'border-primary bg-primary/5': dragging,
'border-base-300 hover:border-primary/50': !dragging,
'opacity-50 pointer-events-none': uploading
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,
}"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="handleDrop($event)"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<x-icon name="o-cloud-arrow-up" class="w-12 h-12 mx-auto opacity-40 mb-3" />
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
<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>
<label class="btn btn-outline btn-sm mt-4 cursor-pointer" :class="uploading && 'btn-disabled'">
<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="hidden"
:disabled="uploading"
/>
<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">
<template x-if="progress < 100">
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<button type="button" class="btn btn-ghost btn-xs" x-on:click="$wire.cancelUpload('files')">
{{ __('Cancel') }}
</button>
</div>
<progress class="progress progress-primary w-full" max="100" x-bind:value="progress"></progress>
{{-- 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>
</template>
<template x-if="progress >= 100">
<div class="flex items-center gap-3 text-sm font-medium">
<span class="loading loading-spinner loading-sm"></span>
{{ __('Processing files...') }}
</div>
</template>
<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>
{{-- Errors --}}
@error('files')
<div class="alert alert-error mb-4">{{ $message }}</div>
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
@enderror
{{-- File List --}}
{{-- Selected files --}}
@if (count($files))
<div class="mb-6">
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
<div class="space-y-1 max-h-60 overflow-y-auto">
@foreach ($files as $index => $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
</span>
<span class="opacity-50 flex-shrink-0">
({{ Number::fileSize($file->getSize()) }})
</span>
</div>
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
<x-icon name="o-x-mark" class="w-4 h-4" />
</button>
</div>
@endforeach
<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>
@endif
{{-- Options --}}
<x-card title="{{ __('Share Options') }}" class="mb-6" shadow>
<div class="space-y-4">
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
<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 />
@if ($usePassword)
<x-password wire:model="password" label="{{ __('Password') }}" />
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
@endif
<x-select
wire:model="expiration"
label="{{ __('Expiration') }}"
:label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -192,22 +188,24 @@
<x-input
wire:model="maxDownloads"
label="{{ __('Max downloads') }}"
:label="__('Max downloads')"
type="number"
min="1"
placeholder="{{ __('Unlimited') }}"
:placeholder="__('Unlimited')"
/>
</div>
</x-card>
{{-- Submit --}}
<x-button
type="submit"
label="{{ __('Create Share Link') }}"
class="btn-primary w-full"
icon="o-link"
: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
+10 -10
View File
@@ -4,37 +4,37 @@
<form wire:submit="createAdmin" class="flex flex-col gap-6">
<x-input
wire:model="name"
label="{{ __('Name') }}"
:label="__('Name')"
type="text"
required
autofocus
placeholder="{{ __('Admin name') }}"
icon="o-user"
:placeholder="__('Admin name')"
icon="person"
/>
<x-input
wire:model="email"
label="{{ __('Email address') }}"
:label="__('Email address')"
type="email"
required
placeholder="admin@example.com"
icon="o-envelope"
icon="mail"
/>
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('Password') }}"
:placeholder="__('Password')"
/>
<x-password
wire:model="password_confirmation"
label="{{ __('Confirm password') }}"
:label="__('Confirm password')"
required
placeholder="{{ __('Confirm password') }}"
:placeholder="__('Confirm password')"
/>
<x-button type="submit" label="{{ __('Create Admin Account') }}" class="btn-primary w-full" spinner="createAdmin" />
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
</form>
</div>
@@ -1,65 +1,38 @@
<div class="max-w-lg mx-auto">
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
<div class="space-y-4">
{{-- Share URL --}}
<div
x-data="{
copied: false,
url: '{{ route('share.download', $share) }}',
async copy() {
try {
await navigator.clipboard.writeText(this.url);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
} catch (e) {}
}
}"
>
<label class="label font-medium text-sm">{{ __('Share Link') }}</label>
<div class="join w-full">
<input
type="text"
readonly
:value="url"
class="input input-bordered join-item w-full"
/>
<button type="button" @click="copy()" class="btn join-item">
<span x-show="!copied">{{ __('Copy') }}</span>
<span x-show="copied" class="text-success">{{ __('Copied!') }}</span>
</button>
</div>
</div>
{{-- Details --}}
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Files') }}</span>
<p class="font-semibold">{{ $share->files->count() }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Total Size') }}</span>
<p class="font-semibold">{{ Number::fileSize($share->total_size) }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Expires') }}</span>
<p class="font-semibold">{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Max Downloads') }}</span>
<p class="font-semibold">{{ $share->max_downloads ?? __('Unlimited') }}</p>
</div>
</div>
@if ($share->isPasswordProtected())
<div class="alert alert-info">
<x-icon name="o-lock-closed" class="w-5 h-5" />
<span>{{ __('This share is password protected') }}</span>
</div>
@endif
<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>
<x-slot:actions>
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
</x-slot:actions>
</x-card>
<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">
<x-input
:label="__('Share Link')"
:value="route('share.download', $share)"
readonly
copyable
icon="link"
data-test="share-link"
/>
<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>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<div class="flex justify-end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</div>
</div>
</div>
@@ -1,67 +1,61 @@
<div class="w-full max-w-lg mx-auto">
<div class="text-center mb-6">
{{-- 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="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
<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="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if (! $authenticated)
{{-- Password form --}}
<form wire:submit="verifyPassword">
<x-card title="{{ __('Password Required') }}" subtitle="{{ __('Enter the password to access these files') }}" shadow>
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" variant="outlined">
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('Enter share password') }}"
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-slot:actions>
<x-button type="submit" label="{{ __('Unlock') }}" class="btn-primary w-full mt-4" icon="o-lock-open" spinner="verifyPassword" />
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" class="w-full" />
</x-slot:actions>
</x-card>
</form>
@else
{{-- File list --}}
<x-card title="{{ __('Shared Files') }}" shadow>
<div class="space-y-2 mb-4">
<x-card :title="__('Shared Files')" variant="outlined">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
</div>
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
</a>
</div>
<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
</div>
</x-list>
@if ($share->expires_at)
<p class="text-xs opacity-50 mb-2">
<p class="mt-2 type-body-sm text-on-surface-variant">
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
</p>
@endif
<x-slot:actions>
@if ($share->files->count() > 1)
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download All as ZIP') }}
</a>
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
@else
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download') }}
</a>
<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>
@@ -4,11 +4,11 @@
<form wire:submit="verify" class="flex flex-col gap-6">
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('System password') }}"
:placeholder="__('System password')"
/>
<x-button type="submit" label="{{ __('Continue') }}" class="btn-primary w-full" spinner="verify" />
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
</form>
</div>