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
+17 -5
View File
@@ -1,10 +1,22 @@
@import 'tailwindcss';
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
@import './material-scheme.css';
@source '../views';
@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source inline("swap swap-rotate swap-on swap-off theme-controller");
@source '../../vendor/nonameweb/livewire-material/resources/views';
@source '../../vendor/nonameweb/livewire-material/src';
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
/* share-created: the check on its shape settles in once the link is ready. */
@keyframes share-ready {
from {
opacity: 0;
rotate: -90deg;
scale: 0.4;
}
to {
opacity: 1;
rotate: 0deg;
scale: 1;
}
}
+150
View File
@@ -0,0 +1,150 @@
/*
* Material 3 colour roles, generated by Google's material-color-utilities (spec 2025).
*
* php artisan material:scheme "#4f46e5" --variant=vibrant
*
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
* M3's contrast guarantee only as generated. The head script sets data-theme before the
* first paint; the light block also stands without it.
*/
:root,
[data-theme='light'] {
color-scheme: light;
--md-sys-color-background: #faf4ff;
--md-sys-color-on-background: #32294f;
--md-sys-color-surface: #faf4ff;
--md-sys-color-surface-dim: #dacdff;
--md-sys-color-surface-bright: #faf4ff;
--md-sys-color-surface-container-lowest: #ffffff;
--md-sys-color-surface-container-low: #f5eeff;
--md-sys-color-surface-container: #ede4ff;
--md-sys-color-surface-container-high: #e8deff;
--md-sys-color-surface-container-highest: #e2d7ff;
--md-sys-color-on-surface: #32294f;
--md-sys-color-on-surface-variant: #5f557f;
--md-sys-color-outline: #7b719c;
--md-sys-color-outline-variant: #b2a6d5;
--md-sys-color-inverse-surface: #10062d;
--md-sys-color-inverse-on-surface: #a296c4;
--md-sys-color-primary: #4a3fe2;
--md-sys-color-primary-dim: #3d2fd6;
--md-sys-color-on-primary: #f4f1ff;
--md-sys-color-primary-container: #9795ff;
--md-sys-color-on-primary-container: #14007e;
--md-sys-color-primary-fixed: #9795ff;
--md-sys-color-primary-fixed-dim: #8885ff;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #1a0099;
--md-sys-color-inverse-primary: #8582ff;
--md-sys-color-secondary: #6249b2;
--md-sys-color-secondary-dim: #563ca5;
--md-sys-color-on-secondary: #f7f0ff;
--md-sys-color-secondary-container: #d8caff;
--md-sys-color-on-secondary-container: #4e339c;
--md-sys-color-secondary-fixed: #d8caff;
--md-sys-color-secondary-fixed-dim: #cbbaff;
--md-sys-color-on-secondary-fixed: #3a1b88;
--md-sys-color-on-secondary-fixed-variant: #573da6;
--md-sys-color-tertiary: #983772;
--md-sys-color-tertiary-dim: #892a65;
--md-sys-color-on-tertiary: #ffeff4;
--md-sys-color-tertiary-container: #fd8bca;
--md-sys-color-on-tertiary-container: #610244;
--md-sys-color-tertiary-fixed: #fd8bca;
--md-sys-color-tertiary-fixed-dim: #ee7ebc;
--md-sys-color-on-tertiary-fixed: #360024;
--md-sys-color-on-tertiary-fixed-variant: #6d104e;
--md-sys-color-error: #b41340;
--md-sys-color-error-dim: #a20036;
--md-sys-color-on-error: #ffefef;
--md-sys-color-error-container: #f74b6d;
--md-sys-color-on-error-container: #510017;
--md-sys-color-success: #006c45;
--md-sys-color-on-success: #ffffff;
--md-sys-color-success-container: #86f9bc;
--md-sys-color-on-success-container: #002112;
--md-sys-color-warning: #7c5800;
--md-sys-color-on-warning: #ffffff;
--md-sys-color-warning-container: #ffdea6;
--md-sys-color-on-warning-container: #271900;
--md-sys-color-info: #005ac4;
--md-sys-color-on-info: #ffffff;
--md-sys-color-info-container: #d8e2ff;
--md-sys-color-on-info-container: #001a42;
--md-sys-color-inverse-error: #ff6e84;
--md-sys-color-inverse-success: #69dca1;
--md-sys-color-inverse-warning: #fdbb28;
--md-sys-color-inverse-info: #aec6ff;
}
[data-theme='dark'] {
color-scheme: dark;
--md-sys-color-background: #10062d;
--md-sys-color-on-background: #eae1ff;
--md-sys-color-surface: #10062d;
--md-sys-color-surface-dim: #10062d;
--md-sys-color-surface-bright: #30215d;
--md-sys-color-surface-container-lowest: #000000;
--md-sys-color-surface-container-low: #160b36;
--md-sys-color-surface-container: #1c113f;
--md-sys-color-surface-container-high: #231649;
--md-sys-color-surface-container-highest: #291c53;
--md-sys-color-on-surface: #eae1ff;
--md-sys-color-on-surface-variant: #b0a4d3;
--md-sys-color-outline: #7a6f9b;
--md-sys-color-outline-variant: #4b426a;
--md-sys-color-inverse-surface: #fdf7ff;
--md-sys-color-inverse-on-surface: #594f78;
--md-sys-color-primary: #a7a5ff;
--md-sys-color-primary-dim: #645dfc;
--md-sys-color-on-primary: #1c00a0;
--md-sys-color-primary-container: #9795ff;
--md-sys-color-on-primary-container: #14007e;
--md-sys-color-primary-fixed: #9795ff;
--md-sys-color-primary-fixed-dim: #8885ff;
--md-sys-color-on-primary-fixed: #000000;
--md-sys-color-on-primary-fixed-variant: #1a0099;
--md-sys-color-inverse-primary: #4d44e6;
--md-sys-color-secondary: #a98ffd;
--md-sys-color-secondary-dim: #a68dfa;
--md-sys-color-on-secondary: #280072;
--md-sys-color-secondary-container: #4d329b;
--md-sys-color-on-secondary-container: #d6c9ff;
--md-sys-color-secondary-fixed: #d8caff;
--md-sys-color-secondary-fixed-dim: #cbbaff;
--md-sys-color-on-secondary-fixed: #3a1b88;
--md-sys-color-on-secondary-fixed-variant: #573da6;
--md-sys-color-tertiary: #ff9dd1;
--md-sys-color-tertiary-dim: #fa88c8;
--md-sys-color-on-tertiary: #6c0f4d;
--md-sys-color-tertiary-container: #fa88c8;
--md-sys-color-on-tertiary-container: #5e0042;
--md-sys-color-tertiary-fixed: #fd8bca;
--md-sys-color-tertiary-fixed-dim: #ee7ebc;
--md-sys-color-on-tertiary-fixed: #360024;
--md-sys-color-on-tertiary-fixed-variant: #6d104e;
--md-sys-color-error: #ff6e84;
--md-sys-color-error-dim: #d73357;
--md-sys-color-on-error: #490013;
--md-sys-color-error-container: #a70138;
--md-sys-color-on-error-container: #ffb2b9;
--md-sys-color-success: #69dca1;
--md-sys-color-on-success: #003822;
--md-sys-color-success-container: #005233;
--md-sys-color-on-success-container: #86f9bc;
--md-sys-color-warning: #fdbb28;
--md-sys-color-on-warning: #412d00;
--md-sys-color-warning-container: #5e4200;
--md-sys-color-on-warning-container: #ffdea6;
--md-sys-color-info: #aec6ff;
--md-sys-color-on-info: #002e6a;
--md-sys-color-info-container: #004396;
--md-sys-color-on-info-container: #d8e2ff;
--md-sys-color-inverse-error: #b41340;
--md-sys-color-inverse-success: #006c45;
--md-sys-color-inverse-warning: #7c5800;
--md-sys-color-inverse-info: #005ac4;
}
+140
View File
@@ -0,0 +1,140 @@
{
"seed": "#4f46e5",
"variant": "vibrant",
"spec": "2025",
"contrast": 0,
"light": {
"background": "#faf4ff",
"on-background": "#32294f",
"surface": "#faf4ff",
"surface-dim": "#dacdff",
"surface-bright": "#faf4ff",
"surface-container-lowest": "#ffffff",
"surface-container-low": "#f5eeff",
"surface-container": "#ede4ff",
"surface-container-high": "#e8deff",
"surface-container-highest": "#e2d7ff",
"on-surface": "#32294f",
"on-surface-variant": "#5f557f",
"outline": "#7b719c",
"outline-variant": "#b2a6d5",
"inverse-surface": "#10062d",
"inverse-on-surface": "#a296c4",
"primary": "#4a3fe2",
"primary-dim": "#3d2fd6",
"on-primary": "#f4f1ff",
"primary-container": "#9795ff",
"on-primary-container": "#14007e",
"primary-fixed": "#9795ff",
"primary-fixed-dim": "#8885ff",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#1a0099",
"inverse-primary": "#8582ff",
"secondary": "#6249b2",
"secondary-dim": "#563ca5",
"on-secondary": "#f7f0ff",
"secondary-container": "#d8caff",
"on-secondary-container": "#4e339c",
"secondary-fixed": "#d8caff",
"secondary-fixed-dim": "#cbbaff",
"on-secondary-fixed": "#3a1b88",
"on-secondary-fixed-variant": "#573da6",
"tertiary": "#983772",
"tertiary-dim": "#892a65",
"on-tertiary": "#ffeff4",
"tertiary-container": "#fd8bca",
"on-tertiary-container": "#610244",
"tertiary-fixed": "#fd8bca",
"tertiary-fixed-dim": "#ee7ebc",
"on-tertiary-fixed": "#360024",
"on-tertiary-fixed-variant": "#6d104e",
"error": "#b41340",
"error-dim": "#a20036",
"on-error": "#ffefef",
"error-container": "#f74b6d",
"on-error-container": "#510017",
"success": "#006c45",
"on-success": "#ffffff",
"success-container": "#86f9bc",
"on-success-container": "#002112",
"warning": "#7c5800",
"on-warning": "#ffffff",
"warning-container": "#ffdea6",
"on-warning-container": "#271900",
"info": "#005ac4",
"on-info": "#ffffff",
"info-container": "#d8e2ff",
"on-info-container": "#001a42",
"inverse-error": "#ff6e84",
"inverse-success": "#69dca1",
"inverse-warning": "#fdbb28",
"inverse-info": "#aec6ff"
},
"dark": {
"background": "#10062d",
"on-background": "#eae1ff",
"surface": "#10062d",
"surface-dim": "#10062d",
"surface-bright": "#30215d",
"surface-container-lowest": "#000000",
"surface-container-low": "#160b36",
"surface-container": "#1c113f",
"surface-container-high": "#231649",
"surface-container-highest": "#291c53",
"on-surface": "#eae1ff",
"on-surface-variant": "#b0a4d3",
"outline": "#7a6f9b",
"outline-variant": "#4b426a",
"inverse-surface": "#fdf7ff",
"inverse-on-surface": "#594f78",
"primary": "#a7a5ff",
"primary-dim": "#645dfc",
"on-primary": "#1c00a0",
"primary-container": "#9795ff",
"on-primary-container": "#14007e",
"primary-fixed": "#9795ff",
"primary-fixed-dim": "#8885ff",
"on-primary-fixed": "#000000",
"on-primary-fixed-variant": "#1a0099",
"inverse-primary": "#4d44e6",
"secondary": "#a98ffd",
"secondary-dim": "#a68dfa",
"on-secondary": "#280072",
"secondary-container": "#4d329b",
"on-secondary-container": "#d6c9ff",
"secondary-fixed": "#d8caff",
"secondary-fixed-dim": "#cbbaff",
"on-secondary-fixed": "#3a1b88",
"on-secondary-fixed-variant": "#573da6",
"tertiary": "#ff9dd1",
"tertiary-dim": "#fa88c8",
"on-tertiary": "#6c0f4d",
"tertiary-container": "#fa88c8",
"on-tertiary-container": "#5e0042",
"tertiary-fixed": "#fd8bca",
"tertiary-fixed-dim": "#ee7ebc",
"on-tertiary-fixed": "#360024",
"on-tertiary-fixed-variant": "#6d104e",
"error": "#ff6e84",
"error-dim": "#d73357",
"on-error": "#490013",
"error-container": "#a70138",
"on-error-container": "#ffb2b9",
"success": "#69dca1",
"on-success": "#003822",
"success-container": "#005233",
"on-success-container": "#86f9bc",
"warning": "#fdbb28",
"on-warning": "#412d00",
"warning-container": "#5e4200",
"on-warning-container": "#ffdea6",
"info": "#aec6ff",
"on-info": "#002e6a",
"info-container": "#004396",
"on-info-container": "#d8e2ff",
"inverse-error": "#b41340",
"inverse-success": "#006c45",
"inverse-warning": "#7c5800",
"inverse-info": "#005ac4"
}
}
+2 -2
View File
@@ -1,2 +1,2 @@
// Alpine.js is bundled and started automatically by Livewire 4.
// Do not import it here to avoid "multiple instances of Alpine" errors.
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
@@ -1,14 +0,0 @@
@props([
'on',
])
<div
x-data="{ shown: false, timeout: null }"
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms
style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }}
>
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div>
@@ -1,4 +0,0 @@
<a href="{{ route('home') }}" {{ $attributes->merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate>
<x-app-logo-icon class="size-6 fill-current" />
<span>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
</a>
@@ -4,6 +4,6 @@
])
<div class="flex w-full flex-col text-center">
<h2 class="text-xl font-bold">{{ $title }}</h2>
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
<h1 class="type-headline-sm">{{ $title }}</h1>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
</div>
@@ -3,7 +3,5 @@
])
@if ($status)
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
{{ $status }}
</div>
<x-alert color="success" {{ $attributes }}>{{ $status }}</x-alert>
@endif
@@ -1 +0,0 @@
{{-- Desktop user menu - integrated into sidebar layout --}}
@@ -1,12 +0,0 @@
@props([
'id' => uniqid(),
])
<svg {{ $attributes }} fill="none">
<defs>
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
</svg>
-18
View File
@@ -1,18 +0,0 @@
<x-layouts::app :title="__('Dashboard')">
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
<div class="relative aspect-video overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
</div>
<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-base-300">
<x-placeholder-pattern class="absolute inset-0 size-full stroke-current/20" />
</div>
</div>
</x-layouts::app>
+15 -3
View File
@@ -1,3 +1,15 @@
<x-layouts::app.sidebar :title="$title ?? null">
{{ $slot }}
</x-layouts::app.sidebar>
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased">
@include('partials.app-bar')
<main class="mx-auto w-full max-w-5xl px-4 pt-4 pb-16 sm:px-6">
{{ $slot }}
</main>
<x-toast />
</body>
</html>
@@ -1,4 +0,0 @@
{{-- Header layout not used - redirects to sidebar layout --}}
<x-layouts::app.sidebar :title="$title ?? null">
{{ $slot }}
</x-layouts::app.sidebar>
@@ -1,42 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
<head>
@include('partials.head')
</head>
<body class="min-h-screen font-sans antialiased bg-base-200/50 flex flex-col">
{{-- MAIN CONTENT --}}
<main class="flex-1 w-full max-w-5xl mx-auto px-4 py-8">
{{ $slot }}
</main>
{{-- FOOTER NAV --}}
<footer class="border-t border-base-300 bg-base-100/50">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between text-sm">
<a href="{{ route('upload') }}" class="font-medium opacity-70 hover:opacity-100 transition-opacity">
{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}
</a>
<nav class="flex items-center gap-4">
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
@auth
@if(auth()->user()->is_admin)
<a href="{{ route('admin.dashboard') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Dashboard') }}</a>
<a href="{{ route('admin.settings') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Settings') }}</a>
@endif
<a href="{{ route('profile.edit') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Profile') }}</a>
<form method="POST" action="{{ route('logout') }}" class="inline">
@csrf
<button type="submit" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Logout') }}</button>
</form>
@else
<a href="{{ route('login') }}" class="opacity-60 hover:opacity-100 transition-opacity">{{ __('Login') }}</a>
@endauth
</nav>
</div>
</footer>
{{-- Toast --}}
<x-toast />
</body>
</html>
+19 -3
View File
@@ -1,3 +1,19 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
@include('partials.head')
</head>
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased">
@include('partials.app-bar')
<main class="flex flex-1 justify-center px-4 pt-6 pb-16 sm:items-center sm:pt-0">
<div class="w-full max-w-md rounded-corner-xl bg-surface-container-low p-6 sm:p-8">
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
</div>
</main>
<x-toast />
</body>
</html>
@@ -1,4 +0,0 @@
{{-- Card auth layout - delegates to simple layout --}}
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
<head>
@include('partials.head')
@livewireStyles
</head>
<body class="min-h-screen bg-base-200 antialiased">
<div class="flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current" />
</span>
<span class="sr-only">{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
</a>
<div class="flex flex-col gap-6">
{{ $slot }}
</div>
<div class="flex justify-center">
<x-theme-toggle class="opacity-60 hover:opacity-100 transition-opacity" />
</div>
</div>
</div>
@livewireScripts
</body>
</html>
@@ -1,4 +0,0 @@
{{-- Split auth layout - delegates to simple layout --}}
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>
@@ -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>
@@ -1,100 +1,66 @@
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
<div class="mx-auto max-w-2xl">
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
@if (session('message'))
<div class="alert alert-success mb-6">
<x-icon name="o-check-circle" class="w-5 h-5" />
<span>{{ session('message') }}</span>
</div>
@endif
<form wire:submit="saveSettings" class="grid gap-6">
<x-card :title="__('Branding')" variant="outlined">
<div class="grid gap-5">
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
<form wire:submit="saveSettings">
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
wire:model="siteTitle"
label="{{ __('Site Title') }}"
hint="{{ __('Displayed as the heading on the upload page.') }}"
/>
<x-textarea
wire:model="siteDescription"
label="{{ __('Site Description') }}"
hint="{{ __('Displayed below the title on the upload page.') }}"
rows="3"
/>
<div>
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
<div class="grid gap-3">
@if ($currentLogo)
<div class="flex items-center gap-4 mb-3">
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
<x-button
label="{{ __('Remove Logo') }}"
class="btn-sm btn-ghost text-error"
wire:click="removeLogo"
wire:confirm="{{ __('Remove the logo?') }}"
/>
<div class="flex flex-wrap items-center gap-4">
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
</div>
@endif
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
<x-file wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
@if ($siteLogo && is_object($siteLogo))
<div class="mt-2">
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
@endif
</div>
@endif
@error('siteLogo')
<p class="text-error text-sm mt-1">{{ $message }}</p>
@enderror
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
</div>
</div>
</x-card>
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
<div class="space-y-4">
<div>
<x-password
wire:model="systemPassword"
label="{{ __('System Upload Password') }}"
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
/>
@if ($hasSystemPassword)
<div class="mt-2">
<x-button
label="{{ __('Clear System Password') }}"
class="btn-sm btn-ghost text-error"
wire:click="clearSystemPassword"
wire:confirm="{{ __('Remove the system password?') }}"
/>
</div>
@if (str_contains($siteLogo->getMimeType(), 'svg'))
<p class="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
@else
<div>
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
</div>
@endif
@endif
</div>
</div>
</x-card>
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
<div class="space-y-4">
<x-card :title="__('Upload Protection')" variant="outlined">
<div class="grid gap-3">
<x-password
wire:model="systemPassword"
:label="__('System Upload Password')"
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
autocomplete="new-password"
/>
@if ($hasSystemPassword)
<div>
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
</div>
@endif
</div>
</x-card>
<x-card :title="__('Upload Limits')" variant="outlined">
<div class="grid gap-5">
<x-toggle
wire:model.live="allowNeverExpire"
label="{{ __('Allow shares to never expire') }}"
hint="{{ __('When disabled, users must select an expiration time.') }}"
:label="__('Allow shares to never expire')"
:hint="__('When disabled, users must select an expiration time.')"
right
/>
<x-select
wire:model="defaultExpiration"
label="{{ __('Default Expiration') }}"
:label="__('Default Expiration')"
:placeholder="$allowNeverExpire ? __('None') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -108,44 +74,49 @@
<x-input
wire:model="maxFileSize"
label="{{ __('Max file size (MB)') }}"
:label="__('Max file size (MB)')"
type="number"
min="1"
max="{{ $phpMaxUploadMb }}"
:max="$phpMaxUploadMb"
suffix="MB"
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
/>
<x-input
wire:model="maxFilesPerShare"
label="{{ __('Max files per share') }}"
type="number"
min="1"
/>
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
<x-input
wire:model="maxSizePerShare"
label="{{ __('Max total size per share (GB)') }}"
type="number"
min="1"
suffix="GB"
/>
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
</div>
</x-card>
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
<div class="space-y-4">
<x-input
wire:model="maxStorageQuota"
label="{{ __('Max storage quota (GB)') }}"
type="number"
min="1"
suffix="GB"
hint="{{ __('When reached, new uploads are blocked.') }}"
/>
</div>
<x-card :title="__('Storage')" variant="outlined">
<x-input
wire:model="maxStorageQuota"
:label="__('Max storage quota (GB)')"
type="number"
min="1"
suffix="GB"
:hint="__('When reached, new uploads are blocked.')"
/>
</x-card>
<x-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" />
</form>
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
{{ __('The upload and download pages show the default mark again.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
{{ __('Anyone who can reach the upload page can upload files again.') }}
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
</x-slot:actions>
</x-modal>
</div>
@@ -1,21 +1,16 @@
<div class="max-w-3xl mx-auto">
<div class="text-center mb-6">
<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="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
<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="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if ($isStorageFull)
<div class="alert alert-warning mb-6">
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
</div>
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
@else
<form
wire:submit="createShare"
@@ -88,97 +83,98 @@
x-on:livewire-upload-error="resetUpload()"
x-on:livewire-upload-progress="progress = $event.detail.progress"
>
{{-- Drop Zone --}}
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
<div
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
:class="{
'border-primary bg-primary/5': dragging,
'border-base-300 hover:border-primary/50': !dragging,
'opacity-50 pointer-events-none': uploading
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,
}"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@drop.prevent="handleDrop($event)"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="handleDrop($event)"
data-test="drop-zone"
>
<x-icon name="o-cloud-arrow-up" class="w-12 h-12 mx-auto opacity-40 mb-3" />
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
<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>
<label class="btn btn-outline btn-sm mt-4 cursor-pointer" :class="uploading && 'btn-disabled'">
<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="hidden"
:disabled="uploading"
/>
<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">
<template x-if="progress < 100">
<div>
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
<button type="button" class="btn btn-ghost btn-xs" x-on:click="$wire.cancelUpload('files')">
{{ __('Cancel') }}
</button>
</div>
<progress class="progress progress-primary w-full" max="100" x-bind:value="progress"></progress>
{{-- 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>
</template>
<template x-if="progress >= 100">
<div class="flex items-center gap-3 text-sm font-medium">
<span class="loading loading-spinner loading-sm"></span>
{{ __('Processing files...') }}
</div>
</template>
<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>
{{-- Errors --}}
@error('files')
<div class="alert alert-error mb-4">{{ $message }}</div>
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
@enderror
{{-- File List --}}
{{-- Selected files --}}
@if (count($files))
<div class="mb-6">
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
<div class="space-y-1 max-h-60 overflow-y-auto">
@foreach ($files as $index => $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
</span>
<span class="opacity-50 flex-shrink-0">
({{ Number::fileSize($file->getSize()) }})
</span>
</div>
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
<x-icon name="o-x-mark" class="w-4 h-4" />
</button>
</div>
@endforeach
<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') }}" class="mb-6" shadow>
<div class="space-y-4">
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
<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') }}" />
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
@endif
<x-select
wire:model="expiration"
label="{{ __('Expiration') }}"
:label="__('Expiration')"
:placeholder="$allowNeverExpire ? __('Never') : null"
:options="[
['id' => '1h', 'name' => __('1 Hour')],
@@ -192,22 +188,24 @@
<x-input
wire:model="maxDownloads"
label="{{ __('Max downloads') }}"
:label="__('Max downloads')"
type="number"
min="1"
placeholder="{{ __('Unlimited') }}"
:placeholder="__('Unlimited')"
/>
</div>
</x-card>
{{-- Submit --}}
<x-button
type="submit"
label="{{ __('Create Share Link') }}"
class="btn-primary w-full"
icon="o-link"
: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
+10 -10
View File
@@ -4,37 +4,37 @@
<form wire:submit="createAdmin" class="flex flex-col gap-6">
<x-input
wire:model="name"
label="{{ __('Name') }}"
:label="__('Name')"
type="text"
required
autofocus
placeholder="{{ __('Admin name') }}"
icon="o-user"
:placeholder="__('Admin name')"
icon="person"
/>
<x-input
wire:model="email"
label="{{ __('Email address') }}"
:label="__('Email address')"
type="email"
required
placeholder="admin@example.com"
icon="o-envelope"
icon="mail"
/>
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('Password') }}"
:placeholder="__('Password')"
/>
<x-password
wire:model="password_confirmation"
label="{{ __('Confirm password') }}"
:label="__('Confirm password')"
required
placeholder="{{ __('Confirm password') }}"
:placeholder="__('Confirm password')"
/>
<x-button type="submit" label="{{ __('Create Admin Account') }}" class="btn-primary w-full" spinner="createAdmin" />
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
</form>
</div>
@@ -1,65 +1,38 @@
<div class="max-w-lg mx-auto">
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
<div class="space-y-4">
{{-- Share URL --}}
<div
x-data="{
copied: false,
url: '{{ route('share.download', $share) }}',
async copy() {
try {
await navigator.clipboard.writeText(this.url);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
} catch (e) {}
}
}"
>
<label class="label font-medium text-sm">{{ __('Share Link') }}</label>
<div class="join w-full">
<input
type="text"
readonly
:value="url"
class="input input-bordered join-item w-full"
/>
<button type="button" @click="copy()" class="btn join-item">
<span x-show="!copied">{{ __('Copy') }}</span>
<span x-show="copied" class="text-success">{{ __('Copied!') }}</span>
</button>
</div>
</div>
{{-- Details --}}
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Files') }}</span>
<p class="font-semibold">{{ $share->files->count() }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Total Size') }}</span>
<p class="font-semibold">{{ Number::fileSize($share->total_size) }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Expires') }}</span>
<p class="font-semibold">{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}</p>
</div>
<div class="bg-base-200 rounded-lg p-3">
<span class="opacity-60">{{ __('Max Downloads') }}</span>
<p class="font-semibold">{{ $share->max_downloads ?? __('Unlimited') }}</p>
</div>
</div>
@if ($share->isPasswordProtected())
<div class="alert alert-info">
<x-icon name="o-lock-closed" class="w-5 h-5" />
<span>{{ __('This share is password protected') }}</span>
</div>
@endif
<div class="mx-auto max-w-lg">
<div class="mb-8 text-center">
{{-- The link is ready: a check on an Expressive shape that settles in. --}}
<div class="relative mx-auto mb-4 grid size-24 place-items-center motion-safe:animate-[share-ready_var(--md-sys-motion-spatial-slow-duration)_var(--md-sys-motion-spatial-slow)_both]">
<x-shape name="soft-burst" class="absolute inset-0 size-full text-primary-container" />
<x-icon name="check" class="relative size-12 text-on-primary-container" />
</div>
<x-slot:actions>
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
</x-slot:actions>
</x-card>
<h1 class="type-headline-md">{{ __('Share Created!') }}</h1>
<p class="mt-1 type-body-lg text-on-surface-variant">{{ __('Your files are ready to share') }}</p>
</div>
<div class="grid gap-4">
<x-input
:label="__('Share Link')"
:value="route('share.download', $share)"
readonly
copyable
icon="link"
data-test="share-link"
/>
<div class="grid grid-cols-2 gap-3">
<x-stat :title="__('Files')" :value="$share->files->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" />
</div>
@if ($share->isPasswordProtected())
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
@endif
<div class="flex justify-end">
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
</div>
</div>
</div>
@@ -1,67 +1,61 @@
<div class="w-full max-w-lg mx-auto">
<div class="text-center mb-6">
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
iOS before Safari 18.4, which cannot position them. --}}
<div class="mx-auto w-full max-w-lg">
<div class="mb-8 text-center">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
@else
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
<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="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
</div>
@if (! $authenticated)
{{-- Password form --}}
<form wire:submit="verifyPassword">
<x-card title="{{ __('Password Required') }}" subtitle="{{ __('Enter the password to access these files') }}" shadow>
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" variant="outlined">
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('Enter share password') }}"
autocomplete="off"
:placeholder="__('Enter share password')"
/>
<x-slot:actions>
<x-button type="submit" label="{{ __('Unlock') }}" class="btn-primary w-full mt-4" icon="o-lock-open" spinner="verifyPassword" />
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" class="w-full" />
</x-slot:actions>
</x-card>
</form>
@else
{{-- File list --}}
<x-card title="{{ __('Shared Files') }}" shadow>
<div class="space-y-2 mb-4">
<x-card :title="__('Shared Files')" variant="outlined">
<x-list :label="__('Shared Files')">
@foreach ($share->files as $file)
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
<div class="flex items-center gap-2 min-w-0">
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
</div>
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
</a>
</div>
<x-list-item
:title="$file->relative_path ?: $file->original_name"
:description="Number::fileSize($file->file_size)"
icon="description"
wire:key="file-{{ $file->id }}"
>
<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-slot:end>
</x-list-item>
@endforeach
</div>
</x-list>
@if ($share->expires_at)
<p class="text-xs opacity-50 mb-2">
<p class="mt-2 type-body-sm text-on-surface-variant">
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
</p>
@endif
<x-slot:actions>
@if ($share->files->count() > 1)
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download All as ZIP') }}
</a>
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
@else
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary w-full">
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
{{ __('Download') }}
</a>
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
@endif
</x-slot:actions>
</x-card>
@@ -4,11 +4,11 @@
<form wire:submit="verify" class="flex flex-col gap-6">
<x-password
wire:model="password"
label="{{ __('Password') }}"
:label="__('Password')"
required
placeholder="{{ __('System password') }}"
:placeholder="__('System password')"
/>
<x-button type="submit" label="{{ __('Continue') }}" class="btn-primary w-full" spinner="verify" />
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
</form>
</div>
@@ -1,24 +1,22 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
<x-layouts::auth :title="__('Confirm password')">
<x-auth-header
:title="__('Confirm password')"
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
/>
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
@csrf
<x-password
name="password"
:label="__('Password')"
required
autofocus
autocomplete="current-password"
/>
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
@csrf
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="current-password"
placeholder="{{ __('Password') }}"
/>
<x-button type="submit" label="{{ __('Confirm') }}" class="btn-primary w-full" data-test="confirm-password-button" />
</form>
</div>
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
</form>
</x-layouts::auth>
@@ -1,30 +1,27 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<x-layouts::auth :title="__('Forgot password')">
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
@csrf
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
@csrf
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email Address') }}"
type="email"
required
autofocus
placeholder="email@example.com"
icon="o-envelope"
/>
<x-input
name="email"
:label="__('Email Address')"
:value="old('email')"
type="email"
required
autofocus
placeholder="email@example.com"
icon="mail"
/>
<x-button type="submit" label="{{ __('Email password reset link') }}" class="btn-primary w-full" data-test="email-password-reset-link-button" />
</form>
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
<span>{{ __('Or, return to') }}</span>
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('log in') }}</a>
</div>
</div>
<p class="text-center type-body-md text-on-surface-variant">
{{ __('Or, return to') }}
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
</p>
</x-layouts::auth>
+31 -50
View File
@@ -1,59 +1,40 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<x-layouts::auth :title="__('Log in')">
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
@csrf
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
@csrf
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email address') }}"
:value="old('email')"
type="email"
<x-input
name="email"
:label="__('Email address')"
:value="old('email')"
type="email"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="mail"
/>
<div class="grid gap-1">
<x-password
name="password"
:label="__('Password')"
required
autofocus
autocomplete="email"
placeholder="email@example.com"
icon="o-envelope"
autocomplete="current-password"
/>
<!-- Password -->
<div class="relative">
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="current-password"
placeholder="{{ __('Password') }}"
/>
@if (Route::has('password.request'))
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
@endif
</div>
@if (Route::has('password.request'))
<a class="absolute top-0 text-sm end-0 link link-primary" href="{{ route('password.request') }}" wire:navigate>
{{ __('Forgot your password?') }}
</a>
@endif
</div>
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
<!-- Remember Me -->
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="remember" class="checkbox checkbox-sm" {{ old('remember') ? 'checked' : '' }} />
<span class="text-sm">{{ __('Remember me') }}</span>
</label>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Log in') }}" class="btn-primary w-full" data-test="login-button" />
</div>
</form>
@if (Route::has('register'))
<div class="space-x-1 text-sm text-center rtl:space-x-reverse opacity-60">
<span>{{ __('Don\'t have an account?') }}</span>
<a href="{{ route('register') }}" class="link link-primary" wire:navigate>{{ __('Sign up') }}</a>
</div>
@endif
</div>
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
</form>
</x-layouts::auth>
@@ -1,63 +0,0 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
@csrf
<!-- Name -->
<x-input
name="name"
label="{{ __('Name') }}"
:value="old('name')"
type="text"
required
autofocus
autocomplete="name"
placeholder="{{ __('Full name') }}"
icon="o-user"
/>
<!-- Email Address -->
<x-input
name="email"
label="{{ __('Email address') }}"
:value="old('email')"
type="email"
required
autocomplete="email"
placeholder="email@example.com"
icon="o-envelope"
/>
<!-- Password -->
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Password') }}"
/>
<!-- Confirm Password -->
<x-password
name="password_confirmation"
label="{{ __('Confirm password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Confirm password') }}"
/>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Create account') }}" class="btn-primary w-full" data-test="register-user-button" />
</div>
</form>
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
<span>{{ __('Already have an account?') }}</span>
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('Log in') }}</a>
</div>
</div>
</x-layouts::auth>
@@ -1,47 +1,36 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<x-layouts::auth :title="__('Reset password')">
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<x-auth-session-status :status="session('status')" />
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
@csrf
<!-- Token -->
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
@csrf
<input type="hidden" name="token" value="{{ request()->route('token') }}">
<!-- Email Address -->
<x-input
name="email"
value="{{ request('email') }}"
label="{{ __('Email') }}"
type="email"
required
autocomplete="email"
icon="o-envelope"
/>
<x-input
name="email"
:value="old('email', request('email'))"
:label="__('Email')"
type="email"
required
autocomplete="email"
icon="mail"
/>
<!-- Password -->
<x-password
name="password"
label="{{ __('Password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Password') }}"
/>
<x-password
name="password"
:label="__('Password')"
required
autocomplete="new-password"
/>
<!-- Confirm Password -->
<x-password
name="password_confirmation"
label="{{ __('Confirm password') }}"
required
autocomplete="new-password"
placeholder="{{ __('Confirm password') }}"
/>
<x-password
name="password_confirmation"
:label="__('Confirm password')"
required
autocomplete="new-password"
/>
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Reset password') }}" class="btn-primary w-full" data-test="reset-password-button" />
</div>
</form>
</div>
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
</form>
</x-layouts::auth>
@@ -1,90 +1,65 @@
<x-layouts::auth>
<div class="flex flex-col gap-6">
<div
class="relative w-full h-auto"
x-cloak
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
code: '',
recovery_code: '',
toggleInput() {
this.showRecoveryInput = !this.showRecoveryInput;
this.code = '';
this.recovery_code = '';
$nextTick(() => {
this.showRecoveryInput
? this.$refs.recovery_code?.focus()
: this.$refs.code?.focus();
<x-layouts::auth :title="__('Two-factor authentication')">
<div
class="flex flex-col gap-6"
x-data="{
showRecoveryInput: @js($errors->has('recovery_code')),
toggleInput() {
this.showRecoveryInput = ! this.showRecoveryInput;
$nextTick(() => {
requestAnimationFrame(() => {
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
});
},
}"
>
<div x-show="!showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput">
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}">
@csrf
<div class="space-y-5 text-center">
<div x-show="!showRecoveryInput">
<div class="my-5">
<x-input
type="text"
name="code"
x-ref="code"
x-model="code"
x-bind:required="!showRecoveryInput"
autocomplete="one-time-code"
placeholder="000000"
class="text-center text-2xl tracking-widest"
maxlength="6"
/>
</div>
</div>
<div x-show="showRecoveryInput">
<div class="my-5">
<x-input
type="text"
name="recovery_code"
x-ref="recovery_code"
x-bind:required="showRecoveryInput"
autocomplete="one-time-code"
x-model="recovery_code"
/>
</div>
@error('recovery_code')
<p class="text-error text-sm">{{ $message }}</p>
@enderror
</div>
<x-button
type="submit"
label="{{ __('Continue') }}"
class="btn-primary w-full"
/>
</div>
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
<span class="opacity-50">{{ __('or you can') }}</span>
<div class="inline font-medium underline cursor-pointer opacity-80">
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
</div>
</div>
</form>
});
},
}"
>
<div x-show="! showRecoveryInput">
<x-auth-header
:title="__('Authentication Code')"
:description="__('Enter the authentication code provided by your authenticator application.')"
/>
</div>
<div x-show="showRecoveryInput" x-cloak>
<x-auth-header
:title="__('Recovery Code')"
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
/>
</div>
<form method="POST" action="{{ route('two-factor.login.store') }}" class="flex flex-col gap-5">
@csrf
<div x-ref="code" x-show="! showRecoveryInput">
<x-input
name="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
x-bind:disabled="showRecoveryInput"
/>
</div>
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
<x-input
name="recovery_code"
:label="__('Recovery code')"
autocomplete="one-time-code"
mono
x-bind:disabled="! showRecoveryInput"
/>
</div>
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" />
</form>
<p class="text-center type-body-md text-on-surface-variant">
{{ __('or you can') }}
<button type="button" class="link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
<button type="button" class="link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
</p>
</div>
</x-layouts::auth>
@@ -1,25 +1,24 @@
<x-layouts::auth>
<div class="mt-4 flex flex-col gap-6">
<p class="text-center text-sm opacity-70">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</p>
<x-layouts::auth :title="__('Verify email')">
<x-auth-header
:title="__('Verify your email')"
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
/>
@if (session('status') == 'verification-link-sent')
<p class="text-center text-sm font-medium text-success">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</p>
@endif
@if (session('status') == 'verification-link-sent')
<x-alert color="success">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</x-alert>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-button type="submit" label="{{ __('Resend verification email') }}" class="btn-primary w-full" />
</form>
<div class="flex flex-col items-stretch gap-3">
<form method="POST" action="{{ route('verification.send') }}">
@csrf
<x-button type="submit" :label="__('Resend verification email')" variant="filled" class="w-full" />
</form>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-button type="submit" label="{{ __('Log out') }}" class="btn-ghost btn-sm" data-test="logout-button" />
</form>
</div>
<form method="POST" action="{{ route('logout') }}" class="self-center">
@csrf
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
</form>
</div>
</x-layouts::auth>
+18 -16
View File
@@ -1,22 +1,24 @@
<div class="flex items-start max-md:flex-col">
<div class="me-10 w-full pb-4 md:w-[220px]">
<x-menu class="!p-0">
<x-menu-item title="{{ __('Profile') }}" link="{{ route('profile.edit') }}" wire:navigate />
<x-menu-item title="{{ __('Password') }}" link="{{ route('user-password.edit') }}" wire:navigate />
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication())
<x-menu-item title="{{ __('Two-Factor Auth') }}" link="{{ route('two-factor.show') }}" wire:navigate />
@endif
<x-menu-item title="{{ __('Appearance') }}" link="{{ route('appearance.edit') }}" wire:navigate />
</x-menu>
</div>
@php
$items = [
['title' => __('Profile'), 'icon' => 'person', 'url' => route('profile.edit'), 'active' => request()->routeIs('profile.edit')],
['title' => __('Password'), 'icon' => 'password', 'url' => route('user-password.edit'), 'active' => request()->routeIs('user-password.edit')],
];
<div class="divider md:hidden"></div>
if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) {
$items[] = ['title' => __('Two-Factor Auth'), 'icon' => 'shield_lock', 'url' => route('two-factor.show'), 'active' => request()->routeIs('two-factor.show')];
}
<div class="flex-1 self-stretch max-md:pt-6">
<h2 class="text-lg font-semibold">{{ $heading ?? '' }}</h2>
<p class="text-sm opacity-60">{{ $subheading ?? '' }}</p>
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
@endphp
<div class="mt-5 w-full max-w-lg">
<div class="w-full">
<x-section-nav :items="$items" :label="__('Settings')" />
<div class="mt-8">
<h2 class="type-title-lg">{{ $heading ?? '' }}</h2>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subheading ?? '' }}</p>
<div class="mt-6 w-full max-w-lg">
{{ $slot }}
</div>
</div>
@@ -45,83 +45,48 @@ new class extends Component {
}
}; ?>
<div
class="py-6 space-y-6 border shadow-sm rounded-xl border-base-300"
wire:cloak
x-data="{ showRecoveryCodes: false }"
>
<div class="px-6 space-y-2">
<div class="flex items-center gap-2">
<x-icon name="o-lock-closed" class="w-4 h-4" />
<h3 class="text-lg font-semibold">{{ __('2FA Recovery Codes') }}</h3>
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
<div class="grid gap-4">
<div>
<div class="flex items-center gap-2">
<x-icon name="lock" class="size-5 text-on-surface-variant" />
<h3 class="type-title-md">{{ __('2FA Recovery Codes') }}</h3>
</div>
<p class="mt-1 type-body-md text-on-surface-variant">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</p>
</div>
<p class="text-sm opacity-60">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</p>
</div>
<div class="px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<x-button
x-show="!showRecoveryCodes"
icon="o-eye"
label="{{ __('View Recovery Codes') }}"
class="btn-primary btn-sm"
@click="showRecoveryCodes = true;"
/>
<x-button
x-show="showRecoveryCodes"
icon="o-eye-slash"
label="{{ __('Hide Recovery Codes') }}"
class="btn-primary btn-sm"
@click="showRecoveryCodes = false"
/>
<div class="flex flex-wrap items-center gap-2">
<span x-show="! showRecoveryCodes" class="inline-flex">
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = true" />
</span>
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = false" />
</span>
@if (filled($recoveryCodes))
<x-button
x-show="showRecoveryCodes"
icon="o-arrow-path"
label="{{ __('Regenerate Codes') }}"
class="btn-sm"
wire:click="regenerateRecoveryCodes"
/>
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" wire:click="regenerateRecoveryCodes" />
</span>
@endif
</div>
<div
x-show="showRecoveryCodes"
x-transition
id="recovery-codes-section"
class="relative overflow-hidden"
x-bind:aria-hidden="!showRecoveryCodes"
>
<div class="mt-3 space-y-3">
@error('recoveryCodes')
<div class="alert alert-error">{{ $message }}</div>
@enderror
<div x-show="showRecoveryCodes" x-cloak id="recovery-codes-section" class="grid gap-3">
@error('recoveryCodes')
<x-alert color="error">{{ $message }}</x-alert>
@enderror
@if (filled($recoveryCodes))
<div
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-base-200"
role="list"
aria-label="{{ __('Recovery codes') }}"
>
@foreach($recoveryCodes as $code)
<div
role="listitem"
class="select-text"
wire:loading.class="opacity-50 animate-pulse"
>
{{ $code }}
</div>
@endforeach
</div>
<p class="text-xs opacity-60">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
</p>
@endif
</div>
@if (filled($recoveryCodes))
<div class="grid gap-1 rounded-corner-md bg-surface-container-highest p-4 font-mono type-body-md" role="list" aria-label="{{ __('Recovery codes') }}">
@foreach ($recoveryCodes as $code)
<div role="listitem" class="select-text" wire:loading.class="animate-pulse opacity-50">{{ $code }}</div>
@endforeach
</div>
<p class="type-body-sm text-on-surface-variant">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
</p>
@endif
</div>
</div>
</div>
</x-card>
@@ -10,6 +10,6 @@ new class extends Component {
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
<x-theme-toggle />
<x-theme-toggle mode="picker" class="w-full max-w-sm" data-test="appearance-picker" />
</x-pages::settings.layout>
</section>
@@ -26,31 +26,30 @@ new class extends Component {
}
}; ?>
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<h3 class="text-lg font-semibold">{{ __('Delete account') }}</h3>
<p class="text-sm opacity-60">{{ __('Delete your account and all of its resources') }}</p>
<section class="mt-12 grid gap-4">
<x-divider />
<div>
<h3 class="type-title-md">{{ __('Delete account') }}</h3>
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Delete your account and all of its resources') }}</p>
</div>
<x-button
label="{{ __('Delete account') }}"
class="btn-error"
@click="$wire.showDeleteModal = true"
data-test="delete-user-button"
/>
<div>
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
</div>
<x-modal wire:model="showDeleteModal" title="{{ __('Are you sure you want to delete your account?') }}">
<p class="text-sm opacity-70">
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
<p>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<form wire:submit="deleteUser" class="space-y-6 mt-4">
<x-password wire:model="password" label="{{ __('Password') }}" />
<x-slot:actions>
<x-button label="{{ __('Cancel') }}" @click="$wire.showDeleteModal = false" />
<x-button type="submit" label="{{ __('Delete account') }}" class="btn-error" data-test="confirm-delete-user-button" />
</x-slot:actions>
<form id="delete-user-form" wire:submit="deleteUser" class="mt-4">
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
</form>
<x-slot:actions>
<x-button :label="__('Cancel')" x-on:click="close()" />
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
</x-slot:actions>
</x-modal>
</section>
@@ -5,9 +5,11 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
new class extends Component {
use PasswordValidationRules;
use Toasts;
public string $current_password = '';
public string $password = '';
@@ -36,6 +38,8 @@ new class extends Component {
$this->reset('current_password', 'password', 'password_confirmation');
$this->dispatch('password-updated');
$this->success(__('Saved.'));
}
}; ?>
@@ -43,34 +47,13 @@ new class extends Component {
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<x-password
wire:model="current_password"
label="{{ __('Current password') }}"
required
autocomplete="current-password"
/>
<x-password
wire:model="password"
label="{{ __('New password') }}"
required
autocomplete="new-password"
/>
<x-password
wire:model="password_confirmation"
label="{{ __('Confirm Password') }}"
required
autocomplete="new-password"
/>
<form method="POST" wire:submit="updatePassword" class="grid gap-5">
<x-password wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
<x-password wire:model="password" :label="__('New password')" required autocomplete="new-password" />
<x-password wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updatePassword" data-test="update-password-button" />
</div>
<x-action-message class="me-3" on="password-updated">
{{ __('Saved.') }}
</x-action-message>
<div>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
</div>
</form>
</x-pages::settings.layout>
@@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Session;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Component;
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
new class extends Component {
use ProfileValidationRules;
use Toasts;
public string $name = '';
public string $email = '';
@@ -42,6 +44,8 @@ new class extends Component {
$user->save();
$this->dispatch('profile-updated', name: $user->name);
$this->success(__('Saved.'));
}
/**
@@ -52,7 +56,7 @@ new class extends Component {
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
$this->redirectIntended(default: route('admin.dashboard', absolute: false));
return;
}
@@ -80,39 +84,29 @@ new class extends Component {
@include('partials.settings-heading')
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<x-input wire:model="name" label="{{ __('Name') }}" type="text" required autofocus autocomplete="name" icon="o-user" />
<form wire:submit="updateProfileInformation" class="grid w-full gap-5">
<x-input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
<div>
<x-input wire:model="email" label="{{ __('Email') }}" type="email" required autocomplete="email" icon="o-envelope" />
<div class="grid gap-3">
<x-input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
@if ($this->hasUnverifiedEmail)
<div>
<p class="mt-4 text-sm opacity-70">
{{ __('Your email address is unverified.') }}
<p class="type-body-md text-on-surface-variant">
{{ __('Your email address is unverified.') }}
<a class="link link-primary text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</a>
</p>
<button type="button" class="link" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 text-sm font-medium text-success">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@if (session('status') === 'verification-link-sent')
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
@endif
@endif
</div>
<div class="flex items-center gap-4">
<div class="flex items-center justify-end">
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updateProfileInformation" data-test="update-profile-button" />
</div>
<x-action-message class="me-3" on="profile-updated">
{{ __('Saved.') }}
</x-action-message>
<div>
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
</div>
</form>
@@ -184,142 +184,81 @@ new class extends Component {
:heading="__('Two Factor Authentication')"
:subheading="__('Manage your two-factor authentication settings')"
>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
<div class="grid w-full gap-6" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="badge badge-success">{{ __('Enabled') }}</span>
</div>
<div class="grid justify-items-start gap-4">
<x-badge :value="__('Enabled')" tonal color="success" />
<p class="opacity-70">
<p class="type-body-md text-on-surface-variant">
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</p>
</div>
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
<div class="flex justify-start">
<x-button
label="{{ __('Disable 2FA') }}"
icon="o-shield-exclamation"
class="btn-error"
wire:click="disable"
/>
</div>
<div>
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
</div>
@else
<div class="space-y-4">
<div class="flex items-center gap-3">
<span class="badge badge-error">{{ __('Disabled') }}</span>
</div>
<div class="grid justify-items-start gap-4">
<x-badge :value="__('Disabled')" tonal color="error" />
<p class="opacity-60">
<p class="type-body-md text-on-surface-variant">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</p>
<x-button
label="{{ __('Enable 2FA') }}"
icon="o-shield-check"
class="btn-primary"
wire:click="enable"
/>
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
</div>
@endif
</div>
</x-pages::settings.layout>
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" class="max-w-md">
<p class="text-sm opacity-70">{{ $this->modalConfig['description'] }}</p>
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
@if ($showVerificationStep)
<div class="space-y-6 mt-4">
<div class="flex flex-col items-center space-y-3 justify-center">
<x-input
name="code"
wire:model="code"
placeholder="000000"
class="text-center text-2xl tracking-widest"
maxlength="6"
/>
</div>
<x-slot:actions>
<x-button
label="{{ __('Back') }}"
wire:click="resetVerification"
/>
<x-button
label="{{ __('Confirm') }}"
class="btn-primary"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
/>
</x-slot:actions>
<div class="mt-2">
<x-input
name="code"
wire:model="code"
:label="__('Code')"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="6"
mono
autofocus
/>
</div>
<x-slot:actions>
<x-button :label="__('Back')" wire:click="resetVerification" />
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
</x-slot:actions>
@else
@error('setupData')
<div class="alert alert-error mt-4">{{ $message }}</div>
<x-alert color="error" class="mt-2">{{ $message }}</x-alert>
@enderror
<div class="flex justify-center mt-4">
<div class="relative w-64 overflow-hidden border rounded-lg border-base-300 aspect-square">
<div class="mt-2 flex justify-center">
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
<div class="grid aspect-square w-64 place-items-center overflow-hidden rounded-corner-lg bg-white p-4">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-base-200 animate-pulse">
<span class="loading loading-spinner"></span>
</div>
<x-loading :label="__('Loading')" />
@else
<div class="flex items-center justify-center h-full p-4 bg-white">
{!! $qrCodeSvg !!}
</div>
{!! $qrCodeSvg !!}
@endempty
</div>
</div>
<div class="space-y-4 mt-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-base-300"></div>
<span class="relative px-2 text-sm bg-base-100 opacity-60">
{{ __('or, enter the code manually') }}
</span>
</div>
<div class="mt-6 grid gap-3">
<p class="text-center type-label-lg text-on-surface-variant">{{ __('or, enter the code manually') }}</p>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="join w-full">
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="input input-bordered join-item w-full"
/>
<button
@click="copy()"
class="btn join-item"
>
<x-icon x-show="!copied" name="o-document-duplicate" class="w-4 h-4" />
<x-icon x-show="copied" name="o-check" class="w-4 h-4 text-success" />
</button>
</div>
</div>
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
</div>
<x-slot:actions>
<x-button
:disabled="$errors->has('setupData')"
label="{{ $this->modalConfig['buttonText'] }}"
class="btn-primary"
:label="$this->modalConfig['buttonText']"
variant="filled"
wire:click="showVerificationIfNecessary"
/>
</x-slot:actions>
@@ -0,0 +1,47 @@
{{-- The one top app bar on every page: the site's logo and title, then the account menu for a
signed-in user, or the theme toggle and a way to sign in for everyone else. --}}
@php
$siteTitle = \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare');
$siteLogo = \App\Models\Setting::get('site_logo');
@endphp
<x-app-bar>
<x-slot:navigation>
<a href="{{ route('home') }}" class="focus-ring flex min-w-0 items-center gap-3 rounded-corner-full py-2 ps-3 pe-4" data-test="app-bar-home">
@if ($siteLogo)
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="" class="h-8 w-auto" />
@else
<x-app-logo-icon class="size-8 shrink-0 text-primary" />
@endif
<span class="truncate type-title-lg text-on-surface">{{ $siteTitle }}</span>
</a>
</x-slot:navigation>
<x-slot:actions>
@auth
<span class="me-2 inline-flex">
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
<x-menu-item :label="__('Upload')" icon="upload" :link="route('upload')" />
@if (auth()->user()->is_admin)
<x-menu-item :label="__('Admin dashboard')" icon="dashboard" :link="route('admin.dashboard')" />
<x-menu-item :label="__('Admin settings')" icon="admin_panel_settings" :link="route('admin.settings')" />
@endif
<x-menu-item :label="__('Settings')" icon="settings" :link="route('profile.edit')" />
<x-slot:footer>
<form method="POST" action="{{ route('logout') }}">
@csrf
<x-menu-item :label="__('Log out')" icon="logout" type="submit" data-test="logout-button" />
</form>
</x-slot:footer>
</x-account-menu>
</span>
@else
<x-theme-toggle />
@if (Route::has('login') && ! request()->routeIs('login'))
<span class="me-1 inline-flex"><x-button :label="__('Log in')" :link="route('login')" /></span>
@endif
@endauth
</x-slot:actions>
</x-app-bar>
+2 -3
View File
@@ -1,5 +1,5 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>{{ $title ?? (\App\Models\Setting::get('site_title') ?: config('app.name')) }}</title>
@@ -7,7 +7,6 @@
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
<x-theme-script />
@vite(['resources/css/app.css', 'resources/js/app.js'])
@@ -1,5 +1,4 @@
<div class="relative mb-6 w-full">
<h1 class="text-2xl font-bold">{{ __('Settings') }}</h1>
<p class="text-sm opacity-60 mb-6">{{ __('Manage your profile and account settings') }}</p>
<div class="divider my-0"></div>
<div class="mb-6 w-full">
<h1 class="type-headline-md">{{ __('Settings') }}</h1>
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Manage your profile and account settings') }}</p>
</div>
-17
View File
@@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" data-theme="dark">
<script>document.documentElement.setAttribute('data-theme', localStorage.getItem('mary-theme')?.replaceAll('"','') || 'dark')</script>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</title>
@include('partials.head')
</head>
<body class="min-h-screen bg-base-200 flex items-center justify-center">
<div class="text-center">
<h1 class="text-4xl font-bold mb-2">{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</h1>
<p class="text-base-content/60 mb-6">{{ __('Secure file sharing made simple.') }}</p>
<a href="{{ route('upload') }}" class="btn btn-primary">{{ __('Upload Files') }}</a>
</div>
</body>
</html>