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:
co-authored by
Claude Opus 5
parent
6d7120e8e2
commit
c4a17b65c8
@@ -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.
|
- 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.
|
- 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 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Testing Validation
|
||||||
|
|
||||||
- Write one test for each validation rule when each failure represents a separate contract.
|
- Write one test for each validation rule when each failure represents a separate contract.
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ jobs:
|
|||||||
- name: Install Node Dependencies
|
- name: Install Node Dependencies
|
||||||
run: npm ci
|
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
|
- name: Install Dependencies
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
php-version: '8.5'
|
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
|
- name: Install Dependencies
|
||||||
run: |
|
run: |
|
||||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
|||||||
@@ -42,9 +42,6 @@ jobs:
|
|||||||
- name: Install Node Dependencies
|
- name: Install Node Dependencies
|
||||||
run: npm i
|
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
|
- name: Install Dependencies
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
@@ -57,5 +54,8 @@ jobs:
|
|||||||
- name: Build Assets
|
- name: Build Assets
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install Playwright Browsers
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: ./vendor/bin/pest
|
run: ./vendor/bin/pest
|
||||||
|
|||||||
+18
-16
@@ -1,20 +1,5 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
# Stage 1: Build frontend assets
|
# Stage 1: Install PHP dependencies
|
||||||
# ============================================
|
|
||||||
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
|
|
||||||
# ============================================
|
# ============================================
|
||||||
FROM composer:2 AS vendor
|
FROM composer:2 AS vendor
|
||||||
|
|
||||||
@@ -33,6 +18,23 @@ COPY . .
|
|||||||
|
|
||||||
RUN composer dump-autoload --optimize --no-dev
|
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)
|
# Stage 3: Production image (FrankenPHP/Octane)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
@@ -14,37 +14,38 @@ class AdminDashboard extends Component
|
|||||||
{
|
{
|
||||||
use WithPagination;
|
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'];
|
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
|
public function deleteShare(int $shareId, ShareService $shareService): void
|
||||||
{
|
{
|
||||||
$share = Share::query()->findOrFail($shareId);
|
$share = Share::query()->findOrFail($shareId);
|
||||||
$shareService->deleteShare($share);
|
$shareService->deleteShare($share);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
$this->deletingShareId = null;
|
||||||
* @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')],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
{
|
{
|
||||||
$shareService = app(ShareService::class);
|
$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()
|
$shares = Share::query()
|
||||||
->withCount('files')
|
->withCount('files')
|
||||||
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
|
->orderBy($column, $direction)
|
||||||
->paginate(15);
|
->paginate(15);
|
||||||
|
|
||||||
return view('livewire.admin.admin-dashboard', [
|
return view('livewire.admin.admin-dashboard', [
|
||||||
@@ -56,7 +57,6 @@ class AdminDashboard extends Component
|
|||||||
'totalFiles' => ShareFile::query()->count(),
|
'totalFiles' => ShareFile::query()->count(),
|
||||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||||
'headers' => $this->headers(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
|
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||||
|
|
||||||
#[Layout('layouts.app')]
|
#[Layout('layouts.app')]
|
||||||
class AdminSettings extends Component
|
class AdminSettings extends Component
|
||||||
{
|
{
|
||||||
|
use Toasts;
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public string $systemPassword = '';
|
public string $systemPassword = '';
|
||||||
@@ -34,6 +36,12 @@ class AdminSettings extends Component
|
|||||||
|
|
||||||
public $siteLogo;
|
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
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||||
@@ -113,7 +121,7 @@ class AdminSettings extends Component
|
|||||||
|
|
||||||
$this->systemPassword = '';
|
$this->systemPassword = '';
|
||||||
|
|
||||||
session()->flash('message', __('Settings saved successfully.'));
|
$this->success(__('Settings saved successfully.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeLogo(): void
|
public function removeLogo(): void
|
||||||
@@ -125,14 +133,18 @@ class AdminSettings extends Component
|
|||||||
Setting::set('site_logo', null);
|
Setting::set('site_logo', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('message', __('Logo removed.'));
|
$this->confirmingLogoRemoval = false;
|
||||||
|
|
||||||
|
$this->success(__('Logo removed.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clearSystemPassword(): void
|
public function clearSystemPassword(): void
|
||||||
{
|
{
|
||||||
Setting::set('system_password', null);
|
Setting::set('system_password', null);
|
||||||
|
|
||||||
session()->flash('message', __('System password cleared.'));
|
$this->confirmingPasswordRemoval = false;
|
||||||
|
|
||||||
|
$this->success(__('System password cleared.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): mixed
|
public function render(): mixed
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
|
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
|
||||||
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
|
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
|
||||||
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
|
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
|
||||||
Fortify::registerView(fn () => view('pages::auth.register'));
|
|
||||||
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
|
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
|
||||||
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
|
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-2
@@ -15,7 +15,7 @@
|
|||||||
"laravel/octane": "^2.13",
|
"laravel/octane": "^2.13",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "^4.0",
|
"livewire/livewire": "^4.0",
|
||||||
"robsontenorio/mary": "^2.7"
|
"nonameweb/livewire-material": "^1.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"pestphp/pest": "^5.1",
|
"pestphp/pest": "^5.1",
|
||||||
|
"pestphp/pest-plugin-browser": "^5.0",
|
||||||
"pestphp/pest-plugin-laravel": "^5.0"
|
"pestphp/pest-plugin-laravel": "^5.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -99,5 +100,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -73,7 +73,7 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'home' => '/dashboard',
|
'home' => '/admin/dashboard',
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -13,6 +13,11 @@ max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
|||||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
if [ ! -f vendor/autoload.php ]; then
|
||||||
|
echo "[dev] Installing PHP dependencies..."
|
||||||
|
composer install --no-interaction 2>&1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[dev] Installing Node dependencies..."
|
echo "[dev] Installing Node dependencies..."
|
||||||
npm install 2>&1
|
npm install 2>&1
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ RUN install-php-extensions \
|
|||||||
# Install Node.js for Vite / frontend asset building
|
# Install Node.js for Vite / frontend asset building
|
||||||
RUN apk add --no-cache nodejs npm
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
|
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
|
||||||
|
|||||||
@@ -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
|
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
||||||
`material`, released as **2.0.0**.
|
`material`, released as **2.0.0**.
|
||||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
- **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
|
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
||||||
upload page for one visual comparison before committing.
|
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.
|
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
||||||
Appearance is a Light / Dark / System connected button group.
|
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
|
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
||||||
|
|||||||
Generated
+31
-36
@@ -6,16 +6,15 @@
|
|||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"alpinejs": "^3.17.2",
|
|
||||||
"autoprefixer": "^10.5.5",
|
"autoprefixer": "^10.5.5",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"daisyui": "^5.7.32",
|
|
||||||
"laravel-vite-plugin": "^3.2.0",
|
"laravel-vite-plugin": "^3.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chokidar": "^5.0.0"
|
"chokidar": "^5.0.0",
|
||||||
|
"playwright": "^1.63.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||||
@@ -609,30 +608,6 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.3.0",
|
"version": "6.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
"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"
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1320,6 +1286,35 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
|
|||||||
+2
-3
@@ -8,10 +8,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"alpinejs": "^3.17.2",
|
|
||||||
"autoprefixer": "^10.5.5",
|
"autoprefixer": "^10.5.5",
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
"daisyui": "^5.7.32",
|
|
||||||
"laravel-vite-plugin": "^3.2.0",
|
"laravel-vite-plugin": "^3.2.0",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"vite": "^8.2.2"
|
"vite": "^8.2.2"
|
||||||
@@ -24,6 +22,7 @@
|
|||||||
"shell-quote": "^1.9.0"
|
"shell-quote": "^1.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chokidar": "^5.0.0"
|
"chokidar": "^5.0.0",
|
||||||
|
"playwright": "^1.63.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-5
@@ -1,10 +1,22 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||||
|
@import './material-scheme.css';
|
||||||
|
|
||||||
@source '../views';
|
@source '../views';
|
||||||
@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php';
|
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
@source '../../vendor/nonameweb/livewire-material/src';
|
||||||
@source inline("swap swap-rotate swap-on swap-off theme-controller");
|
|
||||||
|
|
||||||
@plugin "daisyui" {
|
/* share-created: the check on its shape settles in once the link is ready. */
|
||||||
themes: light --default, dark --prefersdark;
|
@keyframes share-ready {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
rotate: -90deg;
|
||||||
|
scale: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
rotate: 0deg;
|
||||||
|
scale: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,2 +1,2 @@
|
|||||||
// Alpine.js is bundled and started automatically by Livewire 4.
|
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
||||||
// Do not import it here to avoid "multiple instances of Alpine" errors.
|
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">
|
<div class="flex w-full flex-col text-center">
|
||||||
<h2 class="text-xl font-bold">{{ $title }}</h2>
|
<h1 class="type-headline-sm">{{ $title }}</h1>
|
||||||
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
|
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,5 @@
|
|||||||
])
|
])
|
||||||
|
|
||||||
@if ($status)
|
@if ($status)
|
||||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
<x-alert color="success" {{ $attributes }}>{{ $status }}</x-alert>
|
||||||
{{ $status }}
|
|
||||||
</div>
|
|
||||||
@endif
|
@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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
<x-layouts::app.sidebar :title="$title ?? null">
|
<!DOCTYPE html>
|
||||||
{{ $slot }}
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
</x-layouts::app.sidebar>
|
<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>
|
|
||||||
@@ -1,3 +1,19 @@
|
|||||||
<x-layouts::auth.simple :title="$title ?? null">
|
<!DOCTYPE html>
|
||||||
{{ $slot }}
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||||
</x-layouts::auth.simple>
|
<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>
|
<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="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
||||||
<div class="card bg-base-100 shadow-sm">
|
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
||||||
<div class="card-body p-4">
|
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
||||||
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
|
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
|
||||||
<p class="text-2xl font-bold">{{ $totalShares }}</p>
|
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" class="mt-2" :label="__('Disk Usage')" />
|
||||||
</div>
|
</x-stat>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{{-- Shares Table --}}
|
<x-card :title="__('All Shares')" variant="outlined">
|
||||||
<x-card title="{{ __('All Shares') }}" shadow>
|
<div class="-mx-4 overflow-x-auto">
|
||||||
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
|
<x-table>
|
||||||
@scope('cell_total_size', $share)
|
<thead>
|
||||||
{{ Number::fileSize($share->total_size) }}
|
<tr>
|
||||||
@endscope
|
<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)
|
<div class="mt-4">{{ $shares->links() }}</div>
|
||||||
@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>
|
|
||||||
</x-card>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,100 +1,66 @@
|
|||||||
<div class="max-w-2xl mx-auto">
|
<div class="mx-auto max-w-2xl">
|
||||||
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
|
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
||||||
|
|
||||||
@if (session('message'))
|
<form wire:submit="saveSettings" class="grid gap-6">
|
||||||
<div class="alert alert-success mb-6">
|
<x-card :title="__('Branding')" variant="outlined">
|
||||||
<x-icon name="o-check-circle" class="w-5 h-5" />
|
<div class="grid gap-5">
|
||||||
<span>{{ session('message') }}</span>
|
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<form wire:submit="saveSettings">
|
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<div class="grid gap-3">
|
||||||
@if ($currentLogo)
|
@if ($currentLogo)
|
||||||
<div class="flex items-center gap-4 mb-3">
|
<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" />
|
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
|
||||||
<x-button
|
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||||||
label="{{ __('Remove Logo') }}"
|
|
||||||
class="btn-sm btn-ghost text-error"
|
|
||||||
wire:click="removeLogo"
|
|
||||||
wire:confirm="{{ __('Remove the logo?') }}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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))
|
@if ($siteLogo && is_object($siteLogo))
|
||||||
<div class="mt-2">
|
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
<p class="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||||
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
@else
|
||||||
@else
|
<div>
|
||||||
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
|
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
|
||||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
|
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
|
||||||
@endif
|
</div>
|
||||||
</div>
|
@endif
|
||||||
@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>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
|
<x-card :title="__('Upload Protection')" variant="outlined">
|
||||||
<div class="space-y-4">
|
<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
|
<x-toggle
|
||||||
wire:model.live="allowNeverExpire"
|
wire:model.live="allowNeverExpire"
|
||||||
label="{{ __('Allow shares to never expire') }}"
|
:label="__('Allow shares to never expire')"
|
||||||
hint="{{ __('When disabled, users must select an expiration time.') }}"
|
:hint="__('When disabled, users must select an expiration time.')"
|
||||||
|
right
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-select
|
<x-select
|
||||||
wire:model="defaultExpiration"
|
wire:model="defaultExpiration"
|
||||||
label="{{ __('Default Expiration') }}"
|
:label="__('Default Expiration')"
|
||||||
:placeholder="$allowNeverExpire ? __('None') : null"
|
:placeholder="$allowNeverExpire ? __('None') : null"
|
||||||
:options="[
|
:options="[
|
||||||
['id' => '1h', 'name' => __('1 Hour')],
|
['id' => '1h', 'name' => __('1 Hour')],
|
||||||
@@ -108,44 +74,49 @@
|
|||||||
|
|
||||||
<x-input
|
<x-input
|
||||||
wire:model="maxFileSize"
|
wire:model="maxFileSize"
|
||||||
label="{{ __('Max file size (MB)') }}"
|
:label="__('Max file size (MB)')"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="{{ $phpMaxUploadMb }}"
|
:max="$phpMaxUploadMb"
|
||||||
suffix="MB"
|
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
|
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||||
wire:model="maxFilesPerShare"
|
|
||||||
label="{{ __('Max files per share') }}"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-input
|
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||||||
wire:model="maxSizePerShare"
|
|
||||||
label="{{ __('Max total size per share (GB)') }}"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
suffix="GB"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
|
<x-card :title="__('Storage')" variant="outlined">
|
||||||
<div class="space-y-4">
|
<x-input
|
||||||
<x-input
|
wire:model="maxStorageQuota"
|
||||||
wire:model="maxStorageQuota"
|
:label="__('Max storage quota (GB)')"
|
||||||
label="{{ __('Max storage quota (GB)') }}"
|
type="number"
|
||||||
type="number"
|
min="1"
|
||||||
min="1"
|
suffix="GB"
|
||||||
suffix="GB"
|
:hint="__('When reached, new uploads are blocked.')"
|
||||||
hint="{{ __('When reached, new uploads are blocked.') }}"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</x-card>
|
</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>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
<div class="max-w-3xl mx-auto">
|
<div class="mx-auto max-w-3xl">
|
||||||
<div class="text-center mb-6">
|
<div class="mb-8 text-center">
|
||||||
@if ($siteLogo)
|
@if ($siteLogo)
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto 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" />
|
||||||
@else
|
|
||||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|
||||||
@if ($isStorageFull)
|
@if ($isStorageFull)
|
||||||
<div class="alert alert-warning mb-6">
|
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
||||||
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
|
|
||||||
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
|
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
<form
|
<form
|
||||||
wire:submit="createShare"
|
wire:submit="createShare"
|
||||||
@@ -88,97 +83,98 @@
|
|||||||
x-on:livewire-upload-error="resetUpload()"
|
x-on:livewire-upload-error="resetUpload()"
|
||||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
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
|
<div
|
||||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
|
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"
|
||||||
:class="{
|
x-bind:class="{
|
||||||
'border-primary bg-primary/5': dragging,
|
'border-primary bg-primary-container/40': dragging,
|
||||||
'border-base-300 hover:border-primary/50': !dragging,
|
'border-outline-variant': ! dragging,
|
||||||
'opacity-50 pointer-events-none': uploading
|
'pointer-events-none opacity-60': uploading,
|
||||||
}"
|
}"
|
||||||
@dragover.prevent="dragging = true"
|
x-on:dragover.prevent="dragging = true"
|
||||||
@dragleave.prevent="dragging = false"
|
x-on:dragleave.prevent="dragging = false"
|
||||||
@drop.prevent="handleDrop($event)"
|
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" />
|
<div class="relative mx-auto mb-4 grid size-28 place-items-center">
|
||||||
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
|
<span
|
||||||
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
|
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') }}
|
{{ __('Browse Files') }}
|
||||||
<input
|
<input type="file" wire:model="files" multiple class="sr-only" x-bind:disabled="uploading" />
|
||||||
type="file"
|
|
||||||
wire:model="files"
|
|
||||||
multiple
|
|
||||||
class="hidden"
|
|
||||||
:disabled="uploading"
|
|
||||||
/>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Upload Progress --}}
|
{{-- Upload progress --}}
|
||||||
<div x-show="uploading" x-cloak class="mb-6">
|
<div x-show="uploading" x-cloak class="mb-6" data-test="upload-progress">
|
||||||
<template x-if="progress < 100">
|
<div x-show="progress < 100">
|
||||||
<div>
|
<div class="mb-2 flex items-center justify-between">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<span class="type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||||
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
<x-button :label="__('Cancel')" size="xs" x-on:click="$wire.cancelUpload('files')" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
||||||
<template x-if="progress >= 100">
|
</div>
|
||||||
<div class="flex items-center gap-3 text-sm font-medium">
|
<div x-show="progress >= 100" class="flex items-center gap-3 type-label-lg">
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<x-loading class="size-8" :label="false" />
|
||||||
{{ __('Processing files...') }}
|
{{ __('Processing files...') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Errors --}}
|
|
||||||
@error('files')
|
@error('files')
|
||||||
<div class="alert alert-error mb-4">{{ $message }}</div>
|
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
{{-- File List --}}
|
{{-- Selected files --}}
|
||||||
@if (count($files))
|
@if (count($files))
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
|
<h2 class="mb-2 type-title-md">{{ __('Selected Files') }} ({{ count($files) }})</h2>
|
||||||
<div class="space-y-1 max-h-60 overflow-y-auto">
|
<div class="max-h-72 overflow-y-auto">
|
||||||
@foreach ($files as $index => $file)
|
<x-list segmented :label="__('Selected Files')">
|
||||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
@foreach ($files as $index => $file)
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<x-list-item
|
||||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
|
||||||
<span class="truncate">
|
:description="Number::fileSize($file->getSize())"
|
||||||
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
|
icon="description"
|
||||||
</span>
|
wire:key="selected-file-{{ $index }}"
|
||||||
<span class="opacity-50 flex-shrink-0">
|
>
|
||||||
({{ Number::fileSize($file->getSize()) }})
|
<x-slot:end>
|
||||||
</span>
|
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
|
||||||
</div>
|
</x-slot:end>
|
||||||
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
|
</x-list-item>
|
||||||
<x-icon name="o-x-mark" class="w-4 h-4" />
|
@endforeach
|
||||||
</button>
|
</x-list>
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Options --}}
|
{{-- Options --}}
|
||||||
<x-card title="{{ __('Share Options') }}" class="mb-6" shadow>
|
<x-card :title="__('Share Options')" variant="outlined" class="mb-6">
|
||||||
<div class="space-y-4">
|
<div class="grid gap-5">
|
||||||
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
|
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
||||||
|
|
||||||
@if ($usePassword)
|
@if ($usePassword)
|
||||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-select
|
<x-select
|
||||||
wire:model="expiration"
|
wire:model="expiration"
|
||||||
label="{{ __('Expiration') }}"
|
:label="__('Expiration')"
|
||||||
:placeholder="$allowNeverExpire ? __('Never') : null"
|
:placeholder="$allowNeverExpire ? __('Never') : null"
|
||||||
:options="[
|
:options="[
|
||||||
['id' => '1h', 'name' => __('1 Hour')],
|
['id' => '1h', 'name' => __('1 Hour')],
|
||||||
@@ -192,22 +188,24 @@
|
|||||||
|
|
||||||
<x-input
|
<x-input
|
||||||
wire:model="maxDownloads"
|
wire:model="maxDownloads"
|
||||||
label="{{ __('Max downloads') }}"
|
:label="__('Max downloads')"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="{{ __('Unlimited') }}"
|
:placeholder="__('Unlimited')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|
||||||
{{-- Submit --}}
|
|
||||||
<x-button
|
<x-button
|
||||||
type="submit"
|
type="submit"
|
||||||
label="{{ __('Create Share Link') }}"
|
:label="__('Create Share Link')"
|
||||||
class="btn-primary w-full"
|
variant="filled"
|
||||||
icon="o-link"
|
size="md"
|
||||||
|
class="w-full"
|
||||||
|
icon="link"
|
||||||
spinner="createShare"
|
spinner="createShare"
|
||||||
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
||||||
|
data-test="create-share"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -4,37 +4,37 @@
|
|||||||
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
||||||
<x-input
|
<x-input
|
||||||
wire:model="name"
|
wire:model="name"
|
||||||
label="{{ __('Name') }}"
|
:label="__('Name')"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
placeholder="{{ __('Admin name') }}"
|
:placeholder="__('Admin name')"
|
||||||
icon="o-user"
|
icon="person"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-input
|
<x-input
|
||||||
wire:model="email"
|
wire:model="email"
|
||||||
label="{{ __('Email address') }}"
|
:label="__('Email address')"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
placeholder="admin@example.com"
|
placeholder="admin@example.com"
|
||||||
icon="o-envelope"
|
icon="mail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
label="{{ __('Password') }}"
|
:label="__('Password')"
|
||||||
required
|
required
|
||||||
placeholder="{{ __('Password') }}"
|
:placeholder="__('Password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password_confirmation"
|
wire:model="password_confirmation"
|
||||||
label="{{ __('Confirm password') }}"
|
:label="__('Confirm password')"
|
||||||
required
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,65 +1,38 @@
|
|||||||
<div class="max-w-lg mx-auto">
|
<div class="mx-auto max-w-lg">
|
||||||
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
|
<div class="mb-8 text-center">
|
||||||
<div class="space-y-4">
|
{{-- The link is ready: a check on an Expressive shape that settles in. --}}
|
||||||
{{-- Share URL --}}
|
<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]">
|
||||||
<div
|
<x-shape name="soft-burst" class="absolute inset-0 size-full text-primary-container" />
|
||||||
x-data="{
|
<x-icon name="check" class="relative size-12 text-on-primary-container" />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<x-slot:actions>
|
<h1 class="type-headline-md">{{ __('Share Created!') }}</h1>
|
||||||
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
|
<p class="mt-1 type-body-lg text-on-surface-variant">{{ __('Your files are ready to share') }}</p>
|
||||||
</x-slot:actions>
|
</div>
|
||||||
</x-card>
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -1,67 +1,61 @@
|
|||||||
<div class="w-full max-w-lg mx-auto">
|
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
|
||||||
<div class="text-center mb-6">
|
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)
|
@if ($siteLogo)
|
||||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto 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" />
|
||||||
@else
|
|
||||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|
||||||
@if (! $authenticated)
|
@if (! $authenticated)
|
||||||
{{-- Password form --}}
|
|
||||||
<form wire:submit="verifyPassword">
|
<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
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
label="{{ __('Password') }}"
|
:label="__('Password')"
|
||||||
required
|
required
|
||||||
placeholder="{{ __('Enter share password') }}"
|
autocomplete="off"
|
||||||
|
:placeholder="__('Enter share password')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-slot:actions>
|
<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-slot:actions>
|
||||||
</x-card>
|
</x-card>
|
||||||
</form>
|
</form>
|
||||||
@else
|
@else
|
||||||
{{-- File list --}}
|
<x-card :title="__('Shared Files')" variant="outlined">
|
||||||
<x-card title="{{ __('Shared Files') }}" shadow>
|
<x-list :label="__('Shared Files')">
|
||||||
<div class="space-y-2 mb-4">
|
|
||||||
@foreach ($share->files as $file)
|
@foreach ($share->files as $file)
|
||||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
<x-list-item
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
:title="$file->relative_path ?: $file->original_name"
|
||||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
:description="Number::fileSize($file->file_size)"
|
||||||
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
|
icon="description"
|
||||||
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
|
wire:key="file-{{ $file->id }}"
|
||||||
</div>
|
>
|
||||||
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
|
<x-slot:end>
|
||||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
||||||
</a>
|
</x-slot:end>
|
||||||
</div>
|
</x-list-item>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</x-list>
|
||||||
|
|
||||||
@if ($share->expires_at)
|
@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() }}
|
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
|
||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
@if ($share->files->count() > 1)
|
@if ($share->files->count() > 1)
|
||||||
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary w-full">
|
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
|
||||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
|
||||||
{{ __('Download All as ZIP') }}
|
|
||||||
</a>
|
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary w-full">
|
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
|
||||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
|
||||||
{{ __('Download') }}
|
|
||||||
</a>
|
|
||||||
@endif
|
@endif
|
||||||
</x-slot:actions>
|
</x-slot:actions>
|
||||||
</x-card>
|
</x-card>
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
<form wire:submit="verify" class="flex flex-col gap-6">
|
<form wire:submit="verify" class="flex flex-col gap-6">
|
||||||
<x-password
|
<x-password
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
label="{{ __('Password') }}"
|
:label="__('Password')"
|
||||||
required
|
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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
<x-layouts::auth>
|
<x-layouts::auth :title="__('Confirm password')">
|
||||||
<div class="flex flex-col gap-6">
|
<x-auth-header
|
||||||
<x-auth-header
|
:title="__('Confirm password')"
|
||||||
:title="__('Confirm password')"
|
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||||
: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')" />
|
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
|
||||||
|
</form>
|
||||||
<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-layouts::auth>
|
</x-layouts::auth>
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
<x-layouts::auth>
|
<x-layouts::auth :title="__('Forgot password')">
|
||||||
<div class="flex flex-col gap-6">
|
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
||||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
|
||||||
|
|
||||||
<!-- Session Status -->
|
<x-auth-session-status :status="session('status')" />
|
||||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
|
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<!-- Email Address -->
|
<x-input
|
||||||
<x-input
|
name="email"
|
||||||
name="email"
|
:label="__('Email Address')"
|
||||||
label="{{ __('Email Address') }}"
|
:value="old('email')"
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
placeholder="email@example.com"
|
placeholder="email@example.com"
|
||||||
icon="o-envelope"
|
icon="mail"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<x-button type="submit" label="{{ __('Email password reset link') }}" class="btn-primary w-full" data-test="email-password-reset-link-button" />
|
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
|
<p class="text-center type-body-md text-on-surface-variant">
|
||||||
<span>{{ __('Or, return to') }}</span>
|
{{ __('Or, return to') }}
|
||||||
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('log in') }}</a>
|
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
</x-layouts::auth>
|
</x-layouts::auth>
|
||||||
|
|||||||
@@ -1,59 +1,40 @@
|
|||||||
<x-layouts::auth>
|
<x-layouts::auth :title="__('Log in')">
|
||||||
<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-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
|
||||||
|
|
||||||
<!-- Session Status -->
|
<x-auth-session-status :status="session('status')" />
|
||||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
|
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
<!-- Email Address -->
|
<x-input
|
||||||
<x-input
|
name="email"
|
||||||
name="email"
|
:label="__('Email address')"
|
||||||
label="{{ __('Email address') }}"
|
:value="old('email')"
|
||||||
:value="old('email')"
|
type="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
|
required
|
||||||
autofocus
|
autocomplete="current-password"
|
||||||
autocomplete="email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
icon="o-envelope"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Password -->
|
@if (Route::has('password.request'))
|
||||||
<div class="relative">
|
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
||||||
<x-password
|
{{ __('Forgot your password?') }}
|
||||||
name="password"
|
</a>
|
||||||
label="{{ __('Password') }}"
|
@endif
|
||||||
required
|
</div>
|
||||||
autocomplete="current-password"
|
|
||||||
placeholder="{{ __('Password') }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@if (Route::has('password.request'))
|
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
||||||
<a class="absolute top-0 text-sm end-0 link link-primary" href="{{ route('password.request') }}" wire:navigate>
|
|
||||||
{{ __('Forgot your password?') }}
|
|
||||||
</a>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Remember Me -->
|
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
</form>
|
||||||
<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-layouts::auth>
|
</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>
|
<x-layouts::auth :title="__('Reset password')">
|
||||||
<div class="flex flex-col gap-6">
|
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
||||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
|
||||||
|
|
||||||
<!-- Session Status -->
|
<x-auth-session-status :status="session('status')" />
|
||||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
|
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
|
||||||
@csrf
|
@csrf
|
||||||
<!-- Token -->
|
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
|
||||||
|
|
||||||
<!-- Email Address -->
|
<x-input
|
||||||
<x-input
|
name="email"
|
||||||
name="email"
|
:value="old('email', request('email'))"
|
||||||
value="{{ request('email') }}"
|
:label="__('Email')"
|
||||||
label="{{ __('Email') }}"
|
type="email"
|
||||||
type="email"
|
required
|
||||||
required
|
autocomplete="email"
|
||||||
autocomplete="email"
|
icon="mail"
|
||||||
icon="o-envelope"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Password -->
|
<x-password
|
||||||
<x-password
|
name="password"
|
||||||
name="password"
|
:label="__('Password')"
|
||||||
label="{{ __('Password') }}"
|
required
|
||||||
required
|
autocomplete="new-password"
|
||||||
autocomplete="new-password"
|
/>
|
||||||
placeholder="{{ __('Password') }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Confirm Password -->
|
<x-password
|
||||||
<x-password
|
name="password_confirmation"
|
||||||
name="password_confirmation"
|
:label="__('Confirm password')"
|
||||||
label="{{ __('Confirm password') }}"
|
required
|
||||||
required
|
autocomplete="new-password"
|
||||||
autocomplete="new-password"
|
/>
|
||||||
placeholder="{{ __('Confirm password') }}"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end">
|
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
|
||||||
<x-button type="submit" label="{{ __('Reset password') }}" class="btn-primary w-full" data-test="reset-password-button" />
|
</form>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</x-layouts::auth>
|
</x-layouts::auth>
|
||||||
|
|||||||
@@ -1,90 +1,65 @@
|
|||||||
<x-layouts::auth>
|
<x-layouts::auth :title="__('Two-factor authentication')">
|
||||||
<div class="flex flex-col gap-6">
|
<div
|
||||||
<div
|
class="flex flex-col gap-6"
|
||||||
class="relative w-full h-auto"
|
x-data="{
|
||||||
x-cloak
|
showRecoveryInput: @js($errors->has('recovery_code')),
|
||||||
x-data="{
|
toggleInput() {
|
||||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
this.showRecoveryInput = ! this.showRecoveryInput;
|
||||||
code: '',
|
$nextTick(() => {
|
||||||
recovery_code: '',
|
requestAnimationFrame(() => {
|
||||||
toggleInput() {
|
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
||||||
this.showRecoveryInput = !this.showRecoveryInput;
|
|
||||||
this.code = '';
|
|
||||||
this.recovery_code = '';
|
|
||||||
$nextTick(() => {
|
|
||||||
this.showRecoveryInput
|
|
||||||
? this.$refs.recovery_code?.focus()
|
|
||||||
: this.$refs.code?.focus();
|
|
||||||
});
|
});
|
||||||
},
|
});
|
||||||
}"
|
},
|
||||||
>
|
}"
|
||||||
<div x-show="!showRecoveryInput">
|
>
|
||||||
<x-auth-header
|
<div x-show="! showRecoveryInput">
|
||||||
:title="__('Authentication Code')"
|
<x-auth-header
|
||||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
: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>
|
</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>
|
</div>
|
||||||
</x-layouts::auth>
|
</x-layouts::auth>
|
||||||
|
|||||||
@@ -1,25 +1,24 @@
|
|||||||
<x-layouts::auth>
|
<x-layouts::auth :title="__('Verify email')">
|
||||||
<div class="mt-4 flex flex-col gap-6">
|
<x-auth-header
|
||||||
<p class="text-center text-sm opacity-70">
|
:title="__('Verify your email')"
|
||||||
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
|
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
||||||
</p>
|
/>
|
||||||
|
|
||||||
@if (session('status') == 'verification-link-sent')
|
@if (session('status') == 'verification-link-sent')
|
||||||
<p class="text-center text-sm font-medium text-success">
|
<x-alert color="success">
|
||||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||||
</p>
|
</x-alert>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex flex-col items-center justify-between space-y-3">
|
<div class="flex flex-col items-stretch gap-3">
|
||||||
<form method="POST" action="{{ route('verification.send') }}">
|
<form method="POST" action="{{ route('verification.send') }}">
|
||||||
@csrf
|
@csrf
|
||||||
<x-button type="submit" label="{{ __('Resend verification email') }}" class="btn-primary w-full" />
|
<x-button type="submit" :label="__('Resend verification email')" variant="filled" class="w-full" />
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('logout') }}">
|
<form method="POST" action="{{ route('logout') }}" class="self-center">
|
||||||
@csrf
|
@csrf
|
||||||
<x-button type="submit" label="{{ __('Log out') }}" class="btn-ghost btn-sm" data-test="logout-button" />
|
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</x-layouts::auth>
|
</x-layouts::auth>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
<div class="flex items-start max-md:flex-col">
|
@php
|
||||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
$items = [
|
||||||
<x-menu class="!p-0">
|
['title' => __('Profile'), 'icon' => 'person', 'url' => route('profile.edit'), 'active' => request()->routeIs('profile.edit')],
|
||||||
<x-menu-item title="{{ __('Profile') }}" link="{{ route('profile.edit') }}" wire:navigate />
|
['title' => __('Password'), 'icon' => 'password', 'url' => route('user-password.edit'), 'active' => request()->routeIs('user-password.edit')],
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
||||||
<h2 class="text-lg font-semibold">{{ $heading ?? '' }}</h2>
|
@endphp
|
||||||
<p class="text-sm opacity-60">{{ $subheading ?? '' }}</p>
|
|
||||||
|
|
||||||
<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 }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,83 +45,48 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div
|
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
|
||||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-base-300"
|
<div class="grid gap-4">
|
||||||
wire:cloak
|
<div>
|
||||||
x-data="{ showRecoveryCodes: false }"
|
<div class="flex items-center gap-2">
|
||||||
>
|
<x-icon name="lock" class="size-5 text-on-surface-variant" />
|
||||||
<div class="px-6 space-y-2">
|
<h3 class="type-title-md">{{ __('2FA Recovery Codes') }}</h3>
|
||||||
<div class="flex items-center gap-2">
|
</div>
|
||||||
<x-icon name="o-lock-closed" class="w-4 h-4" />
|
<p class="mt-1 type-body-md text-on-surface-variant">
|
||||||
<h3 class="text-lg font-semibold">{{ __('2FA Recovery Codes') }}</h3>
|
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
||||||
|
</p>
|
||||||
</div>
|
</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-wrap items-center gap-2">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<span x-show="! showRecoveryCodes" class="inline-flex">
|
||||||
<x-button
|
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = true" />
|
||||||
x-show="!showRecoveryCodes"
|
</span>
|
||||||
icon="o-eye"
|
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
||||||
label="{{ __('View Recovery Codes') }}"
|
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = false" />
|
||||||
class="btn-primary btn-sm"
|
</span>
|
||||||
@click="showRecoveryCodes = true;"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-button
|
|
||||||
x-show="showRecoveryCodes"
|
|
||||||
icon="o-eye-slash"
|
|
||||||
label="{{ __('Hide Recovery Codes') }}"
|
|
||||||
class="btn-primary btn-sm"
|
|
||||||
@click="showRecoveryCodes = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
@if (filled($recoveryCodes))
|
||||||
<x-button
|
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
||||||
x-show="showRecoveryCodes"
|
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" wire:click="regenerateRecoveryCodes" />
|
||||||
icon="o-arrow-path"
|
</span>
|
||||||
label="{{ __('Regenerate Codes') }}"
|
|
||||||
class="btn-sm"
|
|
||||||
wire:click="regenerateRecoveryCodes"
|
|
||||||
/>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div x-show="showRecoveryCodes" x-cloak id="recovery-codes-section" class="grid gap-3">
|
||||||
x-show="showRecoveryCodes"
|
@error('recoveryCodes')
|
||||||
x-transition
|
<x-alert color="error">{{ $message }}</x-alert>
|
||||||
id="recovery-codes-section"
|
@enderror
|
||||||
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
|
|
||||||
|
|
||||||
@if (filled($recoveryCodes))
|
@if (filled($recoveryCodes))
|
||||||
<div
|
<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') }}">
|
||||||
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-base-200"
|
@foreach ($recoveryCodes as $code)
|
||||||
role="list"
|
<div role="listitem" class="select-text" wire:loading.class="animate-pulse opacity-50">{{ $code }}</div>
|
||||||
aria-label="{{ __('Recovery codes') }}"
|
@endforeach
|
||||||
>
|
</div>
|
||||||
@foreach($recoveryCodes as $code)
|
<p class="type-body-sm text-on-surface-variant">
|
||||||
<div
|
{{ __('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.') }}
|
||||||
role="listitem"
|
</p>
|
||||||
class="select-text"
|
@endif
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</x-card>
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ new class extends Component {
|
|||||||
@include('partials.settings-heading')
|
@include('partials.settings-heading')
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
<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>
|
</x-pages::settings.layout>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -26,31 +26,30 @@ new class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<section class="mt-10 space-y-6">
|
<section class="mt-12 grid gap-4">
|
||||||
<div class="relative mb-5">
|
<x-divider />
|
||||||
<h3 class="text-lg font-semibold">{{ __('Delete account') }}</h3>
|
|
||||||
<p class="text-sm opacity-60">{{ __('Delete your account and all of its resources') }}</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<x-button
|
<div>
|
||||||
label="{{ __('Delete account') }}"
|
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
|
||||||
class="btn-error"
|
</div>
|
||||||
@click="$wire.showDeleteModal = true"
|
|
||||||
data-test="delete-user-button"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<x-modal wire:model="showDeleteModal" title="{{ __('Are you sure you want to delete your account?') }}">
|
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
|
||||||
<p class="text-sm opacity-70">
|
<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.') }}
|
{{ __('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>
|
</p>
|
||||||
|
|
||||||
<form wire:submit="deleteUser" class="space-y-6 mt-4">
|
<form id="delete-user-form" wire:submit="deleteUser" class="mt-4">
|
||||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
<x-password wire:model="password" :label="__('Password')" autocomplete="current-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>
|
</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>
|
</x-modal>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ use Illuminate\Support\Facades\Auth;
|
|||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
use PasswordValidationRules;
|
use PasswordValidationRules;
|
||||||
|
use Toasts;
|
||||||
|
|
||||||
public string $current_password = '';
|
public string $current_password = '';
|
||||||
public string $password = '';
|
public string $password = '';
|
||||||
@@ -36,6 +38,8 @@ new class extends Component {
|
|||||||
$this->reset('current_password', 'password', 'password_confirmation');
|
$this->reset('current_password', 'password', 'password_confirmation');
|
||||||
|
|
||||||
$this->dispatch('password-updated');
|
$this->dispatch('password-updated');
|
||||||
|
|
||||||
|
$this->success(__('Saved.'));
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
@@ -43,34 +47,13 @@ new class extends Component {
|
|||||||
@include('partials.settings-heading')
|
@include('partials.settings-heading')
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
<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">
|
<form method="POST" wire:submit="updatePassword" class="grid gap-5">
|
||||||
<x-password
|
<x-password wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
||||||
wire:model="current_password"
|
<x-password wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
||||||
label="{{ __('Current password') }}"
|
<x-password wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-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>
|
||||||
<div class="flex items-center justify-end">
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</x-pages::settings.layout>
|
</x-pages::settings.layout>
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Session;
|
|||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Livewire\Attributes\Computed;
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
use ProfileValidationRules;
|
use ProfileValidationRules;
|
||||||
|
use Toasts;
|
||||||
|
|
||||||
public string $name = '';
|
public string $name = '';
|
||||||
public string $email = '';
|
public string $email = '';
|
||||||
@@ -42,6 +44,8 @@ new class extends Component {
|
|||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
$this->dispatch('profile-updated', name: $user->name);
|
$this->dispatch('profile-updated', name: $user->name);
|
||||||
|
|
||||||
|
$this->success(__('Saved.'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,7 +56,7 @@ new class extends Component {
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
if ($user->hasVerifiedEmail()) {
|
if ($user->hasVerifiedEmail()) {
|
||||||
$this->redirectIntended(default: route('dashboard', absolute: false));
|
$this->redirectIntended(default: route('admin.dashboard', absolute: false));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,39 +84,29 @@ new class extends Component {
|
|||||||
@include('partials.settings-heading')
|
@include('partials.settings-heading')
|
||||||
|
|
||||||
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
<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">
|
<form wire:submit="updateProfileInformation" class="grid w-full gap-5">
|
||||||
<x-input wire:model="name" label="{{ __('Name') }}" type="text" required autofocus autocomplete="name" icon="o-user" />
|
<x-input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
||||||
|
|
||||||
<div>
|
<div class="grid gap-3">
|
||||||
<x-input wire:model="email" label="{{ __('Email') }}" type="email" required autocomplete="email" icon="o-envelope" />
|
<x-input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
||||||
|
|
||||||
@if ($this->hasUnverifiedEmail)
|
@if ($this->hasUnverifiedEmail)
|
||||||
<div>
|
<p class="type-body-md text-on-surface-variant">
|
||||||
<p class="mt-4 text-sm opacity-70">
|
{{ __('Your email address is unverified.') }}
|
||||||
{{ __('Your email address is unverified.') }}
|
|
||||||
|
|
||||||
<a class="link link-primary text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
|
<button type="button" class="link" wire:click.prevent="resendVerificationNotification">
|
||||||
{{ __('Click here to re-send the verification email.') }}
|
{{ __('Click here to re-send the verification email.') }}
|
||||||
</a>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@if (session('status') === 'verification-link-sent')
|
@if (session('status') === 'verification-link-sent')
|
||||||
<p class="mt-2 text-sm font-medium text-success">
|
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
||||||
{{ __('A new verification link has been sent to your email address.') }}
|
@endif
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
<div class="flex items-center justify-end">
|
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||||
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -184,142 +184,81 @@ new class extends Component {
|
|||||||
:heading="__('Two Factor Authentication')"
|
:heading="__('Two Factor Authentication')"
|
||||||
:subheading="__('Manage your two-factor authentication settings')"
|
: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)
|
@if ($twoFactorEnabled)
|
||||||
<div class="space-y-4">
|
<div class="grid justify-items-start gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<x-badge :value="__('Enabled')" tonal color="success" />
|
||||||
<span class="badge badge-success">{{ __('Enabled') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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.') }}
|
{{ __('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>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
||||||
|
|
||||||
<div class="flex justify-start">
|
<div>
|
||||||
<x-button
|
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
||||||
label="{{ __('Disable 2FA') }}"
|
|
||||||
icon="o-shield-exclamation"
|
|
||||||
class="btn-error"
|
|
||||||
wire:click="disable"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="space-y-4">
|
<div class="grid justify-items-start gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<x-badge :value="__('Disabled')" tonal color="error" />
|
||||||
<span class="badge badge-error">{{ __('Disabled') }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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.') }}
|
{{ __('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>
|
</p>
|
||||||
|
|
||||||
<x-button
|
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
||||||
label="{{ __('Enable 2FA') }}"
|
|
||||||
icon="o-shield-check"
|
|
||||||
class="btn-primary"
|
|
||||||
wire:click="enable"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-pages::settings.layout>
|
</x-pages::settings.layout>
|
||||||
|
|
||||||
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" class="max-w-md">
|
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
||||||
<p class="text-sm opacity-70">{{ $this->modalConfig['description'] }}</p>
|
|
||||||
|
|
||||||
@if ($showVerificationStep)
|
@if ($showVerificationStep)
|
||||||
<div class="space-y-6 mt-4">
|
<div class="mt-2">
|
||||||
<div class="flex flex-col items-center space-y-3 justify-center">
|
<x-input
|
||||||
<x-input
|
name="code"
|
||||||
name="code"
|
wire:model="code"
|
||||||
wire:model="code"
|
:label="__('Code')"
|
||||||
placeholder="000000"
|
inputmode="numeric"
|
||||||
class="text-center text-2xl tracking-widest"
|
autocomplete="one-time-code"
|
||||||
maxlength="6"
|
maxlength="6"
|
||||||
/>
|
mono
|
||||||
</div>
|
autofocus
|
||||||
|
/>
|
||||||
<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>
|
</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
|
@else
|
||||||
@error('setupData')
|
@error('setupData')
|
||||||
<div class="alert alert-error mt-4">{{ $message }}</div>
|
<x-alert color="error" class="mt-2">{{ $message }}</x-alert>
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
<div class="flex justify-center mt-4">
|
<div class="mt-2 flex justify-center">
|
||||||
<div class="relative w-64 overflow-hidden border rounded-lg border-base-300 aspect-square">
|
{{-- 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)
|
@empty($qrCodeSvg)
|
||||||
<div class="absolute inset-0 flex items-center justify-center bg-base-200 animate-pulse">
|
<x-loading :label="__('Loading')" />
|
||||||
<span class="loading loading-spinner"></span>
|
|
||||||
</div>
|
|
||||||
@else
|
@else
|
||||||
<div class="flex items-center justify-center h-full p-4 bg-white">
|
{!! $qrCodeSvg !!}
|
||||||
{!! $qrCodeSvg !!}
|
|
||||||
</div>
|
|
||||||
@endempty
|
@endempty
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4 mt-4">
|
<div class="mt-6 grid gap-3">
|
||||||
<div class="relative flex items-center justify-center w-full">
|
<p class="text-center type-label-lg text-on-surface-variant">{{ __('or, enter the code manually') }}</p>
|
||||||
<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
|
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-slot:actions>
|
<x-slot:actions>
|
||||||
<x-button
|
<x-button
|
||||||
:disabled="$errors->has('setupData')"
|
:disabled="$errors->has('setupData')"
|
||||||
label="{{ $this->modalConfig['buttonText'] }}"
|
:label="$this->modalConfig['buttonText']"
|
||||||
class="btn-primary"
|
variant="filled"
|
||||||
wire:click="showVerificationIfNecessary"
|
wire:click="showVerificationIfNecessary"
|
||||||
/>
|
/>
|
||||||
</x-slot:actions>
|
</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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<meta charset="utf-8" />
|
<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>
|
<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="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<x-theme-script />
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
|
||||||
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<div class="relative mb-6 w-full">
|
<div class="mb-6 w-full">
|
||||||
<h1 class="text-2xl font-bold">{{ __('Settings') }}</h1>
|
<h1 class="type-headline-md">{{ __('Settings') }}</h1>
|
||||||
<p class="text-sm opacity-60 mb-6">{{ __('Manage your profile and account settings') }}</p>
|
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Manage your profile and account settings') }}</p>
|
||||||
<div class="divider my-0"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -32,8 +32,4 @@ Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
|
|||||||
Route::livewire('settings', AdminSettings::class)->name('admin.settings');
|
Route::livewire('settings', AdminSettings::class)->name('admin.settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::view('dashboard', 'dashboard')
|
|
||||||
->middleware(['auth', 'verified'])
|
|
||||||
->name('dashboard');
|
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ test('admin can delete share', function () {
|
|||||||
|
|
||||||
Livewire::actingAs($admin)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminDashboard::class)
|
->test(AdminDashboard::class)
|
||||||
->call('deleteShare', $shareId);
|
->set('deletingShareId', $shareId)
|
||||||
|
->call('deleteShare', $shareId)
|
||||||
|
->assertSet('deletingShareId', null);
|
||||||
|
|
||||||
expect(Share::query()->find($shareId))->toBeNull();
|
expect(Share::query()->find($shareId))->toBeNull();
|
||||||
});
|
});
|
||||||
@@ -69,3 +71,19 @@ test('admin dashboard shows shares table', function () {
|
|||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertSee('testtoken12345678');
|
$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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
use App\Livewire\Admin\AdminSettings;
|
use App\Livewire\Admin\AdminSettings;
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
test('admin settings requires authentication', function () {
|
test('admin settings requires authentication', function () {
|
||||||
@@ -41,7 +43,8 @@ test('admin can save settings', function () {
|
|||||||
->set('maxSizePerShare', 5)
|
->set('maxSizePerShare', 5)
|
||||||
->set('defaultExpiration', '7d')
|
->set('defaultExpiration', '7d')
|
||||||
->call('saveSettings')
|
->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_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
|
||||||
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 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)
|
Livewire::actingAs($admin)
|
||||||
->test(AdminSettings::class)
|
->test(AdminSettings::class)
|
||||||
|
->set('confirmingPasswordRemoval', true)
|
||||||
->call('clearSystemPassword')
|
->call('clearSystemPassword')
|
||||||
->assertHasNoErrors();
|
->assertHasNoErrors()
|
||||||
|
->assertSet('confirmingPasswordRemoval', false)
|
||||||
|
->assertDispatched('toast', type: 'success', title: 'System password cleared.');
|
||||||
|
|
||||||
expect(Setting::get('system_password'))->toBeNull();
|
expect(Setting::get('system_password'))->toBeNull();
|
||||||
});
|
});
|
||||||
@@ -103,3 +109,22 @@ test('settings validation rejects invalid values', function () {
|
|||||||
->call('saveSettings')
|
->call('saveSettings')
|
||||||
->assertHasErrors(['maxFileSize', 'maxStorageQuota']);
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test('users can authenticate using the login screen', function () {
|
|||||||
|
|
||||||
$response
|
$response
|
||||||
->assertSessionHasNoErrors()
|
->assertSessionHasNoErrors()
|
||||||
->assertRedirect(route('dashboard', absolute: false));
|
->assertRedirect(route('admin.dashboard', absolute: false));
|
||||||
|
|
||||||
$this->assertAuthenticated();
|
$this->assertAuthenticated();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test('email can be verified', function () {
|
|||||||
Event::assertDispatched(Verified::class);
|
Event::assertDispatched(Verified::class);
|
||||||
|
|
||||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
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 () {
|
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)
|
$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();
|
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||||
Event::assertNotDispatched(Verified::class);
|
Event::assertNotDispatched(Verified::class);
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
|
||||||
test('guests are redirected to the login page', function () {
|
test('the starter placeholder dashboard is gone', function () {
|
||||||
$response = $this->get(route('dashboard'));
|
$this->actingAs(User::factory()->admin()->create())
|
||||||
$response->assertRedirect(route('login'));
|
->get('/dashboard')
|
||||||
|
->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('authenticated users can visit the dashboard', function () {
|
test('a signed-in admin lands on the admin dashboard', function () {
|
||||||
$user = User::factory()->create();
|
$admin = User::factory()->admin()->create();
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->get(route('dashboard'));
|
$this->post(route('login.store'), ['email' => $admin->email, 'password' => 'password'])
|
||||||
$response->assertOk();
|
->assertRedirect(route('admin.dashboard', absolute: false));
|
||||||
|
|
||||||
|
$this->get(route('admin.dashboard'))->assertOk();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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')
|
->set('password_confirmation', 'new-password')
|
||||||
->call('updatePassword');
|
->call('updatePassword');
|
||||||
|
|
||||||
$response->assertHasNoErrors();
|
$response->assertHasNoErrors()
|
||||||
|
->assertDispatched('toast', type: 'success', title: 'Saved.');
|
||||||
|
|
||||||
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
|
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ test('profile information can be updated', function () {
|
|||||||
->set('email', 'test@example.com')
|
->set('email', 'test@example.com')
|
||||||
->call('updateProfileInformation');
|
->call('updateProfileInformation');
|
||||||
|
|
||||||
$response->assertHasNoErrors();
|
$response->assertHasNoErrors()
|
||||||
|
->assertDispatched('profile-updated')
|
||||||
|
->assertDispatched('toast', type: 'success', title: 'Saved.');
|
||||||
|
|
||||||
$user->refresh();
|
$user->refresh();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user