Release 2.3.0
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
202813a1e6
commit
f9a7839ad3
@@ -37,7 +37,7 @@
|
||||
<a href="{{ route('share.download', $share) }}" target="_blank" rel="noopener" class="md-link"><code>{{ $share->token }}</code></a>
|
||||
|
||||
<x-slot:description>
|
||||
<span class="admin-share-detail md-tabular">{{ trans_choice(':count file|:count files', $share->files_count) }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||
<span class="admin-share-detail md-tabular">{{ collect([$share->files_count || ! $share->text_file_exists ? trans_choice(':count file|:count files', $share->files_count) : null, $share->text_file_exists ? __('Text') : null])->filter()->implode(' · ') }} · {{ Number::fileSize($share->total_size) }} · {{ $share->max_downloads ? trans_choice(':count of :max download|:count of :max downloads', $share->max_downloads, ['count' => $share->download_count, 'max' => $share->max_downloads]) : trans_choice(':count download|:count downloads', $share->download_count) }}</span>
|
||||
|
||||
{{-- A share at its limit is closed; the cleanup deletes it a day after its last download. --}}
|
||||
@if ($share->hasReachedDownloadLimit())
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
@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
|
||||
wire:submit="createShare"
|
||||
x-on:submit.prevent="submit()"
|
||||
x-data="shareUploader({
|
||||
csrfToken: {{ \Illuminate\Support\Js::from(csrf_token()) }},
|
||||
messages: {{ \Illuminate\Support\Js::from([
|
||||
@@ -12,6 +13,7 @@
|
||||
'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)"
|
||||
@@ -95,6 +97,30 @@
|
||||
</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">
|
||||
@@ -148,7 +174,7 @@
|
||||
size="md"
|
||||
icon="link"
|
||||
spinner="createShare"
|
||||
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }}"
|
||||
x-bind:disabled="busy || {{ $allFilesUploaded ? 'false' : 'true' }} || ({{ $pendingFiles->isEmpty() ? 'true' : 'false' }} && text.trim() === '') || textBytes > {{ $maxTextBytes }}"
|
||||
data-test="create-share"
|
||||
/>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<x-page :title="__('Share Created!')" :description="__('Your files are ready to share')">
|
||||
<x-page :title="__('Share Created!')" :description="__('Your share is ready')">
|
||||
{{-- The link is ready: a check on an Expressive shape that settles in (share-ready, app.css). --}}
|
||||
<x-slot:mark>
|
||||
<div class="share-check">
|
||||
@@ -73,10 +73,14 @@
|
||||
</x-stack>
|
||||
|
||||
<x-grid :columns="2" gap="space200">
|
||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
||||
<x-stat :title="__('Files')" :value="$share->files->where('is_text', false)->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" />
|
||||
|
||||
@if ($share->hasText())
|
||||
<x-stat :title="__('Private text')" :value="Number::fileSize($share->textFile->file_size)" icon="notes" />
|
||||
@endif
|
||||
</x-grid>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
iOS before Safari 18.4, which cannot position them. --}}
|
||||
|
||||
<x-page brand>
|
||||
{{-- 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. --}}
|
||||
{{-- Under the page's h1, what the recipient acts on is in cards: the password, or the share's
|
||||
private text and its files, each in its own card, with the share's expiry and limit under them. --}}
|
||||
@if (! $authenticated)
|
||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" heading="h2" variant="outlined">
|
||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to open this share')" heading="h2" variant="outlined">
|
||||
<x-form wire:submit="verifyPassword">
|
||||
<x-password
|
||||
full
|
||||
@@ -22,32 +22,100 @@
|
||||
</x-form>
|
||||
</x-card>
|
||||
@else
|
||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||
{{-- A download link does not render the page again: the download limit's note switches
|
||||
on the first press here, and the server draws the open window on the next visit. --}}
|
||||
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||
<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-on:click="downloaded = true" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
{{-- A download link does not render the page again: the download limit's note switches on
|
||||
the first press of any download or "Show text", and the server draws the open window on
|
||||
the next visit. --}}
|
||||
<x-stack gap="space200" x-data="{ downloaded: false }">
|
||||
{{-- The text is fetched only when the recipient asks for it, never with the page: a link
|
||||
preview must not use up a share limited to one download. It stays in Alpine and is
|
||||
drawn as plain text. --}}
|
||||
@if ($hasText)
|
||||
<x-card :title="__('Private text')" heading="h2" variant="outlined">
|
||||
<x-stack
|
||||
gap="space200"
|
||||
x-data="{
|
||||
text: null,
|
||||
async reveal() {
|
||||
const text = await $wire.revealText();
|
||||
|
||||
if (text === null) {
|
||||
window.materialToast({{ \Illuminate\Support\Js::from(__('This text is no longer available.')) }}, { type: 'error' });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.text = text;
|
||||
downloaded = true;
|
||||
},
|
||||
}"
|
||||
>
|
||||
<template x-if="text !== null">
|
||||
<x-stack gap="space200">
|
||||
<div class="share-text md-type-body-lg" x-text="text" data-test="shared-text"></div>
|
||||
|
||||
<x-row justify="end">
|
||||
<x-button
|
||||
:label="__('Copy')"
|
||||
icon="content_copy"
|
||||
variant="tonal"
|
||||
x-on:click="navigator.clipboard.writeText(text).then(() => window.materialToast({{ \Illuminate\Support\Js::from(__('Copied to the clipboard')) }}, { type: 'success' }))"
|
||||
data-test="copy-text"
|
||||
/>
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</template>
|
||||
|
||||
<x-row justify="end" x-show="text === null">
|
||||
<x-button :label="__('Show text')" icon="visibility" variant="filled" x-on:click="reveal()" spinner="revealText" data-test="show-text" />
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
|
||||
@if ($files->isNotEmpty())
|
||||
<x-card :title="__('Shared Files')" heading="h2" variant="outlined">
|
||||
<x-stack gap="space200">
|
||||
<x-list :label="__('Shared Files')">
|
||||
@foreach ($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-on:click="downloaded = true" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
|
||||
<x-row justify="end">
|
||||
@if ($files->count() > 1)
|
||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@else
|
||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@endif
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
|
||||
@if ($share->expires_at || $share->max_downloads)
|
||||
<x-stack gap="space100">
|
||||
@if ($share->expires_at)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}</p>
|
||||
@endif
|
||||
|
||||
@if ($share->max_downloads)
|
||||
@if ($downloadWindowEndsAt)
|
||||
@if ($hasText)
|
||||
@if ($downloadWindowEndsAt)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('You can open this share for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||
@elseif ($remainingDownloads > 0)
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Showing the text or downloading uses the last remaining download. You then have :window to open this share.|[2,*] Showing the text or downloading uses 1 of :count remaining downloads. You then have :window to open this share.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="downloaded" x-cloak>{{ __('You have :window to open this share.', ['window' => $downloadWindow]) }}</p>
|
||||
@endif
|
||||
@elseif ($downloadWindowEndsAt)
|
||||
<p class="md-type-body-sm md-ink-variant">{{ __('You can download these files for another :time.', ['time' => $downloadWindowEndsAt->diffForHumans(syntax: \Carbon\CarbonInterface::DIFF_ABSOLUTE)]) }}</p>
|
||||
@elseif ($remainingDownloads > 0)
|
||||
<p class="md-type-body-sm md-ink-variant" x-show="! downloaded">{{ trans_choice('{1} Downloading uses the last remaining download. You then have :window to download the files.|[2,*] Downloading uses 1 of :count remaining downloads. You then have :window to download the files.', $remainingDownloads, ['window' => $downloadWindow]) }}</p>
|
||||
@@ -55,15 +123,7 @@
|
||||
@endif
|
||||
@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 x-on:click="downloaded = true" />
|
||||
@else
|
||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate x-on:click="downloaded = true" />
|
||||
@endif
|
||||
</x-row>
|
||||
</x-stack>
|
||||
</x-card>
|
||||
@endif
|
||||
</x-stack>
|
||||
@endif
|
||||
</x-page>
|
||||
|
||||
Reference in New Issue
Block a user