Files
SealShare/resources/views/livewire/file-uploader.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 c4a17b65c8 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
2026-09-13 10:49:38 +02:00

213 lines
10 KiB
PHP

<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>
@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 />
@if ($usePassword)
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
@endif
<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')],
]"
/>
<x-input
wire:model="maxDownloads"
:label="__('Max downloads')"
type="number"
min="1"
:placeholder="__('Unlimited')"
/>
</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>