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
@@ -29,7 +29,7 @@ Read this section before you write a test.
- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project.
- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test.
- Write a feature test first. Write a unit test only for logic that does not use the framework.
- Write a feature test for every behavior reachable through a request. Real-browser tests require `pestphp/pest-plugin-browser` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test.
- Write a browser test only for behavior in JavaScript that a feature test cannot reach. Put a browser test in `tests/Browser`, and call `assertNoJavaScriptErrors()` in it.
- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention.
- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it.
@@ -31,6 +31,32 @@ An HTTP test shows that the endpoint performs authorization. It cannot identify
- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization.
- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists.
## Browser Tests
Write a browser test only for JavaScript behavior that an HTTP test cannot reach, such as modal interaction, drag-and-drop, live search, or client-side validation. Browser tests are slower than HTTP tests and can fail for reasons unrelated to the code under test.
- Assert the state that the user can see, and assert the state in the database that the interaction saves.
- Wait until the test reaches the required state. Do not wait for a fixed number of seconds, which can fail on a slower machine.
- Call `assertNoJavaScriptErrors()` in each browser test. An error in the console is a defect.
### Where a Browser Test Lives and How to Run It
The plugin runs browser tests as normal Pest tests, so they need no separate suite. Put them in `tests/Browser` to separate them from faster tests and run the directory with one command.
- Run a browser test with `vendor/bin/pest tests/Browser`, and add `--parallel` for the complete suite.
- Run `vendor/bin/pest --debug` to open the window of the browser and to pause at a failure. Use `--headed` to watch a run that passes.
- Add `--browser firefox` or `--browser safari` to run the test in a different browser. The default browser is Chrome.
- The run needs Playwright and a browser on the machine. Follow the plugin documentation for local and CI installation commands.
- Fetch `https://pestphp.com/docs/browser-testing` for the interactions, the assertions, and the devices that the plugin gives.
### Browser Test Pitfalls
- The plugin waits five seconds for an element. Raise the value with `pest()->browser()->timeout(10000)` in `Pest.php` for a page that is slower, and do not add a wait for a number of seconds to the test.
- Apply `RefreshDatabase` to the browser tests in `Pest.php`. A browser test hits the application through a real request, and the records that it leaves break the next test.
- Add `tests/Browser/Screenshots` to `.gitignore`. A failure writes a screenshot, and the file is not part of the repository.
- Give `withKeyDown()` a key code, such as `KeyA`. A letter such as `'a'` gives the lowercase character, whatever modifier the test holds.
- Interact inside the callback of `withinFrame()`. An interaction outside the callback does not reach the frame.
## Testing Validation
- Write one test for each validation rule when each failure represents a separate contract.
-3
View File
@@ -42,9 +42,6 @@ jobs:
- name: Install Node Dependencies
run: npm ci
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
-3
View File
@@ -29,9 +29,6 @@ jobs:
with:
php-version: '8.5'
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: |
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
+3 -3
View File
@@ -42,9 +42,6 @@ jobs:
- name: Install Node Dependencies
run: npm i
- name: Add Flux Credentials Loaded From ENV
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
- name: Install Dependencies
run: composer install --no-interaction --prefer-dist --optimize-autoloader
@@ -57,5 +54,8 @@ jobs:
- name: Build Assets
run: npm run build
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Tests
run: ./vendor/bin/pest
+18 -16
View File
@@ -1,20 +1,5 @@
# ============================================
# Stage 1: Build frontend assets
# ============================================
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY vite.config.js ./
COPY resources/ ./resources/
RUN npm run build
# ============================================
# Stage 2: Install PHP dependencies
# Stage 1: Install PHP dependencies
# ============================================
FROM composer:2 AS vendor
@@ -33,6 +18,23 @@ COPY . .
RUN composer dump-autoload --optimize --no-dev
# ============================================
# Stage 2: Build frontend assets
# ============================================
# After Composer: the stylesheet and script import Livewire Material from vendor/.
FROM node:24-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --prefer-offline
COPY vite.config.js ./
COPY resources/ ./resources/
COPY --from=vendor /app/vendor/nonameweb ./vendor/nonameweb
RUN npm run build
# ============================================
# Stage 3: Production image (FrankenPHP/Octane)
# ============================================
+17 -17
View File
@@ -14,37 +14,38 @@ class AdminDashboard extends Component
{
use WithPagination;
/** @var array<string, string> */
/**
* The columns the table can be sorted by.
*
* @var list<string>
*/
public const SORTABLE = ['token', 'files_count', 'total_size', 'download_count', 'expires_at', 'created_at'];
/** @var array{column: string, direction: string} */
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
/** The share the delete dialog is asking about, while it is open. */
public ?int $deletingShareId = null;
public function deleteShare(int $shareId, ShareService $shareService): void
{
$share = Share::query()->findOrFail($shareId);
$shareService->deleteShare($share);
}
/**
* @return array<string, array<string, string|bool>>
*/
public function headers(): array
{
return [
['key' => 'token', 'label' => __('Token')],
['key' => 'files_count', 'label' => __('Files')],
['key' => 'total_size', 'label' => __('Size')],
['key' => 'download_count', 'label' => __('Downloads')],
['key' => 'expires_at', 'label' => __('Expires')],
['key' => 'created_at', 'label' => __('Created')],
];
$this->deletingShareId = null;
}
public function render(): mixed
{
$shareService = app(ShareService::class);
// The sort comes from the browser: only a known column and direction reach the query.
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
$shares = Share::query()
->withCount('files')
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
->orderBy($column, $direction)
->paginate(15);
return view('livewire.admin.admin-dashboard', [
@@ -56,7 +57,6 @@ class AdminDashboard extends Component
'totalFiles' => ShareFile::query()->count(),
'usedSpace' => $shareService->getTotalUsedSpace(),
'maxQuota' => $shareService->getMaxStorageQuota(),
'headers' => $this->headers(),
]);
}
}
+15 -3
View File
@@ -8,10 +8,12 @@ use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithFileUploads;
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
#[Layout('layouts.app')]
class AdminSettings extends Component
{
use Toasts;
use WithFileUploads;
public string $systemPassword = '';
@@ -34,6 +36,12 @@ class AdminSettings extends Component
public $siteLogo;
/** Whether the "Remove the logo?" dialog is open. */
public bool $confirmingLogoRemoval = false;
/** Whether the "Remove the system password?" dialog is open. */
public bool $confirmingPasswordRemoval = false;
public function mount(): void
{
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
@@ -113,7 +121,7 @@ class AdminSettings extends Component
$this->systemPassword = '';
session()->flash('message', __('Settings saved successfully.'));
$this->success(__('Settings saved successfully.'));
}
public function removeLogo(): void
@@ -125,14 +133,18 @@ class AdminSettings extends Component
Setting::set('site_logo', null);
}
session()->flash('message', __('Logo removed.'));
$this->confirmingLogoRemoval = false;
$this->success(__('Logo removed.'));
}
public function clearSystemPassword(): void
{
Setting::set('system_password', null);
session()->flash('message', __('System password cleared.'));
$this->confirmingPasswordRemoval = false;
$this->success(__('System password cleared.'));
}
public function render(): mixed
-1
View File
@@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
Fortify::registerView(fn () => view('pages::auth.register'));
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
}
+9 -2
View File
@@ -15,7 +15,7 @@
"laravel/octane": "^2.13",
"laravel/tinker": "^3.0",
"livewire/livewire": "^4.0",
"robsontenorio/mary": "^2.7"
"nonameweb/livewire-material": "^1.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -26,6 +26,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^5.1",
"pestphp/pest-plugin-browser": "^5.0",
"pestphp/pest-plugin-laravel": "^5.0"
},
"autoload": {
@@ -99,5 +100,11 @@
}
},
"minimum-stability": "stable",
"prefer-stable": true
"prefer-stable": true,
"repositories": {
"livewire-material": {
"type": "vcs",
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git"
}
}
}
Generated
+1661 -474
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -73,7 +73,7 @@ return [
|
*/
'home' => '/dashboard',
'home' => '/admin/dashboard',
/*
|--------------------------------------------------------------------------
+152
View File
@@ -0,0 +1,152 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Component prefix
|--------------------------------------------------------------------------
|
| Every component is an anonymous Blade component. Without a prefix they are
| <x-button>, <x-card> and so on; set a prefix such as 'm' when a name
| clashes with one of the application's own components, and they become
| <x-m::button>, <x-m::card>. They are always <x-livewire-material::button>
| as well.
|
*/
'prefix' => '',
/*
|--------------------------------------------------------------------------
| Theme
|--------------------------------------------------------------------------
|
| The head script decides the theme before the first paint and writes it to
| <html data-theme>. 'default' is used until the visitor chooses: 'light',
| 'dark' or 'system' (follow the operating system). The choice is kept in
| localStorage under 'storage_key'; values found under 'legacy_keys' (an
| earlier theme toggle's key) are adopted once and then removed.
|
*/
'theme' => [
'default' => 'system',
'storage_key' => 'sealshare-theme',
'legacy_keys' => ['mary-theme'],
],
/*
|--------------------------------------------------------------------------
| Navigation rail
|--------------------------------------------------------------------------
|
| Whether a collapsible navigation rail starts 'expanded' or 'collapsed'
| until the visitor toggles it. The head script applies the choice before
| the first paint, from localStorage under 'storage_key'.
|
*/
'rail' => [
'default' => 'expanded',
'storage_key' => 'material-rail',
],
/*
|--------------------------------------------------------------------------
| Fields
|--------------------------------------------------------------------------
|
| Text fields, selects and pickers come in M3's two styles: 'outlined' (a
| notched outline) and 'filled' (a tinted box with an indicator line). This
| is the style a field takes when its `variant` is not given.
|
*/
'fields' => [
'variant' => 'outlined',
],
/*
|--------------------------------------------------------------------------
| Pagination
|--------------------------------------------------------------------------
|
| Draw Laravel's and Livewire's paginators in M3: the package's views are
| put in front of `pagination::tailwind` and `livewire::tailwind` (and
| their simple versions). An application's own published pagination views
| still win.
|
*/
'pagination' => true,
/*
|--------------------------------------------------------------------------
| Node
|--------------------------------------------------------------------------
|
| `php artisan material:scheme` runs Google's colour utilities through Node.
| Set the binary when `node` is not on the PATH of the user running Artisan.
|
*/
'node' => env('MATERIAL_NODE', 'node'),
/*
|--------------------------------------------------------------------------
| Scheme data
|--------------------------------------------------------------------------
|
| The light and dark hexes `php artisan material:scheme` writes beside the
| stylesheet. The mail theme reads its colours here, and so does an error
| page when the build is missing; without the file both use the package's
| default scheme.
|
*/
'scheme' => resource_path('css/material-scheme.json'),
/*
|--------------------------------------------------------------------------
| Mail
|--------------------------------------------------------------------------
|
| Markdown mail takes the theme when `mail.markdown.theme` (MAIL_MARKDOWN_THEME)
| is 'livewire-material::mail.theme'. 'components' puts this package's mail
| header and message after the application's own mail components or
| publish them with `vendor:publish --tag=livewire-material-mail` instead.
| 'logo' replaces the app name in that header with an image: an absolute
| 'src', with 'width' and 'height' in pixels, which Outlook sizes it by.
|
*/
'mail' => [
'components' => (bool) env('MATERIAL_MAIL_COMPONENTS', false),
'logo' => [
'src' => null,
'width' => null,
'height' => null,
],
],
/*
|--------------------------------------------------------------------------
| Showcase
|--------------------------------------------------------------------------
|
| Every component in every variant, rendered in the application's own
| scheme. Off unless the application runs locally. 'vite' names the entry
| points that import this package's CSS and JavaScript; the error pages
| load them too, showcase or not.
|
*/
'showcase' => [
'enabled' => (bool) env('MATERIAL_SHOWCASE', env('APP_ENV', 'production') === 'local'),
'path' => 'material',
'middleware' => ['web'],
'vite' => ['resources/css/app.css', 'resources/js/app.js'],
],
];
+5
View File
@@ -13,6 +13,11 @@ max_input_time = ${PHP_MAX_INPUT_TIME:-300}
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
EOF
if [ ! -f vendor/autoload.php ]; then
echo "[dev] Installing PHP dependencies..."
composer install --no-interaction 2>&1
fi
echo "[dev] Installing Node dependencies..."
npm install 2>&1
+3
View File
@@ -8,6 +8,9 @@ RUN install-php-extensions \
# Install Node.js for Vite / frontend asset building
RUN apk add --no-cache nodejs npm
# Composer, for a checkout without vendor/: the assets import Livewire Material from it
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
+2 -2
View File
@@ -74,8 +74,8 @@ The package's decisions are in its own plan. SealShare's:
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
`material`, released as **2.0.0**.
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
- **Seed `#4f46e5` (the favicon's indigo), Tonal Spot**; Vibrant generated alongside on the
upload page for one visual comparison before committing.
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
Appearance is a Light / Dark / System connected button group.
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
+31 -36
View File
@@ -6,16 +6,15 @@
"": {
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"alpinejs": "^3.17.2",
"autoprefixer": "^10.5.5",
"concurrently": "^10.0.5",
"daisyui": "^5.7.32",
"laravel-vite-plugin": "^3.2.0",
"tailwindcss": "^4.3.3",
"vite": "^8.2.2"
},
"devDependencies": {
"chokidar": "^5.0.0"
"chokidar": "^5.0.0",
"playwright": "^1.63.0"
},
"optionalDependencies": {
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
@@ -609,30 +608,6 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
"integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.42"
}
},
"node_modules/@vue/shared": {
"version": "3.5.42",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
"license": "MIT"
},
"node_modules/alpinejs": {
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.17.2.tgz",
"integrity": "sha512-xbnG3LlnmFkiNO49C+qhqjEDqOdB0snKqvT48ZVp8TakpZOpsoQbc/jAxpptQRjZi7c9rLeCfvwQ/XOSirIZFQ==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "~3.5.40"
}
},
"node_modules/ansi-regex": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
@@ -824,15 +799,6 @@
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/daisyui": {
"version": "5.7.32",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.32.tgz",
"integrity": "sha512-8JQFneb+okHxzPimTGvRKX+5XF03GyAwGw7BaV6+6XpzW0klGuzc4rdfUpLQxIYPKEziwwlrsYfcxoltF2Rowg==",
"license": "MIT",
"funding": {
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1320,6 +1286,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+2 -3
View File
@@ -8,10 +8,8 @@
},
"dependencies": {
"@tailwindcss/vite": "^4.3.3",
"alpinejs": "^3.17.2",
"autoprefixer": "^10.5.5",
"concurrently": "^10.0.5",
"daisyui": "^5.7.32",
"laravel-vite-plugin": "^3.2.0",
"tailwindcss": "^4.3.3",
"vite": "^8.2.2"
@@ -24,6 +22,7 @@
"shell-quote": "^1.9.0"
},
"devDependencies": {
"chokidar": "^5.0.0"
"chokidar": "^5.0.0",
"playwright": "^1.63.0"
}
}
+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>
-4
View File
@@ -32,8 +32,4 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
Route::livewire('settings', AdminSettings::class)->name('admin.settings');
});
Route::view('dashboard', 'dashboard')
->middleware(['auth', 'verified'])
->name('dashboard');
require __DIR__.'/settings.php';
+19 -1
View File
@@ -54,7 +54,9 @@ test('admin can delete share', function () {
Livewire::actingAs($admin)
->test(AdminDashboard::class)
->call('deleteShare', $shareId);
->set('deletingShareId', $shareId)
->call('deleteShare', $shareId)
->assertSet('deletingShareId', null);
expect(Share::query()->find($shareId))->toBeNull();
});
@@ -69,3 +71,19 @@ test('admin dashboard shows shares table', function () {
$response->assertOk();
$response->assertSee('testtoken12345678');
});
test('the shares table sorts only by its own columns', function () {
$admin = User::query()->where('is_admin', true)->first();
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
Livewire::actingAs($admin)
->test(AdminDashboard::class)
->set('sortBy', ['column' => 'download_count', 'direction' => 'asc'])
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
->set('sortBy', ['column' => 'token; drop table shares', 'direction' => 'sideways'])
->assertOk();
expect(Share::query()->count())->toBe(2);
});
+27 -2
View File
@@ -3,7 +3,9 @@
use App\Livewire\Admin\AdminSettings;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
test('admin settings requires authentication', function () {
@@ -41,7 +43,8 @@ test('admin can save settings', function () {
->set('maxSizePerShare', 5)
->set('defaultExpiration', '7d')
->call('saveSettings')
->assertHasNoErrors();
->assertHasNoErrors()
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
@@ -73,8 +76,11 @@ test('admin can clear system password', function () {
Livewire::actingAs($admin)
->test(AdminSettings::class)
->set('confirmingPasswordRemoval', true)
->call('clearSystemPassword')
->assertHasNoErrors();
->assertHasNoErrors()
->assertSet('confirmingPasswordRemoval', false)
->assertDispatched('toast', type: 'success', title: 'System password cleared.');
expect(Setting::get('system_password'))->toBeNull();
});
@@ -103,3 +109,22 @@ test('settings validation rejects invalid values', function () {
->call('saveSettings')
->assertHasErrors(['maxFileSize', 'maxStorageQuota']);
});
test('admin can remove the logo through its dialog', function () {
Storage::fake('public');
$admin = User::query()->where('is_admin', true)->first();
$path = UploadedFile::fake()->image('logo.png')->store('branding', 'public');
Setting::set('site_logo', $path);
Livewire::actingAs($admin)
->test(AdminSettings::class)
->assertSeeHtml('data-test="remove-logo"')
->set('confirmingLogoRemoval', true)
->call('removeLogo')
->assertSet('confirmingLogoRemoval', false)
->assertDispatched('toast', type: 'success', title: 'Logo removed.');
expect(Setting::get('site_logo'))->toBeNull();
Storage::disk('public')->assertMissing($path);
});
+1 -1
View File
@@ -19,7 +19,7 @@ test('users can authenticate using the login screen', function () {
$response
->assertSessionHasNoErrors()
->assertRedirect(route('dashboard', absolute: false));
->assertRedirect(route('admin.dashboard', absolute: false));
$this->assertAuthenticated();
});
+2 -2
View File
@@ -29,7 +29,7 @@ test('email can be verified', function () {
Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
$response->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
});
test('email is not verified with invalid hash', function () {
@@ -60,7 +60,7 @@ test('already verified user visiting verification link is redirected without fir
);
$this->actingAs($user)->get($verificationUrl)
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
Event::assertNotDispatched(Verified::class);
+10 -8
View File
@@ -2,15 +2,17 @@
use App\Models\User;
test('guests are redirected to the login page', function () {
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
test('the starter placeholder dashboard is gone', function () {
$this->actingAs(User::factory()->admin()->create())
->get('/dashboard')
->assertNotFound();
});
test('authenticated users can visit the dashboard', function () {
$user = User::factory()->create();
$this->actingAs($user);
test('a signed-in admin lands on the admin dashboard', function () {
$admin = User::factory()->admin()->create();
$response = $this->get(route('dashboard'));
$response->assertOk();
$this->post(route('login.store'), ['email' => $admin->email, 'password' => 'password'])
->assertRedirect(route('admin.dashboard', absolute: false));
$this->get(route('admin.dashboard'))->assertOk();
});
+19
View File
@@ -0,0 +1,19 @@
<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
test('views and code use only what the design system compiles', function () {
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])->violations())->toBe([]);
});
test('nothing of maryUI or daisyUI is left behind', function () {
$leftovers = collect(['resources/views', 'resources/js', 'resources/css', 'app'])
->flatMap(fn (string $path) => File::allFiles(base_path($path)))
->filter(fn (SplFileInfo $file): bool => preg_match('/\b(base-content|bg-base-\d|btn(-[a-z]+)?|x-mary-|daisyui|robsontenorio)\b/', $file->getContents()) === 1)
->map(fn (SplFileInfo $file): string => str_replace(base_path().'/', '', $file->getPathname()))
->values()
->all();
expect($leftovers)->toBe([]);
});
@@ -17,7 +17,8 @@ test('password can be updated', function () {
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasNoErrors();
$response->assertHasNoErrors()
->assertDispatched('toast', type: 'success', title: 'Saved.');
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
+3 -1
View File
@@ -19,7 +19,9 @@ test('profile information can be updated', function () {
->set('email', 'test@example.com')
->call('updateProfileInformation');
$response->assertHasNoErrors();
$response->assertHasNoErrors()
->assertDispatched('profile-updated')
->assertDispatched('toast', type: 'success', title: 'Saved.');
$user->refresh();