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>
83 lines
5.8 KiB
PHP
83 lines
5.8 KiB
PHP
<x-page :title="__('Admin Dashboard')" :description="__('Shares, files and storage at a glance')">
|
|
<x-grid :columns="2" 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" :label="__('Disk Usage')" />
|
|
</x-stat>
|
|
</x-grid>
|
|
|
|
{{-- The shares as a list, not a table: a table's columns need more than the page's 40rem, and
|
|
every page keeps that one width. The sort is a full-width select above the list instead of column headers. --}}
|
|
<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
|
|
<x-select
|
|
wire:model.live="sort"
|
|
:label="__('Sort by')"
|
|
:options="[
|
|
['id' => 'newest', 'name' => __('Newest first')],
|
|
['id' => 'oldest', 'name' => __('Oldest first')],
|
|
['id' => 'expiring', 'name' => __('Expiring soonest')],
|
|
['id' => 'largest', 'name' => __('Largest')],
|
|
['id' => 'most-downloaded', 'name' => __('Most downloads')],
|
|
['id' => 'most-files', 'name' => __('Most files')],
|
|
]"
|
|
data-test="shares-sort"
|
|
/>
|
|
|
|
{{-- Each share fits the column on a phone: the token opens it, so delete is the one button;
|
|
the details are two short lines that never clip (admin-shares, app.css). --}}
|
|
<x-list dividers :label="__('All Shares')" class="admin-shares">
|
|
@foreach ($shares as $share)
|
|
<x-list-item :overline="__('Created :time', ['time' => $share->created_at->diffForHumans()])" wire:key="share-{{ $share->id }}" data-test="share-row">
|
|
<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">{{ 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())
|
|
<span class="admin-share-detail md-ink-error">{{ __('Download limit reached') }}</span>
|
|
@elseif (! $share->expires_at)
|
|
<span class="admin-share-detail">{{ __('Never expires') }}</span>
|
|
@elseif ($share->isExpired())
|
|
<span class="admin-share-detail md-ink-error">{{ __('Expired :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
|
@else
|
|
<span class="admin-share-detail">{{ __('Expires :time', ['time' => $share->expires_at->diffForHumans()]) }}</span>
|
|
@endif
|
|
</x-slot:description>
|
|
|
|
<x-slot:end>
|
|
<x-button icon="delete" :tooltip="__('Delete')" color="error" wire:click="$set('deletingShareId', {{ $share->id }})" data-test="delete-share-{{ $share->id }}" />
|
|
</x-slot:end>
|
|
</x-list-item>
|
|
@endforeach
|
|
</x-list>
|
|
|
|
{{ $shares->links() }}
|
|
@endif
|
|
</x-stack>
|
|
</x-card>
|
|
|
|
{{-- Page chrome, not content, so a quiet line instead of a card: the installed version, its release notes and who makes SealShare. --}}
|
|
<footer class="md-type-body-sm md-ink-variant md-text-center" data-test="dashboard-about">
|
|
SealShare {{ $version }}
|
|
· <a href="https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v{{ $version }}" target="_blank" rel="noopener" class="md-link md-ink-primary">{{ __('Release notes') }}</a>
|
|
· <a href="https://sealshare.nonameweb.ch" target="_blank" rel="noopener" class="md-link md-ink-primary">{{ __('Website') }}</a>
|
|
· {{ __('Made by') }} <a href="https://nonameweb.ch" target="_blank" rel="noopener" class="md-link md-ink-primary">noNameWEB</a>
|
|
</footer>
|
|
|
|
<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>
|
|
</x-page>
|