A share can now hold a private text (a password, a key, a short note) with its files or on its own. The upload page's new "Private text" card takes up to 100 KB; the browser encrypts the text and sends it through the same chunk pipeline as a file, flagged is_text on share_files, so it gets the share's password, expiry, download limit and cleanup. The recipient sees the text only after pressing "Show text", which counts as their download, so a messenger link preview cannot use up a share limited to one download. The text is left out of the file list, the ZIP and the file counts; the admin dashboard marks shares that hold one with "Text". The website gains a Private text feature card and a fifth phone screenshot; every screenshot is retaken. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
10 KiB
PHP
184 lines
10 KiB
PHP
<x-page brand>
|
|
{{-- Files this page already uploaded count towards the quota: they can still become a share. --}}
|
|
@if ($isStorageFull && $pendingFiles->isEmpty())
|
|
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
|
@else
|
|
{{-- Submitting sends the private text first (submit(), resources/js/share-uploader.js), then creates the share. --}}
|
|
<x-form
|
|
x-on:submit.prevent="submit()"
|
|
x-data="shareUploader({
|
|
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
|
messages: {{ \Illuminate\Support\Js::from([
|
|
'queued' => __('Waiting'),
|
|
'uploaded' => __('Uploaded'),
|
|
'failed' => __('Upload failed'),
|
|
'sessionExpired' => __('Your session expired. Reload the page to upload again.'),
|
|
'textFailed' => __('The text could not be sent. Try again.'),
|
|
]) }},
|
|
})"
|
|
x-on:beforeunload.window="warnBeforeLeaving($event)"
|
|
>
|
|
{{-- WebCrypto, which encrypts the files in the browser, only exists on HTTPS (or localhost). --}}
|
|
<div x-show="! secure" x-cloak data-test="insecure-context">
|
|
<x-alert color="warning" :title="__('Uploads need a secure connection (HTTPS).')" :description="__('Ask the administrator to serve this site over HTTPS.')" />
|
|
</div>
|
|
|
|
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
|
<div
|
|
class="upload-drop-zone"
|
|
x-bind:data-dragging="dragging ? 'true' : 'false'"
|
|
x-bind:aria-disabled="secure ? 'false' : 'true'"
|
|
x-on:dragover.prevent="dragging = true"
|
|
x-on:dragleave.prevent="dragging = false"
|
|
x-on:drop.prevent="handleDrop($event)"
|
|
data-test="drop-zone"
|
|
>
|
|
<x-stack align="center" gap="space200">
|
|
<div class="upload-drop-shapes">
|
|
<x-shape name="cookie-9" class="upload-drop-shape upload-drop-shape--idle" />
|
|
<x-shape name="soft-burst" class="upload-drop-shape upload-drop-shape--burst" data-test="drop-zone-burst" />
|
|
<x-icon name="upload" size="48" class="upload-drop-icon" />
|
|
</div>
|
|
|
|
<x-stack align="center" gap="space50">
|
|
<p class="md-type-title-md md-text-center">{{ __('Drag & drop files or folders here') }}</p>
|
|
<p class="md-type-body-md md-ink-variant md-text-center">{{ __('or click to browse') }}</p>
|
|
</x-stack>
|
|
|
|
{{-- The button is the tab stop and opens the browser's own picker; the input only carries the selection. --}}
|
|
<x-button :label="__('Browse Files')" icon="folder_open" variant="outlined" x-on:click="$refs.picker.click()" x-bind:disabled="! secure" />
|
|
<input type="file" multiple hidden x-ref="picker" x-on:change="choose($event)" x-bind:disabled="! secure" data-test="file-input" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
{{-- Upload progress, over every file still to send --}}
|
|
<div x-show="busy" x-cloak data-test="upload-progress">
|
|
<x-stack gap="space100">
|
|
<x-row justify="between">
|
|
<span class="md-type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
|
<x-button :label="__('Cancel')" size="xs" x-on:click="cancel()" />
|
|
</x-row>
|
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
|
</x-stack>
|
|
</div>
|
|
|
|
@error('files')
|
|
<x-alert color="error">{{ $message }}</x-alert>
|
|
@enderror
|
|
|
|
{{-- Selected files --}}
|
|
@if ($pendingFiles->isNotEmpty())
|
|
<x-stack gap="space100">
|
|
<h2 class="md-type-title-lg">{{ __('Selected Files') }} ({{ $pendingFiles->count() }})</h2>
|
|
|
|
<div class="upload-file-list">
|
|
<x-list segmented :label="__('Selected Files')">
|
|
@foreach ($pendingFiles as $file)
|
|
<x-list-item
|
|
:title="$file->relative_path ?? $file->original_name"
|
|
icon="description"
|
|
wire:key="selected-file-{{ $file->id }}"
|
|
data-test="selected-file"
|
|
>
|
|
<x-slot:description>
|
|
<span class="md-tabular">{{ Number::fileSize($file->file_size) }}</span>
|
|
· <span class="md-tabular" x-text="statusOf({{ $file->id }}, {{ $file->completed_at ? 'true' : 'false' }})" data-test="file-status"></span>
|
|
</x-slot:description>
|
|
<x-slot:end>
|
|
<span x-show="uploads[{{ $file->id }}]?.state === 'failed'" x-cloak>
|
|
<x-button icon="refresh" :aria-label="__('Retry')" x-on:click="retry({{ $file->id }})" />
|
|
</span>
|
|
<x-button icon="close" :aria-label="__('Remove')" x-on:click="remove({{ $file->id }})" />
|
|
</x-slot:end>
|
|
</x-list-item>
|
|
@endforeach
|
|
</x-list>
|
|
</div>
|
|
</x-stack>
|
|
@endif
|
|
|
|
{{-- Private text: bound in Alpine only, never to the component, so it never reaches the server
|
|
unencrypted; it is sent with the share (submit()). The limit is in UTF-8 bytes, as the server counts. --}}
|
|
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
|
<x-stack gap="space100">
|
|
<x-textarea
|
|
full
|
|
x-model="text"
|
|
:label="__('Text')"
|
|
:maxlength="$maxTextBytes"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
data-test="share-text"
|
|
/>
|
|
|
|
<p class="md-type-body-sm md-tabular" x-bind:class="textBytes > {{ $maxTextBytes }} ? 'md-ink-error' : 'md-ink-variant'" data-test="share-text-size">
|
|
<span x-text="textBytes"></span> {{ __('bytes') }} / {{ Number::fileSize($maxTextBytes) }}
|
|
</p>
|
|
|
|
@error('text')
|
|
<x-alert color="error">{{ $message }}</x-alert>
|
|
@enderror
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
{{-- Options --}}
|
|
<x-card :title="__('Share Options')" heading="h2" variant="outlined">
|
|
<x-stack gap="space200">
|
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
|
|
|
@if ($usePassword)
|
|
<x-stack gap="space100">
|
|
<x-password full wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
|
|
|
{{-- Generate draws one as Admin settings say (App\Services\PasswordGeneratorService); Copy takes
|
|
whatever is in the field, typed or generated, with the snackbar a copyable field shows. --}}
|
|
<x-row gap="space100" wrap>
|
|
@if ($passwordGeneratorMode !== 'off')
|
|
<x-button :label="__('Generate')" icon="password" variant="tonal" wire:click="generatePassword" spinner="generatePassword" data-test="generate-password" />
|
|
@endif
|
|
|
|
<x-button
|
|
:label="__('Copy')"
|
|
icon="content_copy"
|
|
variant="tonal"
|
|
x-on:click="navigator.clipboard.writeText($wire.password).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
|
x-bind:disabled="! $wire.password"
|
|
data-test="copy-password"
|
|
/>
|
|
</x-row>
|
|
</x-stack>
|
|
@endif
|
|
|
|
<x-select full
|
|
wire:model="expiration"
|
|
:label="__('Expiration')"
|
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
|
:options="collect(\App\Models\Share::EXPIRATIONS)->map(fn (array $option, string $id): array => ['id' => $id, 'name' => __($option['label'])])->values()->all()"
|
|
/>
|
|
|
|
<x-input full
|
|
wire:model="maxDownloads"
|
|
:label="__('Max downloads')"
|
|
type="number"
|
|
min="1"
|
|
:placeholder="__('Unlimited')"
|
|
/>
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
<x-slot:actions>
|
|
<x-button
|
|
type="submit"
|
|
:label="__('Create Share Link')"
|
|
variant="filled"
|
|
size="md"
|
|
icon="link"
|
|
spinner="createShare"
|
|
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }} || ({{ $pendingFiles->isEmpty() ? 'true' : 'false' }} && text.trim() === '') || textBytes > {{ $maxTextBytes }}"
|
|
data-test="create-share"
|
|
/>
|
|
</x-slot:actions>
|
|
</x-form>
|
|
@endif
|
|
</x-page>
|