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>