Compare commits
10
Commits
6d7120e8e2
...
1352533667
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1352533667 | ||
|
|
fbe358b2e5 | ||
|
|
9fd600571d | ||
|
|
accbc17c1c | ||
|
|
2a363defd1 | ||
|
|
3604005cc7 | ||
|
|
a86f035add | ||
|
|
eb33bf77cc | ||
|
|
bc69066ead | ||
|
|
c4a17b65c8 |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- config/livewire-material.php
|
||||
---
|
||||
|
||||
# Config
|
||||
|
||||
## Theme storage key is sealshare-theme
|
||||
The theme is stored in localStorage under `sealshare-theme` (default `system`). `mary-theme` stays in `theme.legacy_keys` so visitors from 1.x keep their choice once; don't rename the key or drop the legacy entry, or every returning visitor's theme resets.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- 'resources/css/material-scheme.*'
|
||||
---
|
||||
|
||||
# Css
|
||||
|
||||
## Regenerate the colour scheme, never hand-edit it
|
||||
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme "#4f46e5" --variant=vibrant` (Vibrant was chosen over Tonal Spot, which read washed out on the indigo seed). The JSON colours the Markdown mail theme and the fallback error pages, so a hand edit to the CSS alone leaves them out of step. Change the seed or variant and rerun the command instead.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Project Rules Index
|
||||
|
||||
Before planning or editing, find the row whose globs match the file's path and read that rule file.
|
||||
|
||||
| Applies to | Rule file |
|
||||
| --- | --- |
|
||||
| config/livewire-material.php | .ai/rules/config.md |
|
||||
| resources/css/material-scheme.* | .ai/rules/css.md |
|
||||
| resources/views/livewire/share-download.blade.php | .ai/rules/livewire.md |
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- resources/views/livewire/share-download.blade.php
|
||||
---
|
||||
|
||||
# Livewire
|
||||
|
||||
## No anchored components on the download page
|
||||
Recipients open share links on any phone, including iOS Safari below 18.4, where CSS anchor positioning is missing. Keep menus, tooltips, selects-as-menus, datepickers and other anchored/popover components off this page; use plain buttons with aria-label and native controls. tests/Browser/SealShareTest.php checks the page at 393px has no popovers.
|
||||
@@ -0,0 +1,777 @@
|
||||
---
|
||||
name: livewire-material-development
|
||||
description: Build Laravel and Livewire views with Livewire Material's Material 3 Expressive Blade components — props, slots, colour roles, type, shape, motion, theming, toasts, the design guard, and the Livewire traps each component handles.
|
||||
---
|
||||
|
||||
# Livewire Material Development
|
||||
|
||||
## When to use this skill
|
||||
|
||||
Use this skill when writing or changing any Blade view, Livewire component view or layout in an application that requires `nonameweb/livewire-material`, and when styling, theming or testing such views.
|
||||
|
||||
## Setup
|
||||
|
||||
Composer packages must be installed before the Vite build (in Dockerfiles and CI alike), because the application's build imports from `vendor/`:
|
||||
|
||||
```css
|
||||
/* resources/css/app.css */
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
```
|
||||
|
||||
```js
|
||||
// resources/js/app.js
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
```
|
||||
|
||||
Every layout puts the theme script in `<head>`, before `@vite`:
|
||||
|
||||
```blade
|
||||
<head>
|
||||
<x-theme-script />
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
```
|
||||
|
||||
## Colour scheme
|
||||
|
||||
The scheme is generated, never hand-edited. Regenerate it with the seed and variant recorded at the top of `resources/css/material-scheme.css`:
|
||||
|
||||
```bash
|
||||
php artisan material:scheme "#4f46e5" --variant=tonal-spot
|
||||
```
|
||||
|
||||
Variants: `tonal-spot` (M3's default), `vibrant`, `expressive`, `neutral`, `fidelity`, `content`, `monochrome`, `rainbow`, `fruit-salad`. `--success`, `--warning` and `--info` set the source of the state colours; `--contrast` goes from -1 to 1. The command also writes `material-scheme.json` beside the stylesheet.
|
||||
|
||||
## Tokens
|
||||
|
||||
Tailwind's default palette is cleared: every colour class names an M3 role. `text-red-600`, `bg-base-200` or `text-gray-500` compile to nothing.
|
||||
|
||||
- Colour roles (`bg-*`, `text-*`, `border-*`, …): `primary`, `on-primary`, `primary-container`, `on-primary-container`, `inverse-primary`, `primary-fixed`, `primary-fixed-dim`, `on-primary-fixed`, `on-primary-fixed-variant`; the same for `secondary` and `tertiary`; `error`, `on-error`, `error-container`, `on-error-container`; `success`, `warning` and `info` with their `on-`, `-container` and `on-…-container`; `inverse-error|success|warning|info`; `surface`, `surface-dim`, `surface-bright`, `surface-container-lowest|low||high|highest`, `on-surface`, `on-surface-variant`, `inverse-surface`, `inverse-on-surface`, `outline`, `outline-variant`, `scrim`, `shadow`; plus `white` and `black`.
|
||||
- Ink and lines by meaning: `text-body` (body copy), `text-meta` (metadata), `text-quiet` (decoration only), `border-structure`, `border-chrome`, `border-divider` / `divide-divider`.
|
||||
- Type: `type-{display|headline|title|body|label}-{lg|md|sm}` and `type-emphasized-…`. Never assemble `text-*`, `leading-*` and `tracking-*` by hand. The font is Google Sans Flex (`font-sans`).
|
||||
- Shape: `rounded-corner-{none|xs|sm|md|lg|lg-increased|xl|xl-increased|xxl|full}`.
|
||||
- Elevation: `shadow-elevation-{1…5}` — for what floats over content, not for panels (a panel separates by its container tone).
|
||||
- Motion: `ease-spatial-{fast|default|slow}` (position, size, shape; springs that overshoot) and `ease-effects-{fast|default|slow}` (colour, opacity). Always pair an easing with its duration: `duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast`. Reduced motion zeroes the durations.
|
||||
- States: `state-layer` (M3's hover/focus/press overlay; makes the element `relative` and `isolate`), `focus-ring` (keyboard focus indicator), `link` (a link in running text).
|
||||
- `dark:` follows the page's theme (`data-theme`), not the operating system.
|
||||
- `x-figure` on an element holding one number counts it up on first appearance and on change.
|
||||
|
||||
## Theme
|
||||
|
||||
`config/livewire-material.php` → `theme.default` (`light`, `dark` or `system`), `theme.storage_key`, `theme.legacy_keys`. In Alpine, `$store.theme` holds `choice` (what the visitor picked), `resolved` (`light` or `dark`, what shows), `set('light'|'dark'|'system')` and `toggle()`; `x-model="$store.theme.value"` binds a control.
|
||||
|
||||
## Toasts
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
class Settings extends Component
|
||||
{
|
||||
use Toasts;
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
// …
|
||||
$this->success('Settings saved'); // also warning(), error(), info()
|
||||
$this->info('Link copied', timeout: 6000);
|
||||
$this->success('Share created', redirectTo: route('shares.show', $share));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The methods are protected. They dispatch a `toast` browser event (`assertDispatched('toast', type: 'success', title: 'Settings saved')` in tests).
|
||||
|
||||
## Error pages
|
||||
|
||||
Laravel's HTTP error pages — 403, 404, 419, 429, 500, 503, and the framework's own 401 and 402 — render in M3 without setup. The provider appends the package's error views to `view.paths` after the application's, so a file in `resources/views/errors/` always wins.
|
||||
|
||||
- The pages load `config('livewire-material.showcase.vite')` and `<x-theme-script />`, so they use the app's scheme, font and theme. While the build is missing (a deploy in progress) they fall back to an inline stylesheet coloured from `resources/css/material-scheme.json`.
|
||||
- `abort(403, 'Only the owner can open this share.')` and `abort(503, '…')` show the message as the sentence. Every other string goes through `__()`; translate them in `lang/{locale}.json`.
|
||||
- To change wording or design, run `php artisan vendor:publish --tag=livewire-material-errors`, which copies the layout and pages to `resources/views/errors`. A page extends `errors::minimal` and sets `title`, `code`, `headline`, `message`, `shape` (an `<x-shape>` name) and optionally `actions`:
|
||||
|
||||
```blade
|
||||
@extends('errors::minimal')
|
||||
|
||||
@section('title', __('Payment Required'))
|
||||
@section('code', '402')
|
||||
@section('shape', 'cookie-4')
|
||||
@section('headline', __('Your plan has ended'))
|
||||
@section('message', __('Choose a plan to keep using the app.'))
|
||||
|
||||
@section('actions')
|
||||
<x-button :link="route('billing')" :label="__('Choose a plan')" variant="filled" size="md" no-wire-navigate />
|
||||
@endsection
|
||||
```
|
||||
|
||||
- Maintenance mode: `php artisan down --render="errors::503"`.
|
||||
- The showcase previews each page at `/material/errors/{code}`.
|
||||
|
||||
## Mail
|
||||
|
||||
Markdown mail (notifications and `markdown:` mailables) wears M3 once the application selects the theme:
|
||||
|
||||
```dotenv
|
||||
MAIL_MARKDOWN_THEME=livewire-material::mail.theme
|
||||
```
|
||||
|
||||
or per mail: `(new MailMessage)->theme('livewire-material::mail.theme')`, or `public $theme = 'livewire-material::mail.theme';` on a mailable.
|
||||
|
||||
- Colours are the light scheme from `resources/css/material-scheme.json` (`livewire-material.scheme`), inlined as hexes; regenerate the scheme and mail follows. Without the file, the package's default scheme applies.
|
||||
- Write the body as Markdown; the theme styles the bare tags (`#` headings, prose, lists, tables) with M3's typescale. `<x-mail::button :url="…">` is a filled pill in `primary`; `color` also takes `secondary`, `tertiary`, `error`, `success`, `warning` and `info`. `<x-mail::panel>` is a tinted container.
|
||||
- There is no dark mail. Never put `@media` rules, CSS variables or `color-mix()` in mail CSS: the inliner strips media queries and mail clients resolve no variables.
|
||||
- The package's mail header (the app name, or a logo) and message (with a replaceable footer) are opt-in: set `MATERIAL_MAIL_COMPONENTS=true`, or `php artisan vendor:publish --tag=livewire-material-mail` to copy them into `resources/views/vendor/mail`. For a logo set `livewire-material.mail.logo` to `['src' => 'https://example.com/logo.png', 'width' => 160, 'height' => 40]`: an absolute URL, with the image at twice those dimensions.
|
||||
- With those components, a mail can replace the footer:
|
||||
|
||||
```blade
|
||||
<x-mail::message>
|
||||
Your export is ready.
|
||||
|
||||
<x-slot:footer>
|
||||
© {{ date('Y') }} {{ config('app.name') }} · [Unsubscribe]({{ $unsubscribeUrl }})
|
||||
</x-slot:footer>
|
||||
</x-mail::message>
|
||||
```
|
||||
|
||||
- The showcase renders a sample mail at `/material/mail`.
|
||||
|
||||
## Components
|
||||
|
||||
### `<x-icon>`
|
||||
|
||||
A Material Symbol (Rounded, weight 400, grade 0, 24px), inline. Every symbol on fonts.google.com/icons exists, by Google's name with underscores. An unknown name throws.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `name` | required | `calendar_month`, `cloud_upload`, `content_copy` |
|
||||
| `filled` | `false` | the filled symbol — M3 uses it for active or selected |
|
||||
| `label` | `null` | names the icon for screen readers when it carries the meaning alone; otherwise it is `aria-hidden` |
|
||||
|
||||
24px (`size-6`) unless a `size-*`, `w-*` or `h-*` class is passed. Colour follows the text: `<x-icon name="lock" class="size-5 text-on-surface-variant" />`.
|
||||
|
||||
### `<x-shape>`
|
||||
|
||||
One of M3 Expressive's 35 shapes, filled in the text colour, `aria-hidden`, sized by its caller: `<x-shape name="cookie-9" class="size-40 text-secondary-container" />`. Names: `circle`, `square`, `slanted`, `arch`, `fan`, `arrow`, `semi-circle`, `oval`, `pill`, `triangle`, `diamond`, `clam-shell`, `pentagon`, `gem`, `very-sunny`, `sunny`, `cookie-4`, `cookie-6`, `cookie-7`, `cookie-9`, `cookie-12`, `ghostish`, `clover-4`, `clover-8`, `burst`, `soft-burst`, `boom`, `soft-boom`, `flower`, `puffy`, `puffy-diamond`, `pixel-circle`, `pixel-triangle`, `bun`, `heart`.
|
||||
|
||||
### `<x-theme-script>`
|
||||
|
||||
The theme decided before the first paint. Exactly once per layout, in `<head>`, before `@vite`. No props; configured in `config/livewire-material.php`.
|
||||
|
||||
### `<x-button>`
|
||||
|
||||
Label button, icon button, toggle and responsive FAB in one component.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `label` / slot | | the words; without them and with an `icon` it is an icon button |
|
||||
| `variant` | `text` | `filled`, `tonal`, `outlined`, `elevated`, `text` |
|
||||
| `color` (alias `tone`) | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `primary`, `danger`, `caution` | | shorthands: filled primary, filled error, filled warning |
|
||||
| `size` | `sm` | `xs` 32px, `sm` 40px, `md` 56px, `lg` 96px, `xl` 136px |
|
||||
| `shape` | `round` | or `square`; both square off further while pressed |
|
||||
| `icon`, `icon-right` | | Material Symbol names |
|
||||
| `width` | `default` | icon buttons only: `narrow`, `default`, `wide` |
|
||||
| `selected` | `null` | `true`/`false` makes it a toggle (`aria-pressed`, selected colours and shape) |
|
||||
| `link`, `external`, `no-wire-navigate` | | renders `<a>`, with `wire:navigate` unless external |
|
||||
| `spinner` | | `true` shows the loading indicator while its `wire:click` runs; a string names the action |
|
||||
| `tooltip`, `tooltip-left`, `tooltip-right`, `tooltip-bottom` | | plain tooltip; also the icon button's accessible name |
|
||||
| `disabled`, `type`, `responsive`, `fab` | | `responsive` hides the label below `lg`; `fab` is an extended FAB below `sm`, a filled button above |
|
||||
|
||||
```blade
|
||||
<x-button label="Create link" icon="link" variant="filled" size="md" wire:click="create" spinner />
|
||||
<x-button icon="delete" tooltip="Delete share" wire:click="delete({{ $share->id }})" />
|
||||
<x-button icon="favorite" aria-label="Keep" variant="tonal" :selected="$kept" wire:click="toggleKeep" />
|
||||
```
|
||||
|
||||
### `<x-tooltip>`
|
||||
|
||||
M3's plain tooltip, standalone around any trigger: `<x-tooltip text="Copy link" side="bottom"><button>…</button></x-tooltip>`. `side`: `top` (default), `bottom`, `left`, `right`. Shows on hover (fine pointers) and keyboard focus; `aria-hidden`, so the trigger still needs its own accessible name. Buttons and FABs take a `tooltip` prop instead.
|
||||
|
||||
### `<x-menu>`, `<x-menu-item>`, `<x-menu-group>`, `<x-menu-separator>`
|
||||
|
||||
```blade
|
||||
<x-menu label="Share actions" position="bottom-end">
|
||||
<x-slot:trigger>
|
||||
<x-button icon="more_vert" tooltip="More" />
|
||||
</x-slot:trigger>
|
||||
|
||||
<x-menu-group label="Sort by">
|
||||
<x-menu-item label="Newest" :selected="$sort === 'newest'" wire:click="$set('sort', 'newest')" keep-open />
|
||||
</x-menu-group>
|
||||
<x-menu-separator />
|
||||
<x-menu-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
<x-menu-item label="Delete" icon="delete" wire:click="delete" description="Recipients lose access" shortcut="⌘⌫" />
|
||||
</x-menu>
|
||||
```
|
||||
|
||||
`<x-menu>`: `trigger` slot (its first button or link becomes the menu button), `label`, `position` (`bottom-start` default, `bottom-end`, `top-start`, `top-end`), `vibrant`. `<x-menu-item>`: `label`, `icon`, `icon-right`, `description`, `shortcut`, `link`, `external`, `selected` (makes it a `menuitemcheckbox`), `disabled`, `keep-open`. Choosing an item closes the menu unless `keep-open`. Keyboard: arrows, Home, End, a letter, Escape (focus returns to the trigger), Tab.
|
||||
|
||||
### `<x-button-group>`
|
||||
|
||||
A row of `<x-button>`s: `<x-button-group label="View" size="md">…</x-button-group>`. `connected` sets them 2px apart with small inner corners (a selected toggle rounds fully). Pass the `size` of the buttons inside.
|
||||
|
||||
### `<x-group>`
|
||||
|
||||
A choice between a few options as a connected button group of native radios (checkboxes with `multiple`):
|
||||
|
||||
```blade
|
||||
<x-group label="Expires after" wire:model.live="expiry" :options="[
|
||||
['id' => '1h', 'name' => '1 hour'],
|
||||
['id' => '1d', 'name' => '1 day', 'icon' => 'today'],
|
||||
['id' => '7d', 'name' => '7 days', 'disabled' => true],
|
||||
]" hint="Recipients lose access after that" />
|
||||
```
|
||||
|
||||
Props: `label`, `hint`, `name` (required with `x-model`), `options`, `option-value` (`id`), `option-label` (`name`), `option-icon` (`icon`), `size`, `variant` (`tonal`, `filled`, `outlined`), `multiple`, `inline` (intrinsic width instead of sharing the row). A validation error for the bound property replaces the hint.
|
||||
|
||||
### `<x-split-button>`
|
||||
|
||||
```blade
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options">
|
||||
<x-menu-item label="Download files one by one" wire:click="downloadEach" />
|
||||
</x-split-button>
|
||||
```
|
||||
|
||||
Attributes go to the leading button; the slot is the menu. `variant` (`filled` default, `tonal`, `outlined`, `elevated`), `color`, `size`, `disabled`, `spinner`, `menu-label`, `position`.
|
||||
|
||||
### `<x-fab>`
|
||||
|
||||
`<x-fab icon="add" tooltip="New share" />` — `size` `sm` 56px (default), `md` 80px, `lg` 96px; with `label` it is an extended FAB. `color` `primary`/`secondary`/`tertiary`, drawn in the container, or `variant="filled"`. It does not position itself; wrap it (`<div class="fixed end-4 bottom-4">`). `link`, `external`, `disabled`, `type`.
|
||||
|
||||
### `<x-fab-menu>`, `<x-fab-menu-item>`
|
||||
|
||||
```blade
|
||||
<div class="fixed end-4 bottom-4">
|
||||
<x-fab-menu label="New">
|
||||
<x-fab-menu-item label="Upload files" icon="upload_file" wire:click="uploadFiles" />
|
||||
<x-fab-menu-item label="Paste text" icon="content_paste" link="{{ route('paste') }}" />
|
||||
</x-fab-menu>
|
||||
</div>
|
||||
```
|
||||
|
||||
Two to six items open above the FAB, which turns into a close button. `<x-fab-menu>`: `icon` (`add`), `label`, `color`, `position` (`top-end` default). Give items the same `color`. Keyboard as `<x-menu>`.
|
||||
|
||||
### `<x-loading>`
|
||||
|
||||
M3 Expressive's loading indicator — a shape morphing through seven Expressive shapes as it turns — for a wait of unknown length. 48px and `primary` unless sized or coloured by class; `contained` sets it on a primary-container circle. A `progressbar` named by `label` ("Loading"); `:label="false"` makes it decorative. It rests under reduced motion.
|
||||
|
||||
```blade
|
||||
<x-loading />
|
||||
<x-loading contained class="size-8" label="Uploading" />
|
||||
<div wire:loading.flex wire:target="upload"><x-loading /></div>
|
||||
```
|
||||
|
||||
`<x-button spinner>` shows a decorative one in place of its icon.
|
||||
|
||||
### `<x-toast>`
|
||||
|
||||
The snackbar host. Once per layout, near the end of `<body>`: `<x-toast />` (`position="bottom-start"` to leave the centre free). It is `@persist`ed across `wire:navigate` and shows, one at a time, every toast from the `Toasts` concern (see Toasts above) or from JavaScript:
|
||||
|
||||
```js
|
||||
materialToast('Share deleted', { type: 'success', description: null, timeout: 4000, action: { label: 'Undo', handler: () => $wire.restore() } })
|
||||
```
|
||||
|
||||
`type` (`success`, `error`, `warning`, `info`) adds the state icon; `timeout: 0` keeps it until dismissed; a toast with an action or no timeout gets a close button. Hover or focus pauses the timer.
|
||||
|
||||
### `<x-progress>`
|
||||
|
||||
M3 Expressive's progress indicator: linear (as wide as its container) or `circular` (40px, 48px wavy, unless a `size-*` class is passed), flat or `wavy`, determinate with a `value` or indeterminate without one.
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `value` | `null` | 0 to `max`, clamped; `null` is indeterminate |
|
||||
| `max` | `100` | |
|
||||
| `bind` | `null` | an Alpine expression it follows in the browser; `null`/`undefined` is indeterminate |
|
||||
| `circular` | `false` | circular instead of linear |
|
||||
| `wavy` | `false` | Expressive's wave (flat below 10% and from 95%) |
|
||||
| `thick` | `false` | 8px track and indicator instead of 4px |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info`; the track is the colour's container (secondary-container for primary) |
|
||||
| `label` | `"Progress"` | names the `progressbar`; `:label="false"` makes it decorative |
|
||||
|
||||
```blade
|
||||
<x-progress :value="$share->uploaded" :max="$share->size" label="Uploading" />
|
||||
<x-progress circular wavy label="Preparing the download" />
|
||||
|
||||
<div x-data="{ progress: null }" x-on:livewire-upload-progress="progress = $event.detail.progress" x-on:livewire-upload-finish="progress = null">
|
||||
<input type="file" wire:model="file">
|
||||
<div x-show="progress !== null"><x-progress bind="progress" wavy label="Uploading" /></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
A value the server changes animates after a morph (the SVG is `wire:ignore`; only the root's attributes change). Under reduced motion values jump, the wave stands still and an indeterminate indicator holds one frame. A `w-*` class narrows a linear one; never pass a display or position class.
|
||||
|
||||
### `<x-badge>`
|
||||
|
||||
- `<x-badge />` — M3's small badge, a dot. `<x-badge value="4" max="99" />` — M3's large badge, a count. Both `error` by default. `floating` pins it to the top-end corner of a `relative` parent: `<span class="relative inline-flex"><x-icon name="mail" /><x-badge value="4" floating /></span>`. A dot or count is `aria-hidden` unless it has a `label`; name the control instead ("Messages, 4 unread").
|
||||
- `<x-badge value="Expired" tonal />`, `<x-badge value="Active" color="success" tonal />`, `<x-badge value="Pro" outline />` — a status label (not an M3 badge) in the colour's container or a neutral edge. `color` (alias `tone`): `error` default, `primary`, `secondary`, `tertiary`, `success`, `warning`, `info`.
|
||||
|
||||
### `<x-alert>`
|
||||
|
||||
A notice in the page, in the state's container colour with its icon:
|
||||
|
||||
```blade
|
||||
<x-alert title="Storage almost full" description="3.8 GB of 4 GB used." color="warning" />
|
||||
<x-alert color="error" dismissible>
|
||||
The upload failed.
|
||||
<x-slot:actions><x-button label="Try again" wire:click="retry" /></x-slot:actions>
|
||||
</x-alert>
|
||||
```
|
||||
|
||||
`color` (alias `tone`): `info` (default), `success`, `warning`, `error`, `primary`, `secondary`, `tertiary`, `neutral`. `icon` overrides the state icon; `:icon="false"` removes it. Errors and warnings are `role="alert"`, the rest `role="status"`.
|
||||
|
||||
### `<x-rich-tooltip>`
|
||||
|
||||
A few lines of context around a trigger, with an optional `title` and `actions` slot:
|
||||
|
||||
```blade
|
||||
<x-rich-tooltip title="Expiry" text="Recipients lose access after this time.">
|
||||
<x-button icon="help" aria-label="About expiry" />
|
||||
</x-rich-tooltip>
|
||||
```
|
||||
|
||||
Shows on hover and keyboard focus; `persistent` opens it on press and keeps it until a press elsewhere or Escape (use it when there are actions). `side`: `bottom` (default), `top`, `left`, `right`.
|
||||
|
||||
### `<x-stat>`
|
||||
|
||||
`<x-stat title="Shares" value="1,204" icon="link" description="12 this week" />` — a figure on a surface-container panel; the value counts up on first appearance and when it changes. The slot goes under the description (e.g. a quota's `<x-progress>`). Do not pass a `bg-*` class; wrap it.
|
||||
|
||||
### `<x-empty-state>`
|
||||
|
||||
"Nothing here yet": `icon` on an Expressive `shape` (`cookie-9` by default), `title`, `description` or slot, and an `actions` slot. Use it for an empty collection, not for a filter that matched nothing.
|
||||
|
||||
### `<x-card>`
|
||||
|
||||
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
|
||||
|
||||
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
|
||||
|
||||
```blade
|
||||
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
|
||||
<a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
|
||||
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
|
||||
</x-card>
|
||||
```
|
||||
|
||||
### `<x-list>`, `<x-list-item>`
|
||||
|
||||
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content.
|
||||
|
||||
```blade
|
||||
<x-list segmented label="Files">
|
||||
@foreach ($files as $file)
|
||||
<x-list-item :title="$file->name" :description="$file->size" icon="description" wire:key="file-{{ $file->id }}">
|
||||
<x-slot:end><x-button icon="download" tooltip="Download" wire:click="download({{ $file->id }})" /></x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
```
|
||||
|
||||
### `<x-divider>`
|
||||
|
||||
`<x-divider />` — outline-variant line; `vertical`, `inset` (16px start), `middle`, `decorative` (hidden from assistive tech).
|
||||
|
||||
### `<x-collapse>`
|
||||
|
||||
A disclosure on native `<details>`: `<x-collapse title="Advanced" icon="tune" open variant="filled">…</x-collapse>` (`variant` `plain` or `filled`; `heading` slot for rich titles). Keeps its state through a morph.
|
||||
|
||||
### `<x-modal>`
|
||||
|
||||
An M3 dialog on native `<dialog>`. Bind with `wire:model` to a flag or an id; closing (Escape, scrim, `close()`) writes back `false` or `null`. Without `wire:model` it uses `open` from the surrounding Alpine scope.
|
||||
|
||||
```blade
|
||||
<x-modal wire:model="deletingId" title="Delete this share?" subtitle="Recipients lose access at once." icon="delete">
|
||||
<x-slot:actions>
|
||||
<x-button label="Cancel" x-on:click="close()" />
|
||||
<x-button label="Delete" danger wire:click="delete" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
```
|
||||
|
||||
Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen below `sm`, for forms), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead.
|
||||
|
||||
### `<x-drawer>`
|
||||
|
||||
An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `subtitle`, `separator`, `side` (`end` default, `start`), `width` (`25rem`), `with-close-button`, `close-on-escape` (default true), `without-backdrop-close`, `actions` slot. `pane` (with `pane-width`) turns it into a list-detail pane from `xl`: render it after the list inside `<div class="xl:flex xl:items-start xl:gap-6">`. Its body is a size container — lay out inside with `@md:` etc., not `sm:`.
|
||||
|
||||
### `<x-bottom-sheet>`
|
||||
|
||||
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
|
||||
|
||||
### `<x-carousel>`, `<x-carousel-item>`
|
||||
|
||||
```blade
|
||||
<x-carousel label="Recent uploads" item-width="220">
|
||||
@foreach ($photos as $photo)
|
||||
<x-carousel-item :label="$photo->title" wire:key="photo-{{ $photo->id }}">
|
||||
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
|
||||
</x-carousel-item>
|
||||
@endforeach
|
||||
</x-carousel>
|
||||
```
|
||||
|
||||
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
|
||||
|
||||
### `<x-chip>`
|
||||
|
||||
One component for M3's four chips, picked by `type`:
|
||||
|
||||
| `type` | What it is | Element |
|
||||
|---|---|---|
|
||||
| `assist` (default) | an action | `<button>`, or `<a>` with `link` |
|
||||
| `filter` | a toggle | a native checkbox under the chip with `wire:model`, `x-model` or `name`; otherwise a `<button aria-pressed>` whose `selected` you own |
|
||||
| `input` | something a person entered | its own button only with `wire:click`, `x-on:click`, `link` or `selected`; a remove button with `removable` |
|
||||
| `suggestion` | a suggested reply or query | `<button>` |
|
||||
|
||||
Props: `label` / slot, `icon`, `icon-right`, `elevated` (not on input chips), `disabled`, `link`, `external`, `no-wire-navigate`, `selected` (filter and input), `name` / `value` (a filter checkbox; an input chip's hidden input, `value` defaulting to the label), `avatar` (input: image URL or initials), `removable`, `remove` (input: an Alpine expression), `tooltip`. On a filter checkbox and an input chip, `class`, `style` and `wire:key` stay on the chip and every other attribute goes to the control inside.
|
||||
|
||||
```blade
|
||||
<x-chip label="Add to calendar" icon="event" wire:click="addToCalendar" />
|
||||
|
||||
<x-chip-set label="File types" hint="Show only these" error-field="kinds">
|
||||
@foreach ($kindOptions as $kind => $name)
|
||||
<x-chip type="filter" :label="$name" :value="$kind" wire:model.live="kinds" wire:key="kind-{{ $kind }}" />
|
||||
@endforeach
|
||||
</x-chip-set>
|
||||
|
||||
<x-chip type="filter" label="Starred" icon="star" :selected="$starredOnly" wire:click="$toggle('starredOnly')" />
|
||||
|
||||
@foreach ($recipients as $recipient)
|
||||
<x-chip type="input" :label="$recipient->email" :avatar="$recipient->initials" removable wire:remove="removeRecipient({{ $recipient->id }})" wire:key="recipient-{{ $recipient->id }}" />
|
||||
@endforeach
|
||||
```
|
||||
|
||||
- A multi-select set binds `wire:model` on every chip, each with its own `value`, to an array property; a boolean property needs no `value`. The chips render checked as the property already says.
|
||||
- A removable input chip removes through `wire:remove` (it becomes the remove button's `wire:click`), `remove` (Alpine), or, with neither, takes itself off the page. Backspace or Delete on a focused chip removes it and moves focus to the previous or next chip; the remove button is named "Remove <label>". Give each one a `wire:key`.
|
||||
|
||||
### `<x-chip-set>`
|
||||
|
||||
A row of chips 8px apart that wraps, as `role="group"`: `label` (shown, and names the group; otherwise pass `aria-label`), `hint`, `error-field` (a validation message for that property or its items replaces the hint), `scroll` (one line that scrolls sideways, fading the edge it can still scroll towards).
|
||||
|
||||
### `<x-form>`
|
||||
|
||||
A one-column grid of fields with an `actions` slot at the foot (the slot takes its own `class`); `separator` draws a divider above the actions.
|
||||
|
||||
```blade
|
||||
<x-form wire:submit="save">
|
||||
<x-input label="Share name" wire:model="name" required />
|
||||
<x-select label="Expires after" wire:model="hours" :options="$expiryOptions" />
|
||||
<x-slot:actions>
|
||||
<x-button label="Cancel" wire:click="cancel" />
|
||||
<x-button label="Create share" variant="filled" type="submit" spinner="save" />
|
||||
</x-slot:actions>
|
||||
</x-form>
|
||||
```
|
||||
|
||||
### `<x-field>`, `<x-input>`, `<x-password>`, `<x-textarea>`, `<x-select>`, `<x-file>`
|
||||
|
||||
M3 text fields. `variant`: `outlined` or `filled`; without it, `config('livewire-material.fields.variant')` (`outlined`). All take `label`, `hint`, `variant`, and read their errors from the bag under the `wire:model` name, or the `name` in a plain form (`photos[]` → `photos`, `address[city]` → `address.city`); the error replaces the hint and sets `aria-invalid`. `class` lands on the field's outer element (margins, widths); every other attribute (`wire:model`, `type`, `required`, `readonly`, `autocomplete`) reaches the control. Never pass `placeholder` expecting it to show while a label rests in the field: it shows once the field has focus.
|
||||
|
||||
- `<x-input>`: `icon`, `icon-right`, `prefix`, `suffix`, `clearable`, `copyable` (copies the value, confirms with a snackbar), `size` (`sm` 40px, `xs` 32px — for unlabelled toolbar controls; give them `aria-label`), `mono`.
|
||||
- `<x-password>`: a reveal button; `icon`, `size`.
|
||||
- `<x-textarea>`: grows from `rows` (3) to `max-rows`, then scrolls; `:autogrow="false"` for a fixed, hand-resizable one.
|
||||
- `<x-select>`: native `<select>` (M3 menu where the browser supports customizable selects). `options` as `['id' => …, 'name' => …, 'disabled' => bool]`, `option-value`, `option-label`, `placeholder` + `placeholder-value`, or `<option>`s in the slot; `icon`, `size`.
|
||||
- `<x-file>`: native file input; errors from `photos` and `photos.*`. Show previews of what was chosen yourself.
|
||||
- `<x-field id="…" label="…" :messages="$messages">` wraps a custom control given `class="field-control"`; only for controls the package does not have.
|
||||
|
||||
### `<x-checkbox>`, `<x-radio>`, `<x-toggle>`
|
||||
|
||||
M3 selection controls on native inputs; the whole row is the label.
|
||||
|
||||
- `<x-checkbox label hint right indeterminate />` — `indeterminate` for a "select all" whose items are partly ticked (bind it to a server expression; it follows every render).
|
||||
- `<x-radio label wire:model :options inline />` — options `['id', 'name', 'hint', 'disabled']` (`option-value`, `option-label`, `option-hint`); `value` checks an option without `wire:model`; `name` names an unbound group.
|
||||
- `<x-toggle label hint right icons />` — M3 switch (`role="switch"`); `icons` puts a check and a cross on the handle, `icons="selected"` only the check. Without `label`, pass `aria-label`.
|
||||
|
||||
```blade
|
||||
<x-checkbox label="All files" :checked="count($selected) === $files->count()" :indeterminate="$selected && count($selected) < $files->count()" wire:click="toggleAll" />
|
||||
<x-toggle label="Notify me on download" wire:model.live="notify" right />
|
||||
```
|
||||
|
||||
### `<x-slider>`
|
||||
|
||||
M3 Expressive's slider on native `<input type="range">`s (one per handle), so the arrow keys, Home, End, forms and screen readers work as on a plain range; PageUp and PageDown move a tenth of the steps (1 to 10). A press anywhere on the slider moves the nearest handle there. Without JavaScript the native range shows, and posts.
|
||||
|
||||
```blade
|
||||
<x-slider label="Volume" wire:model.live="volume" hint="Applies at once" />
|
||||
<x-slider label="Price" wire:model="price" range :max="500" :step="10" ticks />
|
||||
<x-slider label="Balance" x-model="balance" name="balance" :min="-50" :max="50" centered />
|
||||
<x-slider label="Brightness" name="brightness" value="60" size="lg" icon="light_mode" />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `label`, `hint` | | the label above names the input (with `range`, the group); a validation error for the bound property or `name` replaces the hint |
|
||||
| `value`, `min`, `max`, `step` | `null`, `0`, `100`, `1` | as on a range input; `step="any"` is continuous. With `wire:model` the property's value is drawn |
|
||||
| `name` | the `wire:model` property | with `range` it posts `name[]` twice, from first |
|
||||
| `range` | `false` | two handles that never cross; binds an array `[from, to]` (`wire:model="price"` binds `price.0` and `price.1`, `x-model="price"` binds `price[0]` and `price[1]`) |
|
||||
| `centered` | `false` | fills from the middle of the track, for values that go below zero |
|
||||
| `size` | `xs` | track `xs` 16px, `sm` 24px, `md` 40px, `lg` 56px, `xl` 96px |
|
||||
| `icon` | `null` | a Material Symbol inside the track, `md` and up, standard sliders only |
|
||||
| `ticks` | `false` | a mark per step (up to 200, hidden while closer than 8px); the handle sits on the marks |
|
||||
| `value-label` | `drag` | `drag` (while pressed, dragged or keyboard-focused), `always`, `never` |
|
||||
| `color` | `primary` | `primary`, `secondary`, `tertiary`, `error`, `success`, `warning`, `info` |
|
||||
| `disabled` | `false` | |
|
||||
|
||||
Other attributes go to the input(s). A `wire:model.live` slider sends while it is dragged, and a server render never moves a handle under the pointer (the drawing is `wire:ignore`); a value the server sets moves the handle after the morph. The binding gets `.number`, so values arrive as numbers. Its width is the container's unless a `w-*` class is passed.
|
||||
|
||||
### `<x-datepicker>`
|
||||
|
||||
M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model` without Livewire).
|
||||
|
||||
```blade
|
||||
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
|
||||
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
|
||||
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `mode` | `docked` | `docked`: type a date (in the locale's numeric format) or pick one from a calendar under the field, which opens as a dialog below `sm`; `modal`: the field opens a calendar dialog; `input`: the dialog opens on a text field. Both dialogs switch between calendar and typing |
|
||||
| `range` | `false` | binds one array property, `['start' => 'Y-m-d', 'end' => 'Y-m-d']` (either may be null); errors for `trip`, `trip.start` and `trip.end` show on the field |
|
||||
| `min`, `max` | `null` | `Y-m-d` or a date; days outside are disabled and the keyboard stays inside |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
|
||||
| `value` | `null` | the initial value without `wire:model` |
|
||||
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
|
||||
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
|
||||
|
||||
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
|
||||
|
||||
### `<x-timepicker>`
|
||||
|
||||
M3's time picker in a modal dialog, opened from a read-only text field (a press, Enter, Space, ArrowDown or its clock icon). The dial picks the hour, then the minutes, by press or drag; a 24-hour clock puts 12–23 on the inner ring. The keyboard icon switches to two text fields. The arrow keys change the focused dial's value, Home and End go to the ends, Enter confirms; Escape, Cancel or the scrim close it unchanged and give focus back to the field. In a landscape window the dial lies on its side.
|
||||
|
||||
```blade
|
||||
<x-timepicker label="Starts at" wire:model="startsAt" />
|
||||
<x-timepicker label="Appointment" wire:model.live="appointmentAt" format="24" step="15" min="08:00" max="17:30" clearable />
|
||||
```
|
||||
|
||||
| Prop | Default | |
|
||||
|---|---|---|
|
||||
| `wire:model` / `x-model` | | the time as `H:i`, null until chosen; `H:i:s` (a `time` column) is read and written back as `H:i`; nothing is written until OK |
|
||||
| `format` | the locale's | `12` or `24`; otherwise the hour cycle of `locale` as `Intl.DateTimeFormat` reports it |
|
||||
| `locale` | app locale | the hour cycle, and how the field writes the time |
|
||||
| `step` | `1` | minutes between choices (a tap picks fives, or steps when five is not a multiple of the step) |
|
||||
| `min`, `max` | | `H:i`, inclusive; `min` later than `max` spans midnight. Outside values are greyed out and refused in the picker — validate on the server as well |
|
||||
| `clearable` | `false` | a button that empties the field |
|
||||
| `name` | | posts the value from a hidden input |
|
||||
| `label`, `hint`, `icon`, `variant`, `size` | | the field's; `required`, `disabled` and `placeholder` reach its input |
|
||||
|
||||
Errors under the `wire:model` name replace the hint. The dialog is `wire:ignore`: a Livewire render leaves an open picker open with its draft. Never name a Livewire property `$slot`: it renders empty in the component's view.
|
||||
|
||||
### `<x-choices>`
|
||||
|
||||
Choosing from a list, with typed values (an array of integers stays integers). `options` (`id`, `name`, `disabled`; `option-value`, `option-label`), `label`, `hint`, `single`. Errors for the property and its items replace the hint.
|
||||
|
||||
- Default: filter chips, every option on screen — `single` for choice chips.
|
||||
- `searchable`: a text field that filters a menu as you type (single value; arrow keys, Enter, Escape); `icon`, `variant`, `placeholder`. Its list is a popover, so it is never clipped by a card.
|
||||
|
||||
```blade
|
||||
<x-choices label="Days you are free" wire:model.live="days" :options="$weekdays" />
|
||||
<x-choices label="Time zone" wire:model="timezone" :options="$timezones" searchable icon="public" />
|
||||
```
|
||||
|
||||
Bind with `wire:model` (entangled) or, without Livewire, `x-model`. The options are baked into its Alpine state: when they change on the server, give it a `wire:key` that changes with them.
|
||||
|
||||
### `<x-search>`
|
||||
|
||||
M3 search bar that opens into a search view: docked under the bar from `sm`, full screen with a back arrow below (`docked` keeps it docked). Bind the input like any other and render the results in the slot; `empty` is shown when the slot renders nothing. Choosing a result (a link or button) closes the view; ArrowDown walks the results, Escape closes. Props: `placeholder` ("Search"), `label`, `icon`; `trailing` slot (avatar, icon buttons).
|
||||
|
||||
```blade
|
||||
<x-search wire:model.live.debounce.300ms="query" placeholder="Search shares">
|
||||
@foreach ($this->results as $share)
|
||||
<x-list-item :title="$share->name" :description="$share->size" link="{{ route('shares.show', $share) }}" wire:key="result-{{ $share->id }}" />
|
||||
@endforeach
|
||||
<x-slot:empty>No shares match.</x-slot:empty>
|
||||
</x-search>
|
||||
```
|
||||
|
||||
The docked view overlaps what is under it; never place a search inside an element with `overflow-hidden` (a card), which clips it.
|
||||
|
||||
### `<x-app-shell>`
|
||||
|
||||
The adaptive app shell, a whole layout's body: a navigation bar below `sm`, a collapsed rail that opens as a modal to `lg`, an expanded rail the visitor can collapse from `lg`, the page as `<main id="content" wire:transition.navigate>` behind a skip link, and the snackbar host (do not add another `<x-toast />`). It needs `<x-theme-script />` in `<head>`.
|
||||
|
||||
```blade
|
||||
<x-app-shell :destinations="[
|
||||
['title' => 'Shares', 'icon' => 'folder_shared', 'url' => route('shares.index'), 'active' => request()->routeIs('shares.*'), 'badge' => $expiringCount],
|
||||
['title' => 'Upload', 'icon' => 'upload', 'url' => route('upload')],
|
||||
['title' => 'Users', 'icon' => 'group', 'url' => route('users'), 'section' => 'Admin', 'bar' => false],
|
||||
]">
|
||||
<x-slot:brand><a href="{{ route('home') }}" wire:navigate class="type-title-lg">SealShare</a></x-slot:brand>
|
||||
<x-slot:rail-header>
|
||||
<span class="rail-collapsed:hidden"><x-fab label="New share" icon="add" link="{{ route('upload') }}" /></span>
|
||||
<span class="hidden rail-collapsed:inline-flex"><x-fab icon="add" tooltip-right="New share" link="{{ route('upload') }}" /></span>
|
||||
</x-slot:rail-header>
|
||||
<x-slot:rail-footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" :active="request()->routeIs('settings')" />
|
||||
</x-slot:rail-footer>
|
||||
<x-slot:top>
|
||||
{{-- the page's app bar; its menu button opens the modal rail on a phone --}}
|
||||
<span class="sm:hidden"><x-button icon="menu" tooltip="Open navigation" x-on:click="$store.rail.show()" /></span>
|
||||
</x-slot:top>
|
||||
|
||||
{{ $slot }}
|
||||
</x-app-shell>
|
||||
```
|
||||
|
||||
- `destinations`: `title`, `icon`, `url`; optional `active` (default: the URL is the current one), `badge` (`true` for a dot, or a count), `section` (a heading in the rail, shown only while it is expanded; consecutive destinations with the same section are grouped), `bar` (default `true`; `false` keeps it out of the bottom bar — M3 wants three to five there), `navigate` (`false` for a full page load instead of `wire:navigate`).
|
||||
- Slots, each rendered once: `brand` (beside the rail's menu button, expanded only), `rail-header` (a FAB), `rail-footer` (pinned to the foot of the rail), `actions` (a row of icon buttons at the very foot, stacked when collapsed), `top` (the app bar, above the page at every width), and the page. `label` names the landmarks ("Main"); `rail-width` is the expanded width (`16rem`).
|
||||
- The rail is one element at every width: what is in it is also what a phone sees in the modal rail. Below `sm` nothing opens it but `$store.rail.show()`, so a page whose destinations are not all in the bar needs a menu button in its app bar (hidden from `sm`).
|
||||
- Below `sm` the shell sets `--material-bottom-bar`, so the snackbar and a `fab` button clear the bar; pad anything else you pin to the bottom with it.
|
||||
- The content region is `max-lg:overflow-x-clip`. Never make a page wrapper `overflow-x-hidden`: it turns the region into a scroll container and breaks every `sticky` inside.
|
||||
|
||||
### `<x-navigation-bar>`, `<x-navigation-bar-item>`
|
||||
|
||||
M3 Expressive's flexible navigation bar, for three to five destinations. It does not position itself; wrap it (`<x-app-shell>` does):
|
||||
|
||||
```blade
|
||||
<div class="fixed inset-x-0 bottom-0 z-30 sm:hidden">
|
||||
<x-navigation-bar>
|
||||
<x-navigation-bar-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" :active="request()->routeIs('shares.*')" badge="3" />
|
||||
<x-navigation-bar-item label="Upload" icon="upload" link="{{ route('upload') }}" />
|
||||
</x-navigation-bar>
|
||||
</div>
|
||||
```
|
||||
|
||||
64px in surface-container with the bottom safe area under it. Narrower than 600px the icon sits in a 56×32 indicator over the label; from 600px (the bar's own width) icon and label share a 40px pill and the items gather in the middle. `<x-navigation-bar>`: `label` ("Main"). `<x-navigation-bar-item>`: `label` / slot, `icon`, `link` (with `wire:navigate` unless `external` or `no-wire-navigate`; without a link it is a button), `active` (`aria-current="page"`, filled icon, secondary-container indicator), `badge` (`true` for a dot, a number for a count, 999+ at most), `badge-label` (what a screen reader hears instead of ", 3").
|
||||
|
||||
### `<x-navigation-rail>`, `<x-navigation-rail-item>`, `<x-navigation-rail-section>`
|
||||
|
||||
M3 Expressive's navigation rail: collapsed (96px, icon over label) or expanded (a 56px full-width pill, icon beside label, count at the end).
|
||||
|
||||
```blade
|
||||
<div class="flex min-h-dvh">
|
||||
<x-navigation-rail mode="collapsible">
|
||||
<x-slot:brand><span class="type-title-lg">SealShare</span></x-slot:brand>
|
||||
<x-slot:header><x-fab icon="add" tooltip-right="New share" /></x-slot:header>
|
||||
|
||||
<x-navigation-rail-item label="Shares" icon="folder_shared" link="{{ route('shares.index') }}" active badge="3" />
|
||||
<x-navigation-rail-section label="Admin">
|
||||
<x-navigation-rail-item label="Users" icon="group" link="{{ route('users') }}" />
|
||||
</x-navigation-rail-section>
|
||||
|
||||
<x-slot:footer>
|
||||
<x-navigation-rail-item label="Settings" icon="settings" link="{{ route('settings') }}" />
|
||||
</x-slot:footer>
|
||||
</x-navigation-rail>
|
||||
|
||||
<main class="min-w-0 flex-1">…</main>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `mode`: `collapsed`, `expanded`, `collapsible` (default: expanded until its menu button collapses it; the choice is `$store.rail`, remembered and applied before the first paint), `modal` (collapsed in the layout; the menu button or `$store.rail.show()` opens it expanded over a scrim, focus held until Escape, the scrim or leaving the page), `adaptive` (`<x-app-shell>`'s: hidden and opened as a modal below `sm`, collapsed and opened as a modal to `lg`, collapsible from `lg`).
|
||||
- Props: `label` ("Main"), `width` (expanded width, `16rem`, held between 220 and 360px), `menu` (the menu button; on by default for `collapsible`, `modal`, `adaptive`). Slots: `brand` (beside the menu button, expanded only), `header` (a FAB), the destinations (the only part that scrolls), `footer`. In a flex row the rail sticks to the top of the viewport.
|
||||
- Anything inside a rail takes both shapes with the `rail-collapsed:` variant, true while that rail is drawn collapsed for whatever reason: `<span class="rail-collapsed:hidden">…expanded only…</span>`, `<span class="hidden rail-collapsed:inline-flex">…collapsed only…</span>`. Put the variant on a wrapper, never on a component. Nothing that shows while collapsed may be wider than 96px.
|
||||
- `<x-navigation-rail-item>`: the same props as `<x-navigation-bar-item>`. `<x-navigation-rail-section label="…">`: a group with a heading that shows only while the rail is expanded; it names the group for screen readers either way.
|
||||
- `$store.rail`: `collapsed`, `toggle()`, `collapse()`, `expand()` (the remembered choice), `open`, `show()`, `hide()` (the modal rail; closed on every `wire:navigate`). `config/livewire-material.php` → `rail.default` (`expanded` or `collapsed`) and `rail.storage_key` (`material-rail`).
|
||||
|
||||
### `<x-app-bar>`
|
||||
|
||||
M3 Expressive top app bar, sticky by default (`:sticky="false"` to scroll away), turning surface-container once content scrolls under it. `variant`: `small` (default), `center`, `medium` and `large` (a big title that collapses into the row as the page scrolls — CSS sticky, no layout shift), `search` (put an `<x-search>` in the slot). Props: `title`, `subtitle`, `heading` (`h1` default). Slots: `navigation` (leading icon button), `actions` (trailing icon buttons, avatar).
|
||||
|
||||
```blade
|
||||
<x-app-bar variant="medium" title="Recipients" subtitle="3 people">
|
||||
<x-slot:navigation><x-button icon="arrow_back" tooltip="Back" :link="route('shares.index')" /></x-slot:navigation>
|
||||
<x-slot:actions><x-button icon="person_add" tooltip="Add recipient" wire:click="add" /></x-slot:actions>
|
||||
</x-app-bar>
|
||||
```
|
||||
|
||||
A collapsing bar needs the window to scroll: no ancestor with `overflow-hidden`/`overflow-auto` (`overflow-x-clip` is fine).
|
||||
|
||||
### `<x-toolbar>`
|
||||
|
||||
M3 Expressive toolbar, `role="toolbar"` (arrow keys move between controls). `variant`: `floating` (default pill at elevation 3; `vibrant`, `vertical`) or `docked` (full-width surface-container bar). `place`: `bottom` or `end` to fix it over the page; `fab` slot sets a FAB beside a floating toolbar; `label` names it.
|
||||
|
||||
```blade
|
||||
<x-toolbar label="Selection" place="bottom" vibrant>
|
||||
<x-button icon="download" tooltip="Download" wire:click="download" />
|
||||
<x-button icon="delete" tooltip="Delete" wire:click="delete" />
|
||||
<x-slot:fab><x-fab icon="add" tooltip="New share" /></x-slot:fab>
|
||||
</x-toolbar>
|
||||
```
|
||||
|
||||
### `<x-tabs>`, `<x-tab>`
|
||||
|
||||
M3 tabs with a server-rendered tablist (arrow keys, Home/End, disabled tabs skipped, the indicator moves in a view transition). `tabs`: `['name', 'label', 'icon', 'badge', 'disabled']`; panels are `<x-tab name>` in the slot. Bind with `wire:model` (entangled), or `selected` / `x-model` without Livewire. `variant` `primary` (default) or `secondary`; `stacked` (icon over label), `scrollable`. Give two identical tab sets on one page distinct `id`s.
|
||||
|
||||
```blade
|
||||
<x-tabs wire:model.live="tab" :tabs="[['name' => 'files', 'label' => 'Files'], ['name' => 'people', 'label' => 'People', 'badge' => $pending]]">
|
||||
<x-tab name="files">…</x-tab>
|
||||
<x-tab name="people">…</x-tab>
|
||||
</x-tabs>
|
||||
```
|
||||
|
||||
### `<x-section-nav>`
|
||||
|
||||
Navigation between the sections of one area (settings, admin): secondary tabs as links from `sm` (wrapping onto a grid rather than scrolling), a menu picker below. `items`: `['title', 'url', 'icon', 'active', 'badge']` — current when `active` or its `url` is the request's. `label`, `no-wire-navigate`.
|
||||
|
||||
### `<x-account-menu>`
|
||||
|
||||
An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; default the name's initials), items in the slot, a theme item (`:theme="false"` to drop it), and a `footer` slot for signing out. `label`, `position`.
|
||||
|
||||
```blade
|
||||
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email">
|
||||
<x-menu-item label="Settings" icon="settings" :link="route('settings')" />
|
||||
<x-slot:footer>
|
||||
<form method="POST" action="{{ route('logout') }}">@csrf<x-menu-item label="Sign out" icon="logout" type="submit" /></form>
|
||||
</x-slot:footer>
|
||||
</x-account-menu>
|
||||
```
|
||||
|
||||
### `<x-theme-toggle>`
|
||||
|
||||
Switches `$store.theme`: `mode="toggle"` (default, light/dark icon button), `cycle` (light → dark → system), `picker` (segmented buttons for settings pages). Every toggle on a page shares the store.
|
||||
|
||||
### `<x-table>`, `<x-sort-header>`
|
||||
|
||||
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>` (`size="xs"` for a dense one); cell utilities (`text-end`, `whitespace-nowrap`) always win. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`.
|
||||
|
||||
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
|
||||
|
||||
```blade
|
||||
<div class="overflow-x-auto">
|
||||
<x-table>
|
||||
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach ($shares as $share)
|
||||
<tr data-list-row wire:key="share-{{ $share->id }}">
|
||||
<td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td>
|
||||
<td class="text-end tabular-nums">{{ $share->size }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
{{ $shares->links() }}
|
||||
```
|
||||
|
||||
Pagination: `$paginator->links()` (Laravel and Livewire, full and simple/cursor) is drawn in M3 — current page in secondary-container, "Page 2 of 7" on a phone. Turn off with `config('livewire-material.pagination')` = `false`; published `vendor/pagination` or `vendor/livewire` views still win.
|
||||
|
||||
## Testing the design
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
it('uses only what compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
->forbidColours(['tertiary']) // roles this application's rules leave out
|
||||
->violations())->toBe([]);
|
||||
});
|
||||
```
|
||||
|
||||
It fails on maryUI tags, daisyUI classes, colours the theme does not declare, unknown symbol names and Blade directives written inside a component tag (where they do not compile), with `path:line` for each.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Components are anonymous Blade components: `<x-name>` without a prefix, or `<x-{prefix}::name>` when `config('livewire-material.prefix')` is set; `<x-livewire-material::name>` always works.
|
||||
- Write class names out whole. Tailwind cannot compile `'text-'.$tone` or `type-{{ $size }}`, and the design guard cannot read them.
|
||||
- The showcase at `/material` (local only, `MATERIAL_SHOWCASE=true` to force it) renders every token and component.
|
||||
|
||||
## Livewire traps
|
||||
|
||||
- Blade directives do not compile inside a component tag's attributes: `<x-foo x-show="ok(@js($value))">` reaches the browser as literal text. On a component tag use `{{ }}` and `:prop` bindings, or put the Alpine on a plain element inside the slot.
|
||||
- Never pass `hidden`, a display utility or a position (`absolute`, `relative`) to a component: it is merged beside the component's own and whichever Tailwind emits last wins. Wrap the component in an element that carries it. A variant that only hides (`max-sm:hidden`) is safe.
|
||||
- `$attributes->wire('model')->value()` is `false`, not `null`, when there is no `wire:model`, and `filled(false)` is true. Normalise with `?: null`.
|
||||
- End every statement in a multi-line Alpine attribute with `;`: an inline `@if … @endif` inside it swallows the newline after it.
|
||||
@@ -29,7 +29,7 @@ Read this section before you write a test.
|
||||
- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project.
|
||||
- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test.
|
||||
- Write a feature test first. Write a unit test only for logic that does not use the framework.
|
||||
- Write a feature test for every behavior reachable through a request. Real-browser tests require `pestphp/pest-plugin-browser` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test.
|
||||
- Write a browser test only for behavior in JavaScript that a feature test cannot reach. Put a browser test in `tests/Browser`, and call `assertNoJavaScriptErrors()` in it.
|
||||
- Judge an architecture test by the convention it protects, not by the rules above. An `arch()` test declares a rule for an entire directory, such as the parent class of every model, the classes that may use an enum, or the methods every factory declares. It intentionally checks declarations and fails when a new file breaks the convention.
|
||||
- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it.
|
||||
|
||||
|
||||
@@ -31,6 +31,32 @@ An HTTP test shows that the endpoint performs authorization. It cannot identify
|
||||
- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization.
|
||||
- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists.
|
||||
|
||||
## Browser Tests
|
||||
|
||||
Write a browser test only for JavaScript behavior that an HTTP test cannot reach, such as modal interaction, drag-and-drop, live search, or client-side validation. Browser tests are slower than HTTP tests and can fail for reasons unrelated to the code under test.
|
||||
|
||||
- Assert the state that the user can see, and assert the state in the database that the interaction saves.
|
||||
- Wait until the test reaches the required state. Do not wait for a fixed number of seconds, which can fail on a slower machine.
|
||||
- Call `assertNoJavaScriptErrors()` in each browser test. An error in the console is a defect.
|
||||
|
||||
### Where a Browser Test Lives and How to Run It
|
||||
|
||||
The plugin runs browser tests as normal Pest tests, so they need no separate suite. Put them in `tests/Browser` to separate them from faster tests and run the directory with one command.
|
||||
|
||||
- Run a browser test with `vendor/bin/pest tests/Browser`, and add `--parallel` for the complete suite.
|
||||
- Run `vendor/bin/pest --debug` to open the window of the browser and to pause at a failure. Use `--headed` to watch a run that passes.
|
||||
- Add `--browser firefox` or `--browser safari` to run the test in a different browser. The default browser is Chrome.
|
||||
- The run needs Playwright and a browser on the machine. Follow the plugin documentation for local and CI installation commands.
|
||||
- Fetch `https://pestphp.com/docs/browser-testing` for the interactions, the assertions, and the devices that the plugin gives.
|
||||
|
||||
### Browser Test Pitfalls
|
||||
|
||||
- The plugin waits five seconds for an element. Raise the value with `pest()->browser()->timeout(10000)` in `Pest.php` for a page that is slower, and do not add a wait for a number of seconds to the test.
|
||||
- Apply `RefreshDatabase` to the browser tests in `Pest.php`. A browser test hits the application through a real request, and the records that it leaves break the next test.
|
||||
- Add `tests/Browser/Screenshots` to `.gitignore`. A failure writes a screenshot, and the file is not part of the repository.
|
||||
- Give `withKeyDown()` a key code, such as `KeyA`. A letter such as `'a'` gives the lowercase character, whatever modifier the test holds.
|
||||
- Interact inside the callback of `withinFrame()`. An interaction outside the callback does not reach the frame.
|
||||
|
||||
## Testing Validation
|
||||
|
||||
- Write one test for each validation rule when each failure represents a separate contract.
|
||||
|
||||
@@ -42,9 +42,6 @@ jobs:
|
||||
- name: Install Node Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
@@ -57,6 +54,9 @@ jobs:
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
|
||||
|
||||
@@ -29,9 +29,6 @@ jobs:
|
||||
with:
|
||||
php-version: '8.5'
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
|
||||
@@ -42,9 +42,6 @@ jobs:
|
||||
- name: Install Node Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
@@ -57,5 +54,8 @@ jobs:
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
|
||||
+25
-1
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- The share created page offers the link as a QR code: "Show QR code" opens it in a dialog (full screen on a phone) and "Download" saves it as a PNG. For a password-protected share the dialog reminds that recipients also need the password; the code holds only the link.
|
||||
- A "Share…" button on the same page opens the device's share sheet with the link, where the browser has one (mostly phones and Safari).
|
||||
|
||||
### Changed
|
||||
|
||||
- The interface is rebuilt on [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material), a Material 3 Expressive component library, replacing Mary UI and DaisyUI. Every page — upload, share created, download, sign-in, settings, admin and the setup wizard — uses its components, in a colour scheme generated from SealShare's indigo.
|
||||
- The theme follows the system's light or dark setting until a user picks Light, Dark or System in Settings → Appearance, or from the account menu. A theme chosen in 1.x is kept.
|
||||
- A floating toolbar centred at the bottom of every page replaces the sidebar and header layouts. Signed-in users reach Upload and the admin pages from it, and Settings, the theme and Log out from its account menu. The site's name and logo head the upload and download pages.
|
||||
- Confirmations for deleting a share, removing the logo, clearing the system password and deleting the account are dialogs instead of browser prompts, and "Saved." messages are snackbars.
|
||||
- The two-factor setup opens full screen on a phone.
|
||||
- HTTP error pages and Markdown mail (password reset, email verification) use the same Material design and colours. Set `MAIL_MARKDOWN_THEME=default` to get Laravel's mail theme back.
|
||||
- Signing in now lands on the admin dashboard. The starter kit's placeholder `/dashboard` page is gone.
|
||||
- The Docker build installs Composer packages before building the frontend, because the stylesheet imports Livewire Material from `vendor/`.
|
||||
- Removed the dependencies `robsontenorio/mary`, `daisyui` and `alpinejs` (Livewire bundles Alpine). The Bunny Fonts request is gone.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The admin dashboard passed its sort column and direction straight to the query, so a crafted Livewire request could order shares by any column or cause a server error. It now sorts only by the columns it shows and otherwise falls back to newest first.
|
||||
|
||||
## [1.2.0] - 2026-09-10
|
||||
|
||||
### Added
|
||||
@@ -81,7 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Dark themed UI built with Livewire, Alpine.js, Tailwind CSS and DaisyUI.
|
||||
- Docker images published to `ghcr.io/surtic86/sealshare`, served by FrankenPHP via Laravel Octane.
|
||||
|
||||
[Unreleased]: https://github.com/surtic86/SealShare/compare/v1.2.0...HEAD
|
||||
[Unreleased]: https://github.com/surtic86/SealShare/compare/v2.0.0...HEAD
|
||||
[2.0.0]: https://github.com/surtic86/SealShare/compare/v1.2.0...v2.0.0
|
||||
[1.2.0]: https://github.com/surtic86/SealShare/compare/v1.1.0...v1.2.0
|
||||
[1.1.0]: https://github.com/surtic86/SealShare/compare/v1.0.1...v1.1.0
|
||||
[1.0.1]: https://github.com/surtic86/SealShare/compare/v1.0.0...v1.0.1
|
||||
|
||||
@@ -187,4 +187,16 @@ When working on Octane-specific features (concurrency, shared tables, memory, dr
|
||||
- Run `vendor/bin/pest` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments.
|
||||
- After the feature tests pass, ask the user to run the complete suite with `php artisan test --compact`.
|
||||
|
||||
=== nonameweb/livewire-material/core rules ===
|
||||
|
||||
## Livewire Material
|
||||
|
||||
This application uses `nonameweb/livewire-material`: Material 3 Expressive components for Laravel and Livewire, built on Tailwind CSS. It replaces UI kits such as maryUI, daisyUI and Flux in this application.
|
||||
|
||||
- Components are anonymous Blade components, unprefixed unless `config/livewire-material.php` sets a `prefix`. Before writing or changing a view that uses them, activate the `livewire-material-development` skill for the props, slots and traps of each component.
|
||||
- Never write maryUI tags (`<x-mary-*>`) or daisyUI classes (`btn`, `card`, `badge`, `bg-base-200`, `text-base-content`…). They compile to nothing and fail silently.
|
||||
- Every layout includes `<x-theme-script />` in `<head>` before `@vite`. The colour scheme is generated with `php artisan material:scheme` — never edit `resources/css/material-scheme.css` by hand.
|
||||
- While the application runs locally, every token and component renders in the application's own scheme at `/material` (the showcase).
|
||||
- HTTP error pages and the Markdown mail theme come from the package. Change error wording by publishing `--tag=livewire-material-errors`; select the mail theme with `MAIL_MARKDOWN_THEME=livewire-material::mail.theme`.
|
||||
|
||||
</laravel-boost-guidelines>
|
||||
|
||||
+18
-16
@@ -1,20 +1,5 @@
|
||||
# ============================================
|
||||
# Stage 1: Build frontend assets
|
||||
# ============================================
|
||||
FROM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --prefer-offline
|
||||
|
||||
COPY vite.config.js ./
|
||||
COPY resources/ ./resources/
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Install PHP dependencies
|
||||
# Stage 1: Install PHP dependencies
|
||||
# ============================================
|
||||
FROM composer:2 AS vendor
|
||||
|
||||
@@ -33,6 +18,23 @@ COPY . .
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Build frontend assets
|
||||
# ============================================
|
||||
# After Composer: the stylesheet and script import Livewire Material from vendor/.
|
||||
FROM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --prefer-offline
|
||||
|
||||
COPY vite.config.js ./
|
||||
COPY resources/ ./resources/
|
||||
COPY --from=vendor /app/vendor/nonameweb ./vendor/nonameweb
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Production image (FrankenPHP/Octane)
|
||||
# ============================================
|
||||
|
||||
@@ -5,7 +5,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
## Features
|
||||
|
||||
- **File Uploading** — Drag & drop or browse to upload single/multiple files and folders with real-time progress
|
||||
- **Shareable Links** — Each upload generates a unique link for recipients
|
||||
- **Shareable Links** — Each upload generates a unique link for recipients, also as a QR code (saved as a PNG) or through the device's share sheet
|
||||
- **End-to-End Encryption** — All files encrypted at rest using AES-256-GCM (chunked, streaming)
|
||||
- **Password Protection** — Optionally protect shares with a password
|
||||
- **Expiration** — Shares auto-expire after a configurable duration (1 hour to 30 days)
|
||||
@@ -16,9 +16,9 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
||||
- **Site Branding** — Custom logo, title, and description
|
||||
- **System Password** — Optional global password gate to restrict upload access
|
||||
- **User Authentication** — Login, registration, password reset, email verification
|
||||
- **User Authentication** — Login, password reset, email verification
|
||||
- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify
|
||||
- **Dark Mode** — Dark themed UI with DaisyUI components
|
||||
- **Light and Dark Themes** — Material 3 Expressive design that follows the system theme, or light or dark by choice
|
||||
- **Setup Wizard** — First-run wizard to create the initial admin account
|
||||
|
||||
## Tech Stack
|
||||
@@ -27,11 +27,11 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
|-------|-----------|
|
||||
| **Framework** | Laravel 13 |
|
||||
| **Application Server** | FrankenPHP (via Laravel Octane) |
|
||||
| **Frontend** | Livewire 4, Alpine.js, Tailwind CSS 4, DaisyUI 5, Mary UI |
|
||||
| **Frontend** | Livewire 4, Tailwind CSS 4, [Livewire Material](https://gitea.nonameweb.ch/noNameWEB/livewire-material) (Material 3 Expressive) |
|
||||
| **Authentication** | Laravel Fortify |
|
||||
| **Encryption** | Chunked AES-256-GCM with PBKDF2-SHA256 key derivation |
|
||||
| **ZIP Downloads** | Native PHP ZipArchive |
|
||||
| **Testing** | Pest 4 |
|
||||
| **Testing** | Pest 5 with browser tests (Playwright) |
|
||||
| **Code Style** | Laravel Pint |
|
||||
| **Build Tool** | Vite |
|
||||
|
||||
|
||||
@@ -14,37 +14,38 @@ class AdminDashboard extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
/** @var array<string, string> */
|
||||
/**
|
||||
* The columns the table can be sorted by.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const SORTABLE = ['token', 'files_count', 'total_size', 'download_count', 'expires_at', 'created_at'];
|
||||
|
||||
/** @var array{column: string, direction: string} */
|
||||
public array $sortBy = ['column' => 'created_at', 'direction' => 'desc'];
|
||||
|
||||
/** The share the delete dialog is asking about, while it is open. */
|
||||
public ?int $deletingShareId = null;
|
||||
|
||||
public function deleteShare(int $shareId, ShareService $shareService): void
|
||||
{
|
||||
$share = Share::query()->findOrFail($shareId);
|
||||
$shareService->deleteShare($share);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string|bool>>
|
||||
*/
|
||||
public function headers(): array
|
||||
{
|
||||
return [
|
||||
['key' => 'token', 'label' => __('Token')],
|
||||
['key' => 'files_count', 'label' => __('Files')],
|
||||
['key' => 'total_size', 'label' => __('Size')],
|
||||
['key' => 'download_count', 'label' => __('Downloads')],
|
||||
['key' => 'expires_at', 'label' => __('Expires')],
|
||||
['key' => 'created_at', 'label' => __('Created')],
|
||||
];
|
||||
$this->deletingShareId = null;
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
$shareService = app(ShareService::class);
|
||||
|
||||
// The sort comes from the browser: only a known column and direction reach the query.
|
||||
$column = in_array($this->sortBy['column'] ?? null, self::SORTABLE, true) ? $this->sortBy['column'] : 'created_at';
|
||||
$direction = ($this->sortBy['direction'] ?? null) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$shares = Share::query()
|
||||
->withCount('files')
|
||||
->orderBy($this->sortBy['column'], $this->sortBy['direction'])
|
||||
->orderBy($column, $direction)
|
||||
->paginate(15);
|
||||
|
||||
return view('livewire.admin.admin-dashboard', [
|
||||
@@ -56,7 +57,6 @@ class AdminDashboard extends Component
|
||||
'totalFiles' => ShareFile::query()->count(),
|
||||
'usedSpace' => $shareService->getTotalUsedSpace(),
|
||||
'maxQuota' => $shareService->getMaxStorageQuota(),
|
||||
'headers' => $this->headers(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@ use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
{
|
||||
use Toasts;
|
||||
use WithFileUploads;
|
||||
|
||||
public string $systemPassword = '';
|
||||
@@ -34,6 +36,12 @@ class AdminSettings extends Component
|
||||
|
||||
public $siteLogo;
|
||||
|
||||
/** Whether the "Remove the logo?" dialog is open. */
|
||||
public bool $confirmingLogoRemoval = false;
|
||||
|
||||
/** Whether the "Remove the system password?" dialog is open. */
|
||||
public bool $confirmingPasswordRemoval = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
@@ -113,7 +121,7 @@ class AdminSettings extends Component
|
||||
|
||||
$this->systemPassword = '';
|
||||
|
||||
session()->flash('message', __('Settings saved successfully.'));
|
||||
$this->success(__('Settings saved successfully.'));
|
||||
}
|
||||
|
||||
public function removeLogo(): void
|
||||
@@ -125,14 +133,18 @@ class AdminSettings extends Component
|
||||
Setting::set('site_logo', null);
|
||||
}
|
||||
|
||||
session()->flash('message', __('Logo removed.'));
|
||||
$this->confirmingLogoRemoval = false;
|
||||
|
||||
$this->success(__('Logo removed.'));
|
||||
}
|
||||
|
||||
public function clearSystemPassword(): void
|
||||
{
|
||||
Setting::set('system_password', null);
|
||||
|
||||
session()->flash('message', __('System password cleared.'));
|
||||
$this->confirmingPasswordRemoval = false;
|
||||
|
||||
$this->success(__('System password cleared.'));
|
||||
}
|
||||
|
||||
public function render(): mixed
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Services\QrCodeService;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -18,6 +20,12 @@ class ShareCreated extends Component
|
||||
|
||||
public function render(): mixed
|
||||
{
|
||||
return view('livewire.share-created');
|
||||
$shareUrl = route('share.download', $this->share);
|
||||
|
||||
return view('livewire.share-created', [
|
||||
'shareUrl' => $shareUrl,
|
||||
'qrCodeSvg' => app(QrCodeService::class)->svg($shareUrl),
|
||||
'siteTitle' => Setting::get('site_title') ?: config('app.name'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ class FortifyServiceProvider extends ServiceProvider
|
||||
Fortify::verifyEmailView(fn () => view('pages::auth.verify-email'));
|
||||
Fortify::twoFactorChallengeView(fn () => view('pages::auth.two-factor-challenge'));
|
||||
Fortify::confirmPasswordView(fn () => view('pages::auth.confirm-password'));
|
||||
Fortify::registerView(fn () => view('pages::auth.register'));
|
||||
Fortify::resetPasswordView(fn () => view('pages::auth.reset-password'));
|
||||
Fortify::requestPasswordResetLinkView(fn () => view('pages::auth.forgot-password'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use BaconQrCode\Common\ErrorCorrectionLevel;
|
||||
use BaconQrCode\Encoder\Encoder;
|
||||
use BaconQrCode\Renderer\Color\Rgb;
|
||||
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
|
||||
use BaconQrCode\Renderer\ImageRenderer;
|
||||
use BaconQrCode\Renderer\RendererStyle\Fill;
|
||||
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
|
||||
use BaconQrCode\Writer;
|
||||
|
||||
class QrCodeService
|
||||
{
|
||||
/**
|
||||
* The QR code's width and height in the SVG, in pixels: the size a canvas draws it at.
|
||||
*/
|
||||
public const SIZE = 1024;
|
||||
|
||||
/**
|
||||
* Draw the contents as a QR code in SVG: black on white with a four-module quiet zone and
|
||||
* error correction M, the most reliable to scan from a screen or a print. The XML declaration
|
||||
* is dropped so the markup can sit inline in a page.
|
||||
*/
|
||||
public function svg(string $contents): string
|
||||
{
|
||||
$svg = (new Writer(
|
||||
new ImageRenderer(
|
||||
new RendererStyle(self::SIZE, 4, null, null, Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))),
|
||||
new SvgImageBackEnd,
|
||||
),
|
||||
))->writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M());
|
||||
|
||||
return trim(substr($svg, strpos($svg, "\n") + 1));
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -6,6 +6,9 @@
|
||||
"guidelines": true,
|
||||
"herd_mcp": false,
|
||||
"mcp": true,
|
||||
"packages": [
|
||||
"nonameweb/livewire-material"
|
||||
],
|
||||
"sail": false,
|
||||
"skills": [
|
||||
"infer-conventions",
|
||||
@@ -14,6 +17,7 @@
|
||||
"testing-best-practices",
|
||||
"octane-development",
|
||||
"livewire-development",
|
||||
"tailwindcss-development"
|
||||
"tailwindcss-development",
|
||||
"livewire-material-development"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-2
@@ -10,12 +10,13 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.5",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"laravel/fortify": "^1.30",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/octane": "^2.13",
|
||||
"laravel/tinker": "^3.0",
|
||||
"livewire/livewire": "^4.0",
|
||||
"robsontenorio/mary": "^2.7"
|
||||
"nonameweb/livewire-material": "^1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -26,6 +27,7 @@
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^5.1",
|
||||
"pestphp/pest-plugin-browser": "^5.0",
|
||||
"pestphp/pest-plugin-laravel": "^5.0"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -99,5 +101,11 @@
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"repositories": {
|
||||
"livewire-material": {
|
||||
"type": "vcs",
|
||||
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1661
-474
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'],
|
||||
],
|
||||
|
||||
];
|
||||
@@ -115,4 +115,26 @@ return [
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Markdown Mail Settings
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Markdown mail wears Livewire Material's theme, coloured from the light
|
||||
| scheme in resources/css/material-scheme.json.
|
||||
|
|
||||
*/
|
||||
|
||||
'markdown' => [
|
||||
'theme' => env('MAIL_MARKDOWN_THEME', 'livewire-material::mail.theme'),
|
||||
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
|
||||
'extensions' => [
|
||||
// \League\CommonMark\Extension\Strikethrough\StrikethroughExtension::class,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -12,6 +12,9 @@ services:
|
||||
APP_KEY: ${APP_KEY:-}
|
||||
APP_URL: http://localhost:8000
|
||||
APP_ENV: local
|
||||
# Compiled views stay in the container. The host shares storage/ through the mount, and
|
||||
# compiled Livewire components hold absolute paths (/app/… here, the checkout's path there).
|
||||
VIEW_COMPILED_PATH: /tmp/views
|
||||
APP_DEBUG: "true"
|
||||
SERVER_NAME: ":8000"
|
||||
DB_CONNECTION: sqlite
|
||||
|
||||
@@ -13,6 +13,11 @@ max_input_time = ${PHP_MAX_INPUT_TIME:-300}
|
||||
memory_limit = ${PHP_MEMORY_LIMIT:-512M}
|
||||
EOF
|
||||
|
||||
if [ ! -f vendor/autoload.php ]; then
|
||||
echo "[dev] Installing PHP dependencies..."
|
||||
composer install --no-interaction 2>&1
|
||||
fi
|
||||
|
||||
echo "[dev] Installing Node dependencies..."
|
||||
npm install 2>&1
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ RUN install-php-extensions \
|
||||
# Install Node.js for Vite / frontend asset building
|
||||
RUN apk add --no-cache nodejs npm
|
||||
|
||||
# Composer, for a checkout without vendor/: the assets import Livewire Material from it
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/dev-entrypoint.sh /usr/local/bin/dev-entrypoint.sh
|
||||
|
||||
@@ -74,8 +74,8 @@ The package's decisions are in its own plan. SealShare's:
|
||||
- **Converts after `1.0.0`, in one pass, by hand** (~150 tags; no codemod), on branch
|
||||
`material`, released as **2.0.0**.
|
||||
- **Moving SealShare to Gitea is a separate plan** — this plan works wherever it is hosted.
|
||||
- **Seed `#4f46e5` (the favicon's indigo), Tonal Spot**; Vibrant generated alongside on the
|
||||
upload page for one visual comparison before committing.
|
||||
- **Seed `#4f46e5` (the favicon's indigo), Vibrant** — chosen after comparing it with Tonal Spot
|
||||
on the upload page in both themes (2026-09-13): Tonal Spot read grey-lavender on this seed.
|
||||
- **Theme default `system`**, storage key `sealshare-theme`, legacy `mary-theme` adopted once.
|
||||
Appearance is a Light / Dark / System connected button group.
|
||||
- **One top app bar everywhere** — logo and site title; a theme toggle for guests, an avatar
|
||||
@@ -133,8 +133,8 @@ Step numbers continue the original plan's, so references elsewhere stay valid.
|
||||
from `vendor/`, `./material-scheme.css`, `@source '../views'` and the package's views; drop
|
||||
the daisyUI plugin, maryUI and pagination `@source`s and the swap safelist.
|
||||
`resources/js/app.js` imports the package JS. Run
|
||||
`php artisan material:scheme "#4f46e5" --variant=tonal-spot`; generate Vibrant to a
|
||||
temporary output, compare on the upload page in both themes, commit the chosen one.
|
||||
`php artisan material:scheme "#4f46e5" --variant=vibrant` (chosen over Tonal Spot after
|
||||
comparing both on the upload page in both themes).
|
||||
38. **Head and theme.** `partials/head`: remove fonts.bunny.net; include `<x-theme-script />`
|
||||
before `@vite` (it currently sits outside `<head>`). Publish the config with
|
||||
`theme.default = system`, `storage_key = sealshare-theme`, `legacy_keys = ['mary-theme']`.
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Share by QR code and share sheet
|
||||
|
||||
## Goal
|
||||
|
||||
After an upload, the share created page offers two more ways to hand a share over besides
|
||||
copying the link: a QR code, in a dialog, that another device scans (and that downloads as a
|
||||
PNG for chats and mails), and, where the browser has one, the device's native share sheet. Both
|
||||
carry only the share's link — never a password.
|
||||
|
||||
## Context
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.x, Pest 5 with browser tests, Octane
|
||||
(FrankenPHP). Production image `dunglas/frankenphp:php8.5-alpine` with `intl`, `pcntl`, `zip`
|
||||
added; it has `xmlwriter` and `iconv`, and neither `gd` nor `imagick`.
|
||||
- `bacon/bacon-qr-code` v3.1.1 is installed through `laravel/fortify` (`^3.0`), which draws the
|
||||
two-factor setup QR with `Writer` + `ImageRenderer` + `SvgImageBackEnd` and strips the XML
|
||||
declaration (`TwoFactorAuthenticatable::twoFactorQrCodeSvg()`). SVG needs no image extension;
|
||||
a server-side PNG would.
|
||||
- `app/Livewire/ShareCreated.php` (`#[Layout('layouts.app')]`, `public Share $share`) renders
|
||||
`resources/views/livewire/share-created.blade.php`: the link as
|
||||
`<x-input :value="route('share.download', $share)" readonly copyable data-test="share-link">`,
|
||||
four `<x-stat>`, an info alert for password-protected shares, and "Upload More". The route
|
||||
`share/{share:token}/created` sits behind `system.password`, like the upload page.
|
||||
- The two-factor dialog (`pages/settings/⚡two-factor`) is the in-app pattern: the SVG inline
|
||||
on a `bg-white` panel inside `<x-modal fullscreen>`, so it stays scannable in dark mode.
|
||||
- `<x-modal>` without `wire:model` opens from `open` in the surrounding Alpine scope and gives
|
||||
`close()`; `materialToast()` is global; `resources/js/app.js` imports only the package JS.
|
||||
- Services live in `app/Services` (`ShareService`, `FileEncryptionService`). Share passwords
|
||||
are hashed and turned into a key; the plain password is never stored.
|
||||
- No feature test covers `ShareCreated` yet; `tests/Browser/SealShareTest.php` copies the link
|
||||
on that page.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Only on the share created page** — that is where a share is handed over; the admin
|
||||
dashboard and the download page stay as they are.
|
||||
- **A "Show QR code" button opens a dialog** — the page stays as calm as now; the dialog is
|
||||
`<x-modal fullscreen>` (the whole screen on a phone, to hold up to another camera) with the
|
||||
QR on a white panel.
|
||||
- **Download is a PNG made in the browser** — the dialog's SVG is drawn onto a canvas and saved
|
||||
as `share-<token>.png`; no server route and no `gd`/`imagick` in the Docker images.
|
||||
- **Require `bacon/bacon-qr-code:^3.0` directly** — the version already installed through
|
||||
Fortify, declared so SealShare does not depend on Fortify keeping it.
|
||||
- **Password-protected shares get a note in the dialog** — "Recipients also need the password."
|
||||
The QR holds the link only.
|
||||
- **A "Share…" button opens the native share sheet** — shown only where `navigator.share` exists
|
||||
(mostly phones and Safari), sharing `{ title: <site title>, url: <share link> }`.
|
||||
Cancelling the sheet (`AbortError`) does nothing; any other failure shows an error snackbar.
|
||||
- **In 2.0.0, on the `material` branch** — 2.0.0 is not released and the page was just rebuilt
|
||||
there; one PR, one changelog entry.
|
||||
- **Black modules on white, a four-module quiet zone, error correction M** — the most reliable
|
||||
to scan from a screen or a print; the site's theme does not tint it.
|
||||
- **The SVG is drawn at 1024 × 1024** — CSS scales it down in the dialog, and the canvas draws it
|
||||
at its own size, so the PNG is sharp in every browser (Safari rasterises an SVG at its
|
||||
intrinsic size).
|
||||
- **Generated server-side with the page, opened client-side** — the SVG is a few kilobytes and
|
||||
the dialog needs no round trip; the dialog's `open` is Alpine state, not a Livewire property.
|
||||
- **`App\Services\QrCodeService::svg(string $contents): string`** — one place that knows Bacon's
|
||||
API; `ShareCreated::render()` passes `shareUrl`, `qrCodeSvg` and `siteTitle` to the view (as
|
||||
`FileUploader::render()` passes `siteTitle`), so the URL is built once.
|
||||
- **The share and download behaviour lives in `resources/js/share-created.js`** — an
|
||||
`Alpine.data('shareActions', …)` with `canShare`, `share()` and `downloadQrCode()`, imported
|
||||
by `app.js`, instead of long inline Alpine in the view.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- QR codes on the admin dashboard or the download page.
|
||||
- Sharing the QR image itself through the share sheet (`navigator.share({ files })`).
|
||||
- An SVG download, a server-rendered PNG, or a print layout.
|
||||
- A logo in the middle of the QR, or colours from the theme.
|
||||
- Putting the password (or any secret beyond the link's token) into the QR or the share sheet.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Dependency.** `composer require bacon/bacon-qr-code:^3.0` (stays at v3.1.1).
|
||||
2. **Service.** `php artisan make:class Services/QrCodeService`: `svg(string $contents): string`
|
||||
renders with `new Writer(new ImageRenderer(new RendererStyle(1024, 4, null, null,
|
||||
Fill::uniformColor(new Rgb(255, 255, 255), new Rgb(0, 0, 0))), new SvgImageBackEnd))`,
|
||||
`writeString($contents, Encoder::DEFAULT_BYTE_MODE_ENCODING, ErrorCorrectionLevel::M())`, and
|
||||
drops the XML declaration as Fortify does.
|
||||
3. **Component.** `ShareCreated::render()` builds `$shareUrl = route('share.download',
|
||||
$this->share)` and passes `shareUrl`, `qrCodeSvg` (from the service) and `siteTitle`
|
||||
(`Setting::get('site_title') ?: config('app.name')`) to the view; the link field uses
|
||||
`$shareUrl`.
|
||||
4. **JavaScript.** `resources/js/share-created.js` registers on `alpine:init`
|
||||
`Alpine.data('shareActions', ({ url, title, filename, messages }) => …)`, `messages` holding
|
||||
the translated `shareFailed` and `downloadFailed`:
|
||||
- `open: false` for the dialog;
|
||||
- `canShare`: `typeof navigator.share === 'function'`, read once at init;
|
||||
- `share()`: `navigator.share({ title, url })`, ignoring `AbortError`, otherwise
|
||||
`materialToast(messages.shareFailed, { type: 'error' })`;
|
||||
- `downloadQrCode(svg)`: takes the `<svg>` element (the button passes
|
||||
`$el.closest('dialog').querySelector('[data-qr-code] svg')` — the dialog has its own Alpine
|
||||
scope, so `$refs` from the outer one would not reach it), serialises it, loads it into an
|
||||
`Image` from a Blob URL, draws it on a 1024 × 1024 canvas with a white fill and
|
||||
`imageSmoothingEnabled = false`, `toBlob('image/png')`, clicks a temporary `<a download>`
|
||||
named `filename`, and revokes both object URLs; a failed load or an empty blob shows
|
||||
`materialToast(messages.downloadFailed, { type: 'error' })`.
|
||||
`resources/js/app.js` imports it after the package.
|
||||
5. **View.** In `share-created.blade.php`, wrap the link and actions in
|
||||
`<div x-data="shareActions({ url: @js($shareUrl), title: @js($siteTitle), filename: @js('share-'.$share->token.'.png'), messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]) })">`
|
||||
(a plain element, so `@js` compiles there):
|
||||
- under the link field, a row with `<x-button :label="__('Show QR code')" icon="qr_code_2"
|
||||
variant="tonal" x-on:click="open = true" data-test="show-qr-code" />` and, in a
|
||||
`<span x-show="canShare" x-cloak>` wrapper, `<x-button :label="__('Share…')" icon="share"
|
||||
variant="tonal" x-on:click="share()" data-test="share-sheet" />`;
|
||||
- `<x-modal fullscreen :title="__('Scan to open the share')">` holding
|
||||
`<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>`
|
||||
(the SVG is generated from the app's own URL — no user input), then, for a
|
||||
password-protected share, `<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />`,
|
||||
and actions `<x-button :label="__('Download')" icon="download" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />`
|
||||
and `<x-button :label="__('Close')" x-on:click="close()" />`.
|
||||
"Upload More" and the stats stay where they are.
|
||||
6. **Docs.** README: the "Shareable Links" feature line mentions the QR code and share sheet.
|
||||
CHANGELOG `2.0.0`: an "Added" section (before "Changed", as Keep a Changelog orders them)
|
||||
with an entry for both.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/QrCodeServiceTest.php` (the service needs no application):
|
||||
`svg()` returns markup starting with `<svg`, without an XML declaration, 1024 wide, and the
|
||||
same markup for the same contents and different markup for different contents.
|
||||
- `tests/Feature/ShareCreatedTest.php` (new): the page shows the link, and its HTML contains
|
||||
exactly `QrCodeService::svg(route('share.download', $share))` inside the dialog, the
|
||||
"Show QR code" and "Share…" buttons, and the download filename `share-<token>.png`; the
|
||||
password note appears for a protected share and not for an open one.
|
||||
- `tests/Browser/SealShareTest.php`:
|
||||
- "Show QR code" opens the dialog with the QR on a white panel; Download produces an
|
||||
`image/png` blob named `share-<token>.png` (recorded by stubbing
|
||||
`HTMLAnchorElement.prototype.click` through `window.eval`), with no JavaScript errors;
|
||||
- the Share button is hidden where `navigator.share` is missing, and `share()` passes the
|
||||
link to a stubbed `navigator.share`, stays quiet on `AbortError` and shows the error snackbar
|
||||
on any other rejection.
|
||||
- `DesignLanguageTest` keeps passing (`qr_code_2`, `share`, `download` are Material Symbols;
|
||||
`bg-white` is a token).
|
||||
- Narrow runs per step, then the full suite.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scanning reliability** is not proven by the tests (no decoder in the stack): the feature
|
||||
test pins the SVG to Bacon's output for the exact URL, and a manual scan with a phone during
|
||||
review is the check.
|
||||
- **The QR is only as right as the link.** Behind a reverse proxy with a wrong `APP_URL` or
|
||||
trusted-proxy setting, both point at the wrong host — unchanged from today.
|
||||
- **Safari and canvas.** Drawing an SVG from a Blob URL onto a canvas works in current
|
||||
Chrome, Firefox and Safari without tainting the canvas; the browser test runs in Chromium
|
||||
locally and in CI, and the three-engine check is manual.
|
||||
- **Share sheet on desktop** exists in Safari and Chromium on some platforms and not in
|
||||
Firefox; the button's absence there is by design.
|
||||
Generated
+31
-36
@@ -6,16 +6,15 @@
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"alpinejs": "^3.17.2",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"concurrently": "^10.0.5",
|
||||
"daisyui": "^5.7.32",
|
||||
"laravel-vite-plugin": "^3.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar": "^5.0.0"
|
||||
"chokidar": "^5.0.0",
|
||||
"playwright": "^1.63.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||
@@ -609,30 +608,6 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz",
|
||||
"integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.5.42"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.42",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz",
|
||||
"integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alpinejs": {
|
||||
"version": "3.17.2",
|
||||
"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.17.2.tgz",
|
||||
"integrity": "sha512-xbnG3LlnmFkiNO49C+qhqjEDqOdB0snKqvT48ZVp8TakpZOpsoQbc/jAxpptQRjZi7c9rLeCfvwQ/XOSirIZFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "~3.5.40"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz",
|
||||
@@ -824,15 +799,6 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.7.32",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.7.32.tgz",
|
||||
"integrity": "sha512-8JQFneb+okHxzPimTGvRKX+5XF03GyAwGw7BaV6+6XpzW0klGuzc4rdfUpLQxIYPKEziwwlrsYfcxoltF2Rowg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/saadeghi/daisyui?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1320,6 +1286,35 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
|
||||
+2
-3
@@ -8,10 +8,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"alpinejs": "^3.17.2",
|
||||
"autoprefixer": "^10.5.5",
|
||||
"concurrently": "^10.0.5",
|
||||
"daisyui": "^5.7.32",
|
||||
"laravel-vite-plugin": "^3.2.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.2.2"
|
||||
@@ -24,6 +22,7 @@
|
||||
"shell-quote": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chokidar": "^5.0.0"
|
||||
"chokidar": "^5.0.0",
|
||||
"playwright": "^1.63.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Browser">
|
||||
<directory>tests/Browser</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
|
||||
+17
-5
@@ -1,10 +1,22 @@
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/nonameweb/livewire-material/resources/css/material.css';
|
||||
@import './material-scheme.css';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/robsontenorio/mary/src/View/Components/**/*.php';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source inline("swap swap-rotate swap-on swap-off theme-controller");
|
||||
@source '../../vendor/nonameweb/livewire-material/resources/views';
|
||||
@source '../../vendor/nonameweb/livewire-material/src';
|
||||
|
||||
@plugin "daisyui" {
|
||||
themes: light --default, dark --prefersdark;
|
||||
/* share-created: the check on its shape settles in once the link is ready. */
|
||||
@keyframes share-ready {
|
||||
from {
|
||||
opacity: 0;
|
||||
rotate: -90deg;
|
||||
scale: 0.4;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
rotate: 0deg;
|
||||
scale: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -1,2 +1,3 @@
|
||||
// Alpine.js is bundled and started automatically by Livewire 4.
|
||||
// Do not import it here to avoid "multiple instances of Alpine" errors.
|
||||
// Livewire Material. Alpine is bundled and started by Livewire 4: never import it here as well.
|
||||
import '../../vendor/nonameweb/livewire-material/resources/js/material.js'
|
||||
import './share-created.js'
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `shareActions`: the ways the share created page hands a share over besides copying its link —
|
||||
* the device's share sheet, where the browser has one, and the QR code in its dialog saved as a
|
||||
* PNG. Both carry only the link.
|
||||
*
|
||||
* The PNG is drawn in the browser from the dialog's SVG, which is 1024 pixels square, so the
|
||||
* server needs no image extension and every browser rasterises it at full size.
|
||||
*/
|
||||
document.addEventListener('alpine:init', () => {
|
||||
window.Alpine.data('shareActions', ({ url, title, filename, messages }) => ({
|
||||
open: false,
|
||||
|
||||
canShare: typeof navigator.share === 'function',
|
||||
|
||||
async share() {
|
||||
try {
|
||||
await navigator.share({ title, url })
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') {
|
||||
window.materialToast(messages.shareFailed, { type: 'error' })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async downloadQrCode(svg) {
|
||||
try {
|
||||
const png = await rasterise(svg)
|
||||
const link = document.createElement('a')
|
||||
const href = URL.createObjectURL(png)
|
||||
|
||||
link.href = href
|
||||
link.download = filename
|
||||
link.click()
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
} catch {
|
||||
window.materialToast(messages.downloadFailed, { type: 'error' })
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* The SVG element as a PNG blob, at the SVG's own width and height, on white.
|
||||
*/
|
||||
async function rasterise(svg) {
|
||||
const width = Number(svg.getAttribute('width'))
|
||||
const height = Number(svg.getAttribute('height'))
|
||||
const source = URL.createObjectURL(new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml' }))
|
||||
|
||||
try {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
context.imageSmoothingEnabled = false
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
const png = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'))
|
||||
|
||||
if (!png) {
|
||||
throw new Error('The canvas gave no image.')
|
||||
}
|
||||
|
||||
return png
|
||||
} finally {
|
||||
URL.revokeObjectURL(source)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
@props([
|
||||
'on',
|
||||
])
|
||||
|
||||
<div
|
||||
x-data="{ shown: false, timeout: null }"
|
||||
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
|
||||
x-show.transition.out.opacity.duration.1500ms="shown"
|
||||
x-transition:leave.opacity.duration.1500ms
|
||||
style="display: none"
|
||||
{{ $attributes->merge(['class' => 'text-sm']) }}
|
||||
>
|
||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
||||
</div>
|
||||
@@ -1,4 +0,0 @@
|
||||
<a href="{{ route('home') }}" {{ $attributes->merge(['class' => 'flex items-center gap-2 font-semibold']) }} wire:navigate>
|
||||
<x-app-logo-icon class="size-6 fill-current" />
|
||||
<span>{{ \App\Models\Setting::get('site_title') ?: config('app.name', 'SealShare') }}</span>
|
||||
</a>
|
||||
@@ -4,6 +4,6 @@
|
||||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<h2 class="text-xl font-bold">{{ $title }}</h2>
|
||||
<p class="text-sm opacity-60 mt-1">{{ $description }}</p>
|
||||
<h1 class="type-headline-sm">{{ $title }}</h1>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $description }}</p>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,5 @@
|
||||
])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
<x-alert color="success" {{ $attributes }}>{{ $status }}</x-alert>
|
||||
@endif
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{{-- Desktop user menu - integrated into sidebar layout --}}
|
||||
@@ -1,12 +0,0 @@
|
||||
@props([
|
||||
'id' => uniqid(),
|
||||
])
|
||||
|
||||
<svg {{ $attributes }} fill="none">
|
||||
<defs>
|
||||
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
|
||||
</svg>
|
||||
@@ -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">
|
||||
{{ $slot }}
|
||||
</x-layouts::app.sidebar>
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-dvh bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
||||
<main class="mx-auto w-full max-w-5xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12">
|
||||
{{ $slot }}
|
||||
</main>
|
||||
|
||||
@include('partials.toolbar')
|
||||
|
||||
<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">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="flex min-h-dvh flex-col bg-surface font-sans text-on-surface antialiased [--material-bottom-bar:calc(5rem+env(safe-area-inset-bottom))]">
|
||||
<main class="flex flex-1 items-start justify-center px-4 pt-8 pb-32 sm:items-center">
|
||||
<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>
|
||||
|
||||
@include('partials.toolbar')
|
||||
|
||||
<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,68 @@
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('Admin Dashboard') }}</h1>
|
||||
<h1 class="mb-6 type-headline-md">{{ __('Admin Dashboard') }}</h1>
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Active Shares') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $activeShares }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Total Files') }}</p>
|
||||
<p class="text-2xl font-bold">{{ $totalFiles }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<p class="text-sm opacity-60">{{ __('Disk Usage') }}</p>
|
||||
<p class="text-2xl font-bold">{{ Number::fileSize($usedSpace) }}</p>
|
||||
<progress class="progress progress-primary w-full mt-1" value="{{ $maxQuota > 0 ? ($usedSpace / $maxQuota) * 100 : 0 }}" max="100"></progress>
|
||||
<p class="text-xs opacity-50">{{ Number::fileSize($usedSpace) }} / {{ Number::fileSize($maxQuota) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<x-stat :title="__('Total Shares')" :value="$totalShares" icon="link" />
|
||||
<x-stat :title="__('Active Shares')" :value="$activeShares" icon="schedule" />
|
||||
<x-stat :title="__('Total Files')" :value="$totalFiles" icon="description" />
|
||||
<x-stat :title="__('Disk Usage')" :value="Number::fileSize($usedSpace)" icon="hard_drive" :description="Number::fileSize($usedSpace).' / '.Number::fileSize($maxQuota)">
|
||||
<x-progress :value="$maxQuota > 0 ? min(100, ($usedSpace / $maxQuota) * 100) : 0" class="mt-2" :label="__('Disk Usage')" />
|
||||
</x-stat>
|
||||
</div>
|
||||
|
||||
{{-- Shares Table --}}
|
||||
<x-card title="{{ __('All Shares') }}" shadow>
|
||||
<x-table :headers="$headers" :rows="$shares" :sort-by="$sortBy" with-pagination>
|
||||
@scope('cell_total_size', $share)
|
||||
{{ Number::fileSize($share->total_size) }}
|
||||
@endscope
|
||||
<x-card :title="__('All Shares')" variant="outlined">
|
||||
{{-- Outside the table, so it stays centred on a phone instead of scrolling with the columns. --}}
|
||||
@if ($shares->total() === 0)
|
||||
<x-empty-state icon="link_off" :title="__('No shares yet')" :description="__('Shares appear here once someone uploads files.')" />
|
||||
@else
|
||||
<div class="-mx-4 overflow-x-auto">
|
||||
<x-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<x-sort-header column="token" :sort-by="$sortBy">{{ __('Token') }}</x-sort-header>
|
||||
<x-sort-header column="files_count" :sort-by="$sortBy" class="text-end">{{ __('Files') }}</x-sort-header>
|
||||
<x-sort-header column="total_size" :sort-by="$sortBy" class="text-end">{{ __('Size') }}</x-sort-header>
|
||||
<x-sort-header column="download_count" :sort-by="$sortBy" class="text-end">{{ __('Downloads') }}</x-sort-header>
|
||||
<x-sort-header column="expires_at" :sort-by="$sortBy">{{ __('Expires') }}</x-sort-header>
|
||||
<x-sort-header column="created_at" :sort-by="$sortBy">{{ __('Created') }}</x-sort-header>
|
||||
<th><span class="sr-only">{{ __('Actions') }}</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($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>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</x-table>
|
||||
</div>
|
||||
|
||||
@scope('cell_expires_at', $share)
|
||||
@if ($share->expires_at)
|
||||
<span class="{{ $share->isExpired() ? 'text-error' : '' }}">
|
||||
{{ $share->expires_at->diffForHumans() }}
|
||||
</span>
|
||||
@else
|
||||
<span class="opacity-50">{{ __('Never') }}</span>
|
||||
@endif
|
||||
@endscope
|
||||
|
||||
@scope('cell_created_at', $share)
|
||||
{{ $share->created_at->diffForHumans() }}
|
||||
@endscope
|
||||
|
||||
@scope('actions', $share)
|
||||
<div class="flex gap-1">
|
||||
<a href="{{ route('share.download', $share) }}" class="btn btn-ghost btn-xs" target="_blank">
|
||||
<x-icon name="o-eye" class="w-4 h-4" />
|
||||
</a>
|
||||
<x-button
|
||||
icon="o-trash"
|
||||
class="btn-ghost btn-xs text-error"
|
||||
wire:click="deleteShare({{ $share->id }})"
|
||||
wire:confirm="{{ __('Are you sure you want to delete this share?') }}"
|
||||
/>
|
||||
</div>
|
||||
@endscope
|
||||
</x-table>
|
||||
<div class="mt-4">{{ $shares->links() }}</div>
|
||||
@endif
|
||||
</x-card>
|
||||
|
||||
<x-modal wire:model="deletingShareId" :title="__('Delete this share?')" icon="delete">
|
||||
{{ __('Are you sure you want to delete this share?') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Delete')" danger x-on:click="$wire.deleteShare($wire.deletingShareId)" data-test="confirm-delete-share" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</div>
|
||||
|
||||
@@ -1,100 +1,66 @@
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">{{ __('System Settings') }}</h1>
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
||||
|
||||
@if (session('message'))
|
||||
<div class="alert alert-success mb-6">
|
||||
<x-icon name="o-check-circle" class="w-5 h-5" />
|
||||
<span>{{ session('message') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<form wire:submit="saveSettings" class="grid gap-6">
|
||||
<x-card :title="__('Branding')" variant="outlined">
|
||||
<div class="grid gap-5">
|
||||
<x-input wire:model="siteTitle" :label="__('Site Title')" :hint="__('Displayed as the heading on the upload page.')" />
|
||||
|
||||
<form wire:submit="saveSettings">
|
||||
<x-card title="{{ __('Branding') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="siteTitle"
|
||||
label="{{ __('Site Title') }}"
|
||||
hint="{{ __('Displayed as the heading on the upload page.') }}"
|
||||
/>
|
||||
|
||||
<x-textarea
|
||||
wire:model="siteDescription"
|
||||
label="{{ __('Site Description') }}"
|
||||
hint="{{ __('Displayed below the title on the upload page.') }}"
|
||||
rows="3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label class="label label-text font-semibold">{{ __('Logo') }}</label>
|
||||
<x-textarea wire:model="siteDescription" :label="__('Site Description')" :hint="__('Displayed below the title on the upload page.')" rows="3" />
|
||||
|
||||
<div class="grid gap-3">
|
||||
@if ($currentLogo)
|
||||
<div class="flex items-center gap-4 mb-3">
|
||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded" />
|
||||
<x-button
|
||||
label="{{ __('Remove Logo') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="removeLogo"
|
||||
wire:confirm="{{ __('Remove the logo?') }}"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<img src="{{ Storage::disk('public')->url($currentLogo) }}" alt="{{ __('Site Logo') }}" class="h-16 w-auto rounded-corner-sm" />
|
||||
<x-button :label="__('Remove Logo')" icon="delete" color="error" wire:click="$set('confirmingLogoRemoval', true)" data-test="remove-logo" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<input type="file" wire:model="siteLogo" accept="image/*,.svg,.svgz" class="file-input file-input-bordered w-full" />
|
||||
<x-file wire:model="siteLogo" :label="__('Logo')" accept="image/*,.svg,.svgz" :hint="__('Max 2MB. Recommended: PNG or SVG.')" />
|
||||
|
||||
@if ($siteLogo && is_object($siteLogo))
|
||||
<div class="mt-2">
|
||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||
<p class="text-sm opacity-60">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||
@else
|
||||
<p class="text-sm opacity-60">{{ __('Preview:') }}</p>
|
||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="h-16 w-auto rounded mt-1" />
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error('siteLogo')
|
||||
<p class="text-error text-sm mt-1">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
<p class="text-xs opacity-50 mt-1">{{ __('Max 2MB. Recommended: PNG or SVG.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Protection') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<x-password
|
||||
wire:model="systemPassword"
|
||||
label="{{ __('System Upload Password') }}"
|
||||
hint="{{ __('Leave blank to keep current. Set a password to require it before uploading.') }}"
|
||||
/>
|
||||
|
||||
@if ($hasSystemPassword)
|
||||
<div class="mt-2">
|
||||
<x-button
|
||||
label="{{ __('Clear System Password') }}"
|
||||
class="btn-sm btn-ghost text-error"
|
||||
wire:click="clearSystemPassword"
|
||||
wire:confirm="{{ __('Remove the system password?') }}"
|
||||
/>
|
||||
</div>
|
||||
@if (str_contains($siteLogo->getMimeType(), 'svg'))
|
||||
<p class="type-body-md text-on-surface-variant">{{ __('SVG selected: :name', ['name' => $siteLogo->getClientOriginalName()]) }}</p>
|
||||
@else
|
||||
<div>
|
||||
<p class="type-label-lg text-on-surface-variant">{{ __('Preview:') }}</p>
|
||||
<img src="{{ $siteLogo->temporaryUrl() }}" alt="{{ __('Logo preview') }}" class="mt-1 h-16 w-auto rounded-corner-sm" />
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Upload Limits') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-card :title="__('Upload Protection')" variant="outlined">
|
||||
<div class="grid gap-3">
|
||||
<x-password
|
||||
wire:model="systemPassword"
|
||||
:label="__('System Upload Password')"
|
||||
:hint="__('Leave blank to keep current. Set a password to require it before uploading.')"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
@if ($hasSystemPassword)
|
||||
<div>
|
||||
<x-button :label="__('Clear System Password')" icon="lock_reset" color="error" wire:click="$set('confirmingPasswordRemoval', true)" data-test="clear-system-password" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card :title="__('Upload Limits')" variant="outlined">
|
||||
<div class="grid gap-5">
|
||||
<x-toggle
|
||||
wire:model.live="allowNeverExpire"
|
||||
label="{{ __('Allow shares to never expire') }}"
|
||||
hint="{{ __('When disabled, users must select an expiration time.') }}"
|
||||
:label="__('Allow shares to never expire')"
|
||||
:hint="__('When disabled, users must select an expiration time.')"
|
||||
right
|
||||
/>
|
||||
|
||||
<x-select
|
||||
wire:model="defaultExpiration"
|
||||
label="{{ __('Default Expiration') }}"
|
||||
:label="__('Default Expiration')"
|
||||
:placeholder="$allowNeverExpire ? __('None') : null"
|
||||
:options="[
|
||||
['id' => '1h', 'name' => __('1 Hour')],
|
||||
@@ -108,44 +74,49 @@
|
||||
|
||||
<x-input
|
||||
wire:model="maxFileSize"
|
||||
label="{{ __('Max file size (MB)') }}"
|
||||
:label="__('Max file size (MB)')"
|
||||
type="number"
|
||||
min="1"
|
||||
max="{{ $phpMaxUploadMb }}"
|
||||
:max="$phpMaxUploadMb"
|
||||
suffix="MB"
|
||||
hint="{{ __('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb]) }}"
|
||||
:hint="__('PHP limit: :max MB (upload_max_filesize / post_max_size)', ['max' => $phpMaxUploadMb])"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="maxFilesPerShare"
|
||||
label="{{ __('Max files per share') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
/>
|
||||
<x-input wire:model="maxFilesPerShare" :label="__('Max files per share')" type="number" min="1" />
|
||||
|
||||
<x-input
|
||||
wire:model="maxSizePerShare"
|
||||
label="{{ __('Max total size per share (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
/>
|
||||
<x-input wire:model="maxSizePerShare" :label="__('Max total size per share (GB)')" type="number" min="1" suffix="GB" />
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
<x-card title="{{ __('Storage') }}" shadow class="mb-6">
|
||||
<div class="space-y-4">
|
||||
<x-input
|
||||
wire:model="maxStorageQuota"
|
||||
label="{{ __('Max storage quota (GB)') }}"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
hint="{{ __('When reached, new uploads are blocked.') }}"
|
||||
/>
|
||||
</div>
|
||||
<x-card :title="__('Storage')" variant="outlined">
|
||||
<x-input
|
||||
wire:model="maxStorageQuota"
|
||||
:label="__('Max storage quota (GB)')"
|
||||
type="number"
|
||||
min="1"
|
||||
suffix="GB"
|
||||
:hint="__('When reached, new uploads are blocked.')"
|
||||
/>
|
||||
</x-card>
|
||||
|
||||
<x-button type="submit" label="{{ __('Save Settings') }}" class="btn-primary w-full" icon="o-check" spinner="saveSettings" />
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" />
|
||||
</form>
|
||||
|
||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||||
{{ __('The upload and download pages show the default mark again.') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Remove')" danger wire:click="removeLogo" data-test="confirm-remove-logo" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
|
||||
<x-modal wire:model="confirmingPasswordRemoval" :title="__('Remove the system password?')" icon="lock_open">
|
||||
{{ __('Anyone who can reach the upload page can upload files again.') }}
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button :label="__('Remove')" danger wire:click="clearSystemPassword" data-test="confirm-clear-system-password" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="text-center mb-6">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mb-8 text-center">
|
||||
@if ($siteLogo)
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
|
||||
@else
|
||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
|
||||
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
</div>
|
||||
|
||||
@if ($isStorageFull)
|
||||
<div class="alert alert-warning mb-6">
|
||||
<x-icon name="o-exclamation-triangle" class="w-5 h-5" />
|
||||
<span>{{ __('Storage is full. Uploads are temporarily disabled.') }}</span>
|
||||
</div>
|
||||
<x-alert color="warning" :title="__('Storage is full. Uploads are temporarily disabled.')" />
|
||||
@else
|
||||
<form
|
||||
wire:submit="createShare"
|
||||
@@ -88,97 +83,98 @@
|
||||
x-on:livewire-upload-error="resetUpload()"
|
||||
x-on:livewire-upload-progress="progress = $event.detail.progress"
|
||||
>
|
||||
{{-- Drop Zone --}}
|
||||
{{-- Drop zone: the shape behind the icon turns into a burst while files are over it. --}}
|
||||
<div
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors mb-6"
|
||||
:class="{
|
||||
'border-primary bg-primary/5': dragging,
|
||||
'border-base-300 hover:border-primary/50': !dragging,
|
||||
'opacity-50 pointer-events-none': uploading
|
||||
class="mb-6 rounded-corner-xl border-2 border-dashed p-8 text-center transition-colors duration-(--md-sys-motion-effects-default-duration) ease-effects-default"
|
||||
x-bind:class="{
|
||||
'border-primary bg-primary-container/40': dragging,
|
||||
'border-outline-variant': ! dragging,
|
||||
'pointer-events-none opacity-60': uploading,
|
||||
}"
|
||||
@dragover.prevent="dragging = true"
|
||||
@dragleave.prevent="dragging = false"
|
||||
@drop.prevent="handleDrop($event)"
|
||||
x-on:dragover.prevent="dragging = true"
|
||||
x-on:dragleave.prevent="dragging = false"
|
||||
x-on:drop.prevent="handleDrop($event)"
|
||||
data-test="drop-zone"
|
||||
>
|
||||
<x-icon name="o-cloud-arrow-up" class="w-12 h-12 mx-auto opacity-40 mb-3" />
|
||||
<p class="font-medium">{{ __('Drag & drop files or folders here') }}</p>
|
||||
<p class="text-sm opacity-60 mt-1">{{ __('or click to browse') }}</p>
|
||||
<div class="relative mx-auto mb-4 grid size-28 place-items-center">
|
||||
<span
|
||||
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
|
||||
x-bind:class="dragging ? 'scale-50 rotate-45 opacity-0' : 'scale-100 rotate-0 opacity-100'"
|
||||
><x-shape name="cookie-9" class="size-full text-secondary-container" /></span>
|
||||
<span
|
||||
class="absolute inset-0 transition-[scale,rotate,opacity] duration-(--md-sys-motion-spatial-slow-duration) ease-spatial-slow motion-reduce:transition-none"
|
||||
x-bind:class="dragging ? 'scale-110 rotate-0 opacity-100' : 'scale-50 -rotate-45 opacity-0'"
|
||||
><x-shape name="soft-burst" class="size-full text-primary-container" /></span>
|
||||
<x-icon name="upload" class="relative size-12 text-on-secondary-container" x-bind:class="dragging && 'text-on-primary-container'" />
|
||||
</div>
|
||||
|
||||
<label class="btn btn-outline btn-sm mt-4 cursor-pointer" :class="uploading && 'btn-disabled'">
|
||||
<p class="type-title-md">{{ __('Drag & drop files or folders here') }}</p>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('or click to browse') }}</p>
|
||||
|
||||
<label
|
||||
class="state-layer focus-ring mt-4 inline-flex h-10 cursor-pointer items-center gap-2 rounded-corner-full border border-outline-variant px-4 type-label-lg text-primary has-focus-visible:outline-3 has-focus-visible:outline-secondary"
|
||||
x-bind:class="uploading && 'pointer-events-none opacity-38'"
|
||||
>
|
||||
<x-icon name="folder_open" class="size-5" />
|
||||
{{ __('Browse Files') }}
|
||||
<input
|
||||
type="file"
|
||||
wire:model="files"
|
||||
multiple
|
||||
class="hidden"
|
||||
:disabled="uploading"
|
||||
/>
|
||||
<input type="file" wire:model="files" multiple class="sr-only" x-bind:disabled="uploading" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{{-- Upload Progress --}}
|
||||
<div x-show="uploading" x-cloak class="mb-6">
|
||||
<template x-if="progress < 100">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" x-on:click="$wire.cancelUpload('files')">
|
||||
{{ __('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
<progress class="progress progress-primary w-full" max="100" x-bind:value="progress"></progress>
|
||||
{{-- Upload progress --}}
|
||||
<div x-show="uploading" x-cloak class="mb-6" data-test="upload-progress">
|
||||
<div x-show="progress < 100">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="type-label-lg">{{ __('Uploading...') }} <span x-text="Math.round(progress)"></span>%</span>
|
||||
<x-button :label="__('Cancel')" size="xs" x-on:click="$wire.cancelUpload('files')" />
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="progress >= 100">
|
||||
<div class="flex items-center gap-3 text-sm font-medium">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{{ __('Processing files...') }}
|
||||
</div>
|
||||
</template>
|
||||
<x-progress bind="progress" wavy :label="__('Uploading')" />
|
||||
</div>
|
||||
<div x-show="progress >= 100" class="flex items-center gap-3 type-label-lg">
|
||||
<x-loading class="size-8" :label="false" />
|
||||
{{ __('Processing files...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Errors --}}
|
||||
@error('files')
|
||||
<div class="alert alert-error mb-4">{{ $message }}</div>
|
||||
<x-alert color="error" class="mb-4">{{ $message }}</x-alert>
|
||||
@enderror
|
||||
|
||||
{{-- File List --}}
|
||||
{{-- Selected files --}}
|
||||
@if (count($files))
|
||||
<div class="mb-6">
|
||||
<h3 class="font-semibold mb-2">{{ __('Selected Files') }} ({{ count($files) }})</h3>
|
||||
<div class="space-y-1 max-h-60 overflow-y-auto">
|
||||
@foreach ($files as $index => $file)
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ $relativePaths[$index] ?? $file->getClientOriginalName() }}
|
||||
</span>
|
||||
<span class="opacity-50 flex-shrink-0">
|
||||
({{ Number::fileSize($file->getSize()) }})
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" wire:click="removeFile({{ $index }})" class="btn btn-ghost btn-xs">
|
||||
<x-icon name="o-x-mark" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
<h2 class="mb-2 type-title-md">{{ __('Selected Files') }} ({{ count($files) }})</h2>
|
||||
<div class="max-h-72 overflow-y-auto">
|
||||
<x-list segmented :label="__('Selected Files')">
|
||||
@foreach ($files as $index => $file)
|
||||
<x-list-item
|
||||
:title="$relativePaths[$index] ?? $file->getClientOriginalName()"
|
||||
:description="Number::fileSize($file->getSize())"
|
||||
icon="description"
|
||||
wire:key="selected-file-{{ $index }}"
|
||||
>
|
||||
<x-slot:end>
|
||||
<x-button icon="close" :aria-label="__('Remove')" wire:click="removeFile({{ $index }})" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</x-list>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Options --}}
|
||||
<x-card title="{{ __('Share Options') }}" class="mb-6" shadow>
|
||||
<div class="space-y-4">
|
||||
<x-toggle wire:model.live="usePassword" label="{{ __('Password protect') }}" />
|
||||
<x-card :title="__('Share Options')" variant="outlined" class="mb-6">
|
||||
<div class="grid gap-5">
|
||||
<x-toggle wire:model.live="usePassword" :label="__('Password protect')" right />
|
||||
|
||||
@if ($usePassword)
|
||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
||||
<x-password wire:model="password" :label="__('Password')" autocomplete="new-password" />
|
||||
@endif
|
||||
|
||||
<x-select
|
||||
wire:model="expiration"
|
||||
label="{{ __('Expiration') }}"
|
||||
:label="__('Expiration')"
|
||||
:placeholder="$allowNeverExpire ? __('Never') : null"
|
||||
:options="[
|
||||
['id' => '1h', 'name' => __('1 Hour')],
|
||||
@@ -192,22 +188,24 @@
|
||||
|
||||
<x-input
|
||||
wire:model="maxDownloads"
|
||||
label="{{ __('Max downloads') }}"
|
||||
:label="__('Max downloads')"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="{{ __('Unlimited') }}"
|
||||
:placeholder="__('Unlimited')"
|
||||
/>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
{{-- Submit --}}
|
||||
<x-button
|
||||
type="submit"
|
||||
label="{{ __('Create Share Link') }}"
|
||||
class="btn-primary w-full"
|
||||
icon="o-link"
|
||||
:label="__('Create Share Link')"
|
||||
variant="filled"
|
||||
size="md"
|
||||
class="w-full"
|
||||
icon="link"
|
||||
spinner="createShare"
|
||||
x-bind:disabled="uploading || {{ count($files) === 0 ? 'true' : 'false' }}"
|
||||
data-test="create-share"
|
||||
/>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@@ -4,37 +4,37 @@
|
||||
<form wire:submit="createAdmin" class="flex flex-col gap-6">
|
||||
<x-input
|
||||
wire:model="name"
|
||||
label="{{ __('Name') }}"
|
||||
:label="__('Name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
placeholder="{{ __('Admin name') }}"
|
||||
icon="o-user"
|
||||
:placeholder="__('Admin name')"
|
||||
icon="person"
|
||||
/>
|
||||
|
||||
<x-input
|
||||
wire:model="email"
|
||||
label="{{ __('Email address') }}"
|
||||
:label="__('Email address')"
|
||||
type="email"
|
||||
required
|
||||
placeholder="admin@example.com"
|
||||
icon="o-envelope"
|
||||
icon="mail"
|
||||
/>
|
||||
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
:label="__('Password')"
|
||||
required
|
||||
placeholder="{{ __('Password') }}"
|
||||
:placeholder="__('Password')"
|
||||
/>
|
||||
|
||||
<x-password
|
||||
wire:model="password_confirmation"
|
||||
label="{{ __('Confirm password') }}"
|
||||
:label="__('Confirm password')"
|
||||
required
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
:placeholder="__('Confirm password')"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Create Admin Account') }}" class="btn-primary w-full" spinner="createAdmin" />
|
||||
<x-button type="submit" :label="__('Create Admin Account')" variant="filled" class="w-full" spinner="createAdmin" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
<div class="max-w-lg mx-auto">
|
||||
<x-card title="{{ __('Share Created!') }}" subtitle="{{ __('Your files are ready to share') }}" shadow>
|
||||
<div class="space-y-4">
|
||||
{{-- Share URL --}}
|
||||
<div
|
||||
x-data="{
|
||||
copied: false,
|
||||
url: '{{ route('share.download', $share) }}',
|
||||
async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.url);
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 2000);
|
||||
} catch (e) {}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<label class="label font-medium text-sm">{{ __('Share Link') }}</label>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="url"
|
||||
class="input input-bordered join-item w-full"
|
||||
/>
|
||||
<button type="button" @click="copy()" class="btn join-item">
|
||||
<span x-show="!copied">{{ __('Copy') }}</span>
|
||||
<span x-show="copied" class="text-success">{{ __('Copied!') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Details --}}
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Files') }}</span>
|
||||
<p class="font-semibold">{{ $share->files->count() }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Total Size') }}</span>
|
||||
<p class="font-semibold">{{ Number::fileSize($share->total_size) }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Expires') }}</span>
|
||||
<p class="font-semibold">{{ $share->expires_at ? $share->expires_at->diffForHumans() : __('Never') }}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<span class="opacity-60">{{ __('Max Downloads') }}</span>
|
||||
<p class="font-semibold">{{ $share->max_downloads ?? __('Unlimited') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
<div class="alert alert-info">
|
||||
<x-icon name="o-lock-closed" class="w-5 h-5" />
|
||||
<span>{{ __('This share is password protected') }}</span>
|
||||
</div>
|
||||
@endif
|
||||
<div class="mx-auto max-w-lg">
|
||||
<div class="mb-8 text-center">
|
||||
{{-- The link is ready: a check on an Expressive shape that settles in. --}}
|
||||
<div class="relative mx-auto mb-4 grid size-24 place-items-center motion-safe:animate-[share-ready_var(--md-sys-motion-spatial-slow-duration)_var(--md-sys-motion-spatial-slow)_both]">
|
||||
<x-shape name="soft-burst" class="absolute inset-0 size-full text-primary-container" />
|
||||
<x-icon name="check" class="relative size-12 text-on-primary-container" />
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button label="{{ __('Upload More') }}" link="{{ route('upload') }}" icon="o-plus" />
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
<h1 class="type-headline-md">{{ __('Share Created!') }}</h1>
|
||||
<p class="mt-1 type-body-lg text-on-surface-variant">{{ __('Your files are ready to share') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4">
|
||||
{{-- Besides the link: a QR code in a dialog, saved as a PNG in the browser, and the device's
|
||||
share sheet where there is one (resources/js/share-created.js). Both carry the link only. --}}
|
||||
<div
|
||||
x-data="shareActions({
|
||||
url: @js($shareUrl),
|
||||
title: @js($siteTitle),
|
||||
filename: @js('share-'.$share->token.'.png'),
|
||||
messages: @js(['shareFailed' => __('The share sheet could not open.'), 'downloadFailed' => __('The QR code could not be saved.')]),
|
||||
})"
|
||||
class="grid gap-3"
|
||||
data-test="share-actions"
|
||||
>
|
||||
<x-input
|
||||
:label="__('Share Link')"
|
||||
:value="$shareUrl"
|
||||
readonly
|
||||
copyable
|
||||
icon="link"
|
||||
data-test="share-link"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-button :label="__('Show QR code')" icon="qr_code_2" variant="tonal" x-on:click="open = true" data-test="show-qr-code" />
|
||||
|
||||
<span x-show="canShare" x-cloak class="inline-flex">
|
||||
<x-button :label="__('Share…')" icon="share" variant="tonal" x-on:click="share()" data-test="share-sheet" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<x-modal fullscreen :title="__('Scan to open the share')" data-test="qr-code-dialog">
|
||||
{{-- White in either theme: a scanner needs the contrast. The SVG is drawn from the app's own URL. --}}
|
||||
<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">{!! $qrCodeSvg !!}</div>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
<div class="mt-4">
|
||||
<x-alert color="info" icon="lock" :title="__('Recipients also need the password.')" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Close')" x-on:click="close()" />
|
||||
<x-button :label="__('Download')" icon="download" variant="tonal" x-on:click="downloadQrCode($el.closest('dialog').querySelector('[data-qr-code] svg'))" data-test="download-qr-code" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<x-stat :title="__('Files')" :value="$share->files->count()" icon="description" />
|
||||
<x-stat :title="__('Total Size')" :value="Number::fileSize($share->total_size)" icon="hard_drive" />
|
||||
<x-stat :title="__('Expires')" :value="$share->expires_at ? $share->expires_at->diffForHumans() : __('Never')" icon="schedule" />
|
||||
<x-stat :title="__('Max Downloads')" :value="$share->max_downloads ?? __('Unlimited')" icon="download" />
|
||||
</div>
|
||||
|
||||
@if ($share->isPasswordProtected())
|
||||
<x-alert color="info" icon="lock" :title="__('This share is password protected')" />
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end">
|
||||
<x-button :label="__('Upload More')" :link="route('upload')" icon="add" variant="tonal" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +1,61 @@
|
||||
<div class="w-full max-w-lg mx-auto">
|
||||
<div class="text-center mb-6">
|
||||
{{-- The page a recipient opens. No anchored components (menus, tooltips) on it: it has to work on
|
||||
iOS before Safari 18.4, which cannot position them. --}}
|
||||
|
||||
<div class="mx-auto w-full max-w-lg">
|
||||
<div class="mb-8 text-center">
|
||||
@if ($siteLogo)
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="h-20 w-auto mx-auto mb-4" />
|
||||
@else
|
||||
<x-app-logo-icon class="h-16 w-16 mx-auto mb-4" />
|
||||
<img src="{{ Storage::disk('public')->url($siteLogo) }}" alt="{{ $siteTitle ?: config('app.name', 'SealShare') }}" class="mx-auto mb-4 h-20 w-auto" />
|
||||
@endif
|
||||
|
||||
<h1 class="text-2xl font-bold">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
<h1 class="type-headline-lg">{{ $siteTitle ?: config('app.name', 'SealShare') }}</h1>
|
||||
|
||||
<p class="mt-2 opacity-70">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
<p class="mt-2 type-body-lg text-on-surface-variant">{{ $siteDescription ?: __('Share your files safely and securely') }}</p>
|
||||
</div>
|
||||
|
||||
@if (! $authenticated)
|
||||
{{-- Password form --}}
|
||||
<form wire:submit="verifyPassword">
|
||||
<x-card title="{{ __('Password Required') }}" subtitle="{{ __('Enter the password to access these files') }}" shadow>
|
||||
<x-card :title="__('Password Required')" :subtitle="__('Enter the password to access these files')" variant="outlined">
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
:label="__('Password')"
|
||||
required
|
||||
placeholder="{{ __('Enter share password') }}"
|
||||
autocomplete="off"
|
||||
:placeholder="__('Enter share password')"
|
||||
/>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button type="submit" label="{{ __('Unlock') }}" class="btn-primary w-full mt-4" icon="o-lock-open" spinner="verifyPassword" />
|
||||
<x-button type="submit" :label="__('Unlock')" variant="filled" icon="lock_open" spinner="verifyPassword" class="w-full" />
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
</form>
|
||||
@else
|
||||
{{-- File list --}}
|
||||
<x-card title="{{ __('Shared Files') }}" shadow>
|
||||
<div class="space-y-2 mb-4">
|
||||
<x-card :title="__('Shared Files')" variant="outlined">
|
||||
<x-list :label="__('Shared Files')">
|
||||
@foreach ($share->files as $file)
|
||||
<div class="flex items-center justify-between px-3 py-2 rounded-lg bg-base-200 text-sm">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<x-icon name="o-document" class="w-4 h-4 flex-shrink-0" />
|
||||
<span class="truncate">{{ $file->relative_path ?: $file->original_name }}</span>
|
||||
<span class="opacity-50 flex-shrink-0">({{ Number::fileSize($file->file_size) }})</span>
|
||||
</div>
|
||||
<a href="{{ route('share.download.file', [$share, $file]) }}" class="btn btn-ghost btn-xs">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<x-list-item
|
||||
:title="$file->relative_path ?: $file->original_name"
|
||||
:description="Number::fileSize($file->file_size)"
|
||||
icon="description"
|
||||
wire:key="file-{{ $file->id }}"
|
||||
>
|
||||
<x-slot:end>
|
||||
<x-button icon="download" :link="route('share.download.file', [$share, $file])" no-wire-navigate :aria-label="__('Download :name', ['name' => $file->original_name])" />
|
||||
</x-slot:end>
|
||||
</x-list-item>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-list>
|
||||
|
||||
@if ($share->expires_at)
|
||||
<p class="text-xs opacity-50 mb-2">
|
||||
<p class="mt-2 type-body-sm text-on-surface-variant">
|
||||
{{ __('Expires') }}: {{ $share->expires_at->diffForHumans() }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<x-slot:actions>
|
||||
@if ($share->files->count() > 1)
|
||||
<a href="{{ route('share.download.all', $share) }}" class="btn btn-primary w-full">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
{{ __('Download All as ZIP') }}
|
||||
</a>
|
||||
<x-button :label="__('Download All as ZIP')" icon="download" variant="filled" :link="route('share.download.all', $share)" no-wire-navigate class="w-full" />
|
||||
@else
|
||||
<a href="{{ route('share.download.file', [$share, $share->files->first()]) }}" class="btn btn-primary w-full">
|
||||
<x-icon name="o-arrow-down-tray" class="w-4 h-4" />
|
||||
{{ __('Download') }}
|
||||
</a>
|
||||
<x-button :label="__('Download')" icon="download" variant="filled" :link="route('share.download.file', [$share, $share->files->first()])" no-wire-navigate class="w-full" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
</x-card>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<form wire:submit="verify" class="flex flex-col gap-6">
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('Password') }}"
|
||||
:label="__('Password')"
|
||||
required
|
||||
placeholder="{{ __('System password') }}"
|
||||
:placeholder="__('System password')"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Continue') }}" class="btn-primary w-full" spinner="verify" />
|
||||
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" spinner="verify" />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header
|
||||
:title="__('Confirm password')"
|
||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||
<x-layouts::auth :title="__('Confirm password')">
|
||||
<x-auth-header
|
||||
:title="__('Confirm password')"
|
||||
:description="__('This is a secure area of the application. Please confirm your password before continuing.')"
|
||||
/>
|
||||
|
||||
<x-auth-session-status :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-5">
|
||||
@csrf
|
||||
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.confirm.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
|
||||
<x-password
|
||||
name="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Confirm') }}" class="btn-primary w-full" data-test="confirm-password-button" />
|
||||
</form>
|
||||
</div>
|
||||
<x-button type="submit" :label="__('Confirm')" variant="filled" class="w-full" data-test="confirm-password-button" />
|
||||
</form>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
||||
<x-layouts::auth :title="__('Forgot password')">
|
||||
<x-auth-header :title="__('Forgot password')" :description="__('Enter your email to receive a password reset link')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
<x-auth-session-status :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<form method="POST" action="{{ route('password.email') }}" class="flex flex-col gap-5">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<x-input
|
||||
name="email"
|
||||
label="{{ __('Email Address') }}"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
<x-input
|
||||
name="email"
|
||||
:label="__('Email Address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
placeholder="email@example.com"
|
||||
icon="mail"
|
||||
/>
|
||||
|
||||
<x-button type="submit" label="{{ __('Email password reset link') }}" class="btn-primary w-full" data-test="email-password-reset-link-button" />
|
||||
</form>
|
||||
<x-button type="submit" :label="__('Email password reset link')" variant="filled" class="w-full" data-test="email-password-reset-link-button" />
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
|
||||
<span>{{ __('Or, return to') }}</span>
|
||||
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('log in') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-center type-body-md text-on-surface-variant">
|
||||
{{ __('Or, return to') }}
|
||||
<a href="{{ route('login') }}" class="link" wire:navigate>{{ __('log in') }}</a>
|
||||
</p>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,59 +1,40 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
||||
<x-layouts::auth :title="__('Log in')">
|
||||
<x-auth-header :title="__('Log in to your account')" :description="__('Enter your email and password below to log in')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
<x-auth-session-status :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<form method="POST" action="{{ route('login.store') }}" class="flex flex-col gap-5">
|
||||
@csrf
|
||||
|
||||
<!-- Email Address -->
|
||||
<x-input
|
||||
name="email"
|
||||
label="{{ __('Email address') }}"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
<x-input
|
||||
name="email"
|
||||
:label="__('Email address')"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
icon="mail"
|
||||
/>
|
||||
|
||||
<div class="grid gap-1">
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="relative">
|
||||
<x-password
|
||||
name="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
@if (Route::has('password.request'))
|
||||
<a class="link w-fit justify-self-end type-label-lg" href="{{ route('password.request') }}" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (Route::has('password.request'))
|
||||
<a class="absolute top-0 text-sm end-0 link link-primary" href="{{ route('password.request') }}" wire:navigate>
|
||||
{{ __('Forgot your password?') }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<x-checkbox name="remember" :label="__('Remember me')" :checked="(bool) old('remember')" />
|
||||
|
||||
<!-- Remember Me -->
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="remember" class="checkbox checkbox-sm" {{ old('remember') ? 'checked' : '' }} />
|
||||
<span class="text-sm">{{ __('Remember me') }}</span>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<x-button type="submit" label="{{ __('Log in') }}" class="btn-primary w-full" data-test="login-button" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (Route::has('register'))
|
||||
<div class="space-x-1 text-sm text-center rtl:space-x-reverse opacity-60">
|
||||
<span>{{ __('Don\'t have an account?') }}</span>
|
||||
<a href="{{ route('register') }}" class="link link-primary" wire:navigate>{{ __('Sign up') }}</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-button type="submit" :label="__('Log in')" variant="filled" class="w-full" data-test="login-button" />
|
||||
</form>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Create an account')" :description="__('Enter your details below to create your account')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('register.store') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Name -->
|
||||
<x-input
|
||||
name="name"
|
||||
label="{{ __('Name') }}"
|
||||
:value="old('name')"
|
||||
type="text"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="name"
|
||||
placeholder="{{ __('Full name') }}"
|
||||
icon="o-user"
|
||||
/>
|
||||
|
||||
<!-- Email Address -->
|
||||
<x-input
|
||||
name="email"
|
||||
label="{{ __('Email address') }}"
|
||||
:value="old('email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
placeholder="email@example.com"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<x-password
|
||||
name="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<x-password
|
||||
name="password_confirmation"
|
||||
label="{{ __('Confirm password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<x-button type="submit" label="{{ __('Create account') }}" class="btn-primary w-full" data-test="register-user-button" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="space-x-1 rtl:space-x-reverse text-center text-sm opacity-60">
|
||||
<span>{{ __('Already have an account?') }}</span>
|
||||
<a href="{{ route('login') }}" class="link link-primary" wire:navigate>{{ __('Log in') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
@@ -1,47 +1,36 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
||||
<x-layouts::auth :title="__('Reset password')">
|
||||
<x-auth-header :title="__('Reset password')" :description="__('Please enter your new password below')" />
|
||||
|
||||
<!-- Session Status -->
|
||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
||||
<x-auth-session-status :status="session('status')" />
|
||||
|
||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-6">
|
||||
@csrf
|
||||
<!-- Token -->
|
||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||
<form method="POST" action="{{ route('password.update') }}" class="flex flex-col gap-5">
|
||||
@csrf
|
||||
<input type="hidden" name="token" value="{{ request()->route('token') }}">
|
||||
|
||||
<!-- Email Address -->
|
||||
<x-input
|
||||
name="email"
|
||||
value="{{ request('email') }}"
|
||||
label="{{ __('Email') }}"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
icon="o-envelope"
|
||||
/>
|
||||
<x-input
|
||||
name="email"
|
||||
:value="old('email', request('email'))"
|
||||
:label="__('Email')"
|
||||
type="email"
|
||||
required
|
||||
autocomplete="email"
|
||||
icon="mail"
|
||||
/>
|
||||
|
||||
<!-- Password -->
|
||||
<x-password
|
||||
name="password"
|
||||
label="{{ __('Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ __('Password') }}"
|
||||
/>
|
||||
<x-password
|
||||
name="password"
|
||||
:label="__('Password')"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<x-password
|
||||
name="password_confirmation"
|
||||
label="{{ __('Confirm password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
placeholder="{{ __('Confirm password') }}"
|
||||
/>
|
||||
<x-password
|
||||
name="password_confirmation"
|
||||
:label="__('Confirm password')"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<x-button type="submit" label="{{ __('Reset password') }}" class="btn-primary w-full" data-test="reset-password-button" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<x-button type="submit" :label="__('Reset password')" variant="filled" class="w-full" data-test="reset-password-button" />
|
||||
</form>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,90 +1,65 @@
|
||||
<x-layouts::auth>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div
|
||||
class="relative w-full h-auto"
|
||||
x-cloak
|
||||
x-data="{
|
||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
||||
code: '',
|
||||
recovery_code: '',
|
||||
toggleInput() {
|
||||
this.showRecoveryInput = !this.showRecoveryInput;
|
||||
this.code = '';
|
||||
this.recovery_code = '';
|
||||
$nextTick(() => {
|
||||
this.showRecoveryInput
|
||||
? this.$refs.recovery_code?.focus()
|
||||
: this.$refs.code?.focus();
|
||||
<x-layouts::auth :title="__('Two-factor authentication')">
|
||||
<div
|
||||
class="flex flex-col gap-6"
|
||||
x-data="{
|
||||
showRecoveryInput: @js($errors->has('recovery_code')),
|
||||
toggleInput() {
|
||||
this.showRecoveryInput = ! this.showRecoveryInput;
|
||||
$nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
(this.showRecoveryInput ? $refs.recovery : $refs.code)?.querySelector('input')?.focus();
|
||||
});
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="!showRecoveryInput">
|
||||
<x-auth-header
|
||||
:title="__('Authentication Code')"
|
||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput">
|
||||
<x-auth-header
|
||||
:title="__('Recovery Code')"
|
||||
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('two-factor.login.store') }}">
|
||||
@csrf
|
||||
|
||||
<div class="space-y-5 text-center">
|
||||
<div x-show="!showRecoveryInput">
|
||||
<div class="my-5">
|
||||
<x-input
|
||||
type="text"
|
||||
name="code"
|
||||
x-ref="code"
|
||||
x-model="code"
|
||||
x-bind:required="!showRecoveryInput"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="000000"
|
||||
class="text-center text-2xl tracking-widest"
|
||||
maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput">
|
||||
<div class="my-5">
|
||||
<x-input
|
||||
type="text"
|
||||
name="recovery_code"
|
||||
x-ref="recovery_code"
|
||||
x-bind:required="showRecoveryInput"
|
||||
autocomplete="one-time-code"
|
||||
x-model="recovery_code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@error('recovery_code')
|
||||
<p class="text-error text-sm">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
type="submit"
|
||||
label="{{ __('Continue') }}"
|
||||
class="btn-primary w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 space-x-0.5 text-sm leading-5 text-center">
|
||||
<span class="opacity-50">{{ __('or you can') }}</span>
|
||||
<div class="inline font-medium underline cursor-pointer opacity-80">
|
||||
<span x-show="!showRecoveryInput" @click="toggleInput()">{{ __('login using a recovery code') }}</span>
|
||||
<span x-show="showRecoveryInput" @click="toggleInput()">{{ __('login using an authentication code') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
});
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div x-show="! showRecoveryInput">
|
||||
<x-auth-header
|
||||
:title="__('Authentication Code')"
|
||||
:description="__('Enter the authentication code provided by your authenticator application.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryInput" x-cloak>
|
||||
<x-auth-header
|
||||
:title="__('Recovery Code')"
|
||||
:description="__('Please confirm access to your account by entering one of your emergency recovery codes.')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ route('two-factor.login.store') }}" class="flex flex-col gap-5">
|
||||
@csrf
|
||||
|
||||
<div x-ref="code" x-show="! showRecoveryInput">
|
||||
<x-input
|
||||
name="code"
|
||||
:label="__('Code')"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
mono
|
||||
autofocus
|
||||
x-bind:disabled="showRecoveryInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div x-ref="recovery" x-show="showRecoveryInput" x-cloak>
|
||||
<x-input
|
||||
name="recovery_code"
|
||||
:label="__('Recovery code')"
|
||||
autocomplete="one-time-code"
|
||||
mono
|
||||
x-bind:disabled="! showRecoveryInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<x-button type="submit" :label="__('Continue')" variant="filled" class="w-full" />
|
||||
</form>
|
||||
|
||||
<p class="text-center type-body-md text-on-surface-variant">
|
||||
{{ __('or you can') }}
|
||||
<button type="button" class="link" x-show="! showRecoveryInput" x-on:click="toggleInput()">{{ __('login using a recovery code') }}</button>
|
||||
<button type="button" class="link" x-show="showRecoveryInput" x-cloak x-on:click="toggleInput()">{{ __('login using an authentication code') }}</button>
|
||||
</p>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<x-layouts::auth>
|
||||
<div class="mt-4 flex flex-col gap-6">
|
||||
<p class="text-center text-sm opacity-70">
|
||||
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
|
||||
</p>
|
||||
<x-layouts::auth :title="__('Verify email')">
|
||||
<x-auth-header
|
||||
:title="__('Verify your email')"
|
||||
:description="__('Please verify your email address by clicking on the link we just emailed to you.')"
|
||||
/>
|
||||
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<p class="text-center text-sm font-medium text-success">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</p>
|
||||
@endif
|
||||
@if (session('status') == 'verification-link-sent')
|
||||
<x-alert color="success">
|
||||
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
|
||||
</x-alert>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col items-center justify-between space-y-3">
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<x-button type="submit" label="{{ __('Resend verification email') }}" class="btn-primary w-full" />
|
||||
</form>
|
||||
<div class="flex flex-col items-stretch gap-3">
|
||||
<form method="POST" action="{{ route('verification.send') }}">
|
||||
@csrf
|
||||
<x-button type="submit" :label="__('Resend verification email')" variant="filled" class="w-full" />
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<x-button type="submit" label="{{ __('Log out') }}" class="btn-ghost btn-sm" data-test="logout-button" />
|
||||
</form>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('logout') }}" class="self-center">
|
||||
@csrf
|
||||
<x-button type="submit" :label="__('Log out')" data-test="logout-button" />
|
||||
</form>
|
||||
</div>
|
||||
</x-layouts::auth>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
<div class="flex items-start max-md:flex-col">
|
||||
<div class="me-10 w-full pb-4 md:w-[220px]">
|
||||
<x-menu class="!p-0">
|
||||
<x-menu-item title="{{ __('Profile') }}" link="{{ route('profile.edit') }}" wire:navigate />
|
||||
<x-menu-item title="{{ __('Password') }}" link="{{ route('user-password.edit') }}" wire:navigate />
|
||||
@if (Laravel\Fortify\Features::canManageTwoFactorAuthentication())
|
||||
<x-menu-item title="{{ __('Two-Factor Auth') }}" link="{{ route('two-factor.show') }}" wire:navigate />
|
||||
@endif
|
||||
<x-menu-item title="{{ __('Appearance') }}" link="{{ route('appearance.edit') }}" wire:navigate />
|
||||
</x-menu>
|
||||
</div>
|
||||
@php
|
||||
$items = [
|
||||
['title' => __('Profile'), 'icon' => 'person', 'url' => route('profile.edit'), 'active' => request()->routeIs('profile.edit')],
|
||||
['title' => __('Password'), 'icon' => 'password', 'url' => route('user-password.edit'), 'active' => request()->routeIs('user-password.edit')],
|
||||
];
|
||||
|
||||
<div class="divider md:hidden"></div>
|
||||
if (Laravel\Fortify\Features::canManageTwoFactorAuthentication()) {
|
||||
$items[] = ['title' => __('Two-Factor Auth'), 'icon' => 'shield_lock', 'url' => route('two-factor.show'), 'active' => request()->routeIs('two-factor.show')];
|
||||
}
|
||||
|
||||
<div class="flex-1 self-stretch max-md:pt-6">
|
||||
<h2 class="text-lg font-semibold">{{ $heading ?? '' }}</h2>
|
||||
<p class="text-sm opacity-60">{{ $subheading ?? '' }}</p>
|
||||
$items[] = ['title' => __('Appearance'), 'icon' => 'contrast', 'url' => route('appearance.edit'), 'active' => request()->routeIs('appearance.edit')];
|
||||
@endphp
|
||||
|
||||
<div class="mt-5 w-full max-w-lg">
|
||||
<div class="w-full">
|
||||
<x-section-nav :items="$items" :label="__('Settings')" />
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="type-title-lg">{{ $heading ?? '' }}</h2>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ $subheading ?? '' }}</p>
|
||||
|
||||
<div class="mt-6 w-full max-w-lg">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,83 +45,48 @@ new class extends Component {
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div
|
||||
class="py-6 space-y-6 border shadow-sm rounded-xl border-base-300"
|
||||
wire:cloak
|
||||
x-data="{ showRecoveryCodes: false }"
|
||||
>
|
||||
<div class="px-6 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-icon name="o-lock-closed" class="w-4 h-4" />
|
||||
<h3 class="text-lg font-semibold">{{ __('2FA Recovery Codes') }}</h3>
|
||||
<x-card variant="outlined" wire:cloak x-data="{ showRecoveryCodes: false }">
|
||||
<div class="grid gap-4">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<x-icon name="lock" class="size-5 text-on-surface-variant" />
|
||||
<h3 class="type-title-md">{{ __('2FA Recovery Codes') }}</h3>
|
||||
</div>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">
|
||||
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm opacity-60">
|
||||
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<x-button
|
||||
x-show="!showRecoveryCodes"
|
||||
icon="o-eye"
|
||||
label="{{ __('View Recovery Codes') }}"
|
||||
class="btn-primary btn-sm"
|
||||
@click="showRecoveryCodes = true;"
|
||||
/>
|
||||
|
||||
<x-button
|
||||
x-show="showRecoveryCodes"
|
||||
icon="o-eye-slash"
|
||||
label="{{ __('Hide Recovery Codes') }}"
|
||||
class="btn-primary btn-sm"
|
||||
@click="showRecoveryCodes = false"
|
||||
/>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span x-show="! showRecoveryCodes" class="inline-flex">
|
||||
<x-button icon="visibility" :label="__('View Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = true" />
|
||||
</span>
|
||||
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
||||
<x-button icon="visibility_off" :label="__('Hide Recovery Codes')" variant="tonal" x-on:click="showRecoveryCodes = false" />
|
||||
</span>
|
||||
|
||||
@if (filled($recoveryCodes))
|
||||
<x-button
|
||||
x-show="showRecoveryCodes"
|
||||
icon="o-arrow-path"
|
||||
label="{{ __('Regenerate Codes') }}"
|
||||
class="btn-sm"
|
||||
wire:click="regenerateRecoveryCodes"
|
||||
/>
|
||||
<span x-show="showRecoveryCodes" x-cloak class="inline-flex">
|
||||
<x-button icon="refresh" :label="__('Regenerate Codes')" variant="outlined" wire:click="regenerateRecoveryCodes" />
|
||||
</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-show="showRecoveryCodes"
|
||||
x-transition
|
||||
id="recovery-codes-section"
|
||||
class="relative overflow-hidden"
|
||||
x-bind:aria-hidden="!showRecoveryCodes"
|
||||
>
|
||||
<div class="mt-3 space-y-3">
|
||||
@error('recoveryCodes')
|
||||
<div class="alert alert-error">{{ $message }}</div>
|
||||
@enderror
|
||||
<div x-show="showRecoveryCodes" x-cloak id="recovery-codes-section" class="grid gap-3">
|
||||
@error('recoveryCodes')
|
||||
<x-alert color="error">{{ $message }}</x-alert>
|
||||
@enderror
|
||||
|
||||
@if (filled($recoveryCodes))
|
||||
<div
|
||||
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-base-200"
|
||||
role="list"
|
||||
aria-label="{{ __('Recovery codes') }}"
|
||||
>
|
||||
@foreach($recoveryCodes as $code)
|
||||
<div
|
||||
role="listitem"
|
||||
class="select-text"
|
||||
wire:loading.class="opacity-50 animate-pulse"
|
||||
>
|
||||
{{ $code }}
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="text-xs opacity-60">
|
||||
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (filled($recoveryCodes))
|
||||
<div class="grid gap-1 rounded-corner-md bg-surface-container-highest p-4 font-mono type-body-md" role="list" aria-label="{{ __('Recovery codes') }}">
|
||||
@foreach ($recoveryCodes as $code)
|
||||
<div role="listitem" class="select-text" wire:loading.class="animate-pulse opacity-50">{{ $code }}</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="type-body-sm text-on-surface-variant">
|
||||
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-card>
|
||||
|
||||
@@ -10,6 +10,6 @@ new class extends Component {
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-pages::settings.layout :heading="__('Appearance')" :subheading="__('Update the appearance settings for your account')">
|
||||
<x-theme-toggle />
|
||||
<x-theme-toggle mode="picker" class="w-full max-w-sm" data-test="appearance-picker" />
|
||||
</x-pages::settings.layout>
|
||||
</section>
|
||||
|
||||
@@ -26,31 +26,30 @@ new class extends Component {
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<section class="mt-10 space-y-6">
|
||||
<div class="relative mb-5">
|
||||
<h3 class="text-lg font-semibold">{{ __('Delete account') }}</h3>
|
||||
<p class="text-sm opacity-60">{{ __('Delete your account and all of its resources') }}</p>
|
||||
<section class="mt-12 grid gap-4">
|
||||
<x-divider />
|
||||
|
||||
<div>
|
||||
<h3 class="type-title-md">{{ __('Delete account') }}</h3>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Delete your account and all of its resources') }}</p>
|
||||
</div>
|
||||
|
||||
<x-button
|
||||
label="{{ __('Delete account') }}"
|
||||
class="btn-error"
|
||||
@click="$wire.showDeleteModal = true"
|
||||
data-test="delete-user-button"
|
||||
/>
|
||||
<div>
|
||||
<x-button :label="__('Delete account')" danger icon="delete" wire:click="$set('showDeleteModal', true)" data-test="delete-user-button" />
|
||||
</div>
|
||||
|
||||
<x-modal wire:model="showDeleteModal" title="{{ __('Are you sure you want to delete your account?') }}">
|
||||
<p class="text-sm opacity-70">
|
||||
<x-modal wire:model="showDeleteModal" :title="__('Are you sure you want to delete your account?')" icon="delete">
|
||||
<p>
|
||||
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
|
||||
</p>
|
||||
|
||||
<form wire:submit="deleteUser" class="space-y-6 mt-4">
|
||||
<x-password wire:model="password" label="{{ __('Password') }}" />
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button label="{{ __('Cancel') }}" @click="$wire.showDeleteModal = false" />
|
||||
<x-button type="submit" label="{{ __('Delete account') }}" class="btn-error" data-test="confirm-delete-user-button" />
|
||||
</x-slot:actions>
|
||||
<form id="delete-user-form" wire:submit="deleteUser" class="mt-4">
|
||||
<x-password wire:model="password" :label="__('Password')" autocomplete="current-password" />
|
||||
</form>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Cancel')" x-on:click="close()" />
|
||||
<x-button type="submit" form="delete-user-form" :label="__('Delete account')" danger data-test="confirm-delete-user-button" />
|
||||
</x-slot:actions>
|
||||
</x-modal>
|
||||
</section>
|
||||
|
||||
@@ -5,9 +5,11 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
new class extends Component {
|
||||
use PasswordValidationRules;
|
||||
use Toasts;
|
||||
|
||||
public string $current_password = '';
|
||||
public string $password = '';
|
||||
@@ -36,6 +38,8 @@ new class extends Component {
|
||||
$this->reset('current_password', 'password', 'password_confirmation');
|
||||
|
||||
$this->dispatch('password-updated');
|
||||
|
||||
$this->success(__('Saved.'));
|
||||
}
|
||||
}; ?>
|
||||
|
||||
@@ -43,34 +47,13 @@ new class extends Component {
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-pages::settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
|
||||
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
|
||||
<x-password
|
||||
wire:model="current_password"
|
||||
label="{{ __('Current password') }}"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<x-password
|
||||
wire:model="password"
|
||||
label="{{ __('New password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<x-password
|
||||
wire:model="password_confirmation"
|
||||
label="{{ __('Confirm Password') }}"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<form method="POST" wire:submit="updatePassword" class="grid gap-5">
|
||||
<x-password wire:model="current_password" :label="__('Current password')" required autocomplete="current-password" />
|
||||
<x-password wire:model="password" :label="__('New password')" required autocomplete="new-password" />
|
||||
<x-password wire:model="password_confirmation" :label="__('Confirm Password')" required autocomplete="new-password" />
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updatePassword" data-test="update-password-button" />
|
||||
</div>
|
||||
|
||||
<x-action-message class="me-3" on="password-updated">
|
||||
{{ __('Saved.') }}
|
||||
</x-action-message>
|
||||
<div>
|
||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updatePassword" data-test="update-password-button" />
|
||||
</div>
|
||||
</form>
|
||||
</x-pages::settings.layout>
|
||||
|
||||
@@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Session;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
|
||||
new class extends Component {
|
||||
use ProfileValidationRules;
|
||||
use Toasts;
|
||||
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
@@ -42,6 +44,8 @@ new class extends Component {
|
||||
$user->save();
|
||||
|
||||
$this->dispatch('profile-updated', name: $user->name);
|
||||
|
||||
$this->success(__('Saved.'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +56,7 @@ new class extends Component {
|
||||
$user = Auth::user();
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->redirectIntended(default: route('dashboard', absolute: false));
|
||||
$this->redirectIntended(default: route('admin.dashboard', absolute: false));
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -80,39 +84,29 @@ new class extends Component {
|
||||
@include('partials.settings-heading')
|
||||
|
||||
<x-pages::settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
|
||||
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
|
||||
<x-input wire:model="name" label="{{ __('Name') }}" type="text" required autofocus autocomplete="name" icon="o-user" />
|
||||
<form wire:submit="updateProfileInformation" class="grid w-full gap-5">
|
||||
<x-input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" icon="person" />
|
||||
|
||||
<div>
|
||||
<x-input wire:model="email" label="{{ __('Email') }}" type="email" required autocomplete="email" icon="o-envelope" />
|
||||
<div class="grid gap-3">
|
||||
<x-input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" icon="mail" />
|
||||
|
||||
@if ($this->hasUnverifiedEmail)
|
||||
<div>
|
||||
<p class="mt-4 text-sm opacity-70">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
<p class="type-body-md text-on-surface-variant">
|
||||
{{ __('Your email address is unverified.') }}
|
||||
|
||||
<a class="link link-primary text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</a>
|
||||
</p>
|
||||
<button type="button" class="link" wire:click.prevent="resendVerificationNotification">
|
||||
{{ __('Click here to re-send the verification email.') }}
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<p class="mt-2 text-sm font-medium text-success">
|
||||
{{ __('A new verification link has been sent to your email address.') }}
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (session('status') === 'verification-link-sent')
|
||||
<x-alert color="success">{{ __('A new verification link has been sent to your email address.') }}</x-alert>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center justify-end">
|
||||
<x-button type="submit" label="{{ __('Save') }}" class="btn-primary" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||
</div>
|
||||
|
||||
<x-action-message class="me-3" on="profile-updated">
|
||||
{{ __('Saved.') }}
|
||||
</x-action-message>
|
||||
<div>
|
||||
<x-button type="submit" :label="__('Save')" variant="filled" spinner="updateProfileInformation" data-test="update-profile-button" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -184,142 +184,81 @@ new class extends Component {
|
||||
:heading="__('Two Factor Authentication')"
|
||||
:subheading="__('Manage your two-factor authentication settings')"
|
||||
>
|
||||
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
|
||||
<div class="grid w-full gap-6" wire:cloak>
|
||||
@if ($twoFactorEnabled)
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge badge-success">{{ __('Enabled') }}</span>
|
||||
</div>
|
||||
<div class="grid justify-items-start gap-4">
|
||||
<x-badge :value="__('Enabled')" tonal color="success" />
|
||||
|
||||
<p class="opacity-70">
|
||||
<p class="type-body-md text-on-surface-variant">
|
||||
{{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
||||
<livewire:pages::settings.two-factor.recovery-codes :$requiresConfirmation />
|
||||
|
||||
<div class="flex justify-start">
|
||||
<x-button
|
||||
label="{{ __('Disable 2FA') }}"
|
||||
icon="o-shield-exclamation"
|
||||
class="btn-error"
|
||||
wire:click="disable"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<x-button :label="__('Disable 2FA')" icon="remove_moderator" danger wire:click="disable" />
|
||||
</div>
|
||||
@else
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="badge badge-error">{{ __('Disabled') }}</span>
|
||||
</div>
|
||||
<div class="grid justify-items-start gap-4">
|
||||
<x-badge :value="__('Disabled')" tonal color="error" />
|
||||
|
||||
<p class="opacity-60">
|
||||
<p class="type-body-md text-on-surface-variant">
|
||||
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
|
||||
</p>
|
||||
|
||||
<x-button
|
||||
label="{{ __('Enable 2FA') }}"
|
||||
icon="o-shield-check"
|
||||
class="btn-primary"
|
||||
wire:click="enable"
|
||||
/>
|
||||
<x-button :label="__('Enable 2FA')" icon="shield_lock" variant="filled" wire:click="enable" />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-pages::settings.layout>
|
||||
|
||||
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" class="max-w-md">
|
||||
<p class="text-sm opacity-70">{{ $this->modalConfig['description'] }}</p>
|
||||
|
||||
<x-modal wire:model="showModal" :title="$this->modalConfig['title']" :subtitle="$this->modalConfig['description']" fullscreen>
|
||||
@if ($showVerificationStep)
|
||||
<div class="space-y-6 mt-4">
|
||||
<div class="flex flex-col items-center space-y-3 justify-center">
|
||||
<x-input
|
||||
name="code"
|
||||
wire:model="code"
|
||||
placeholder="000000"
|
||||
class="text-center text-2xl tracking-widest"
|
||||
maxlength="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button
|
||||
label="{{ __('Back') }}"
|
||||
wire:click="resetVerification"
|
||||
/>
|
||||
<x-button
|
||||
label="{{ __('Confirm') }}"
|
||||
class="btn-primary"
|
||||
wire:click="confirmTwoFactor"
|
||||
x-bind:disabled="$wire.code.length < 6"
|
||||
/>
|
||||
</x-slot:actions>
|
||||
<div class="mt-2">
|
||||
<x-input
|
||||
name="code"
|
||||
wire:model="code"
|
||||
:label="__('Code')"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
mono
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button :label="__('Back')" wire:click="resetVerification" />
|
||||
<x-button :label="__('Confirm')" variant="filled" wire:click="confirmTwoFactor" x-bind:disabled="$wire.code.length < 6" />
|
||||
</x-slot:actions>
|
||||
@else
|
||||
@error('setupData')
|
||||
<div class="alert alert-error mt-4">{{ $message }}</div>
|
||||
<x-alert color="error" class="mt-2">{{ $message }}</x-alert>
|
||||
@enderror
|
||||
|
||||
<div class="flex justify-center mt-4">
|
||||
<div class="relative w-64 overflow-hidden border rounded-lg border-base-300 aspect-square">
|
||||
<div class="mt-2 flex justify-center">
|
||||
{{-- The QR code keeps a white ground in both themes: scanners read dark on light. --}}
|
||||
<div class="grid aspect-square w-64 place-items-center overflow-hidden rounded-corner-lg bg-white p-4">
|
||||
@empty($qrCodeSvg)
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-200 animate-pulse">
|
||||
<span class="loading loading-spinner"></span>
|
||||
</div>
|
||||
<x-loading :label="__('Loading')" />
|
||||
@else
|
||||
<div class="flex items-center justify-center h-full p-4 bg-white">
|
||||
{!! $qrCodeSvg !!}
|
||||
</div>
|
||||
{!! $qrCodeSvg !!}
|
||||
@endempty
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 mt-4">
|
||||
<div class="relative flex items-center justify-center w-full">
|
||||
<div class="absolute inset-0 w-full h-px top-1/2 bg-base-300"></div>
|
||||
<span class="relative px-2 text-sm bg-base-100 opacity-60">
|
||||
{{ __('or, enter the code manually') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6 grid gap-3">
|
||||
<p class="text-center type-label-lg text-on-surface-variant">{{ __('or, enter the code manually') }}</p>
|
||||
|
||||
<div
|
||||
class="flex items-center space-x-2"
|
||||
x-data="{
|
||||
copied: false,
|
||||
async copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
|
||||
this.copied = true;
|
||||
setTimeout(() => this.copied = false, 1500);
|
||||
} catch (e) {
|
||||
console.warn('Could not copy to clipboard');
|
||||
}
|
||||
}
|
||||
}"
|
||||
>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value="{{ $manualSetupKey }}"
|
||||
class="input input-bordered join-item w-full"
|
||||
/>
|
||||
<button
|
||||
@click="copy()"
|
||||
class="btn join-item"
|
||||
>
|
||||
<x-icon x-show="!copied" name="o-document-duplicate" class="w-4 h-4" />
|
||||
<x-icon x-show="copied" name="o-check" class="w-4 h-4 text-success" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<x-input :label="__('Setup key')" :value="$manualSetupKey" readonly copyable mono />
|
||||
</div>
|
||||
|
||||
<x-slot:actions>
|
||||
<x-button
|
||||
:disabled="$errors->has('setupData')"
|
||||
label="{{ $this->modalConfig['buttonText'] }}"
|
||||
class="btn-primary"
|
||||
:label="$this->modalConfig['buttonText']"
|
||||
variant="filled"
|
||||
wire:click="showVerificationIfNecessary"
|
||||
/>
|
||||
</x-slot:actions>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
|
||||
<title>{{ $title ?? (\App\Models\Setting::get('site_title') ?: config('app.name')) }}</title>
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
<x-theme-script />
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<div class="relative mb-6 w-full">
|
||||
<h1 class="text-2xl font-bold">{{ __('Settings') }}</h1>
|
||||
<p class="text-sm opacity-60 mb-6">{{ __('Manage your profile and account settings') }}</p>
|
||||
<div class="divider my-0"></div>
|
||||
<div class="mb-6 w-full">
|
||||
<h1 class="type-headline-md">{{ __('Settings') }}</h1>
|
||||
<p class="mt-1 type-body-md text-on-surface-variant">{{ __('Manage your profile and account settings') }}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{-- The one navigation on every page: a floating toolbar centred above the bottom edge, as wide as
|
||||
its buttons. The site's name and logo head the upload and download pages instead of a bar.
|
||||
A signed-in user gets the pages they can reach and the account menu; everyone else gets the
|
||||
upload page, the theme toggle and a way to sign in — without tooltips, so the download page
|
||||
stays free of anchored components. The current page's button is filled. --}}
|
||||
|
||||
@php
|
||||
$onUpload = request()->routeIs('upload', 'share.created');
|
||||
$onDashboard = request()->routeIs('admin.dashboard');
|
||||
$onAdminSettings = request()->routeIs('admin.settings');
|
||||
@endphp
|
||||
|
||||
<nav aria-label="{{ __('Main') }}">
|
||||
<x-toolbar :label="__('Main')" place="bottom" data-test="app-toolbar">
|
||||
@auth
|
||||
<x-button icon="upload" :tooltip="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
|
||||
|
||||
@if (auth()->user()->is_admin)
|
||||
<x-button icon="dashboard" :tooltip="__('Admin dashboard')" :link="route('admin.dashboard')" :variant="$onDashboard ? 'filled' : 'text'" :aria-current="$onDashboard ? 'page' : null" />
|
||||
<x-button icon="admin_panel_settings" :tooltip="__('Admin settings')" :link="route('admin.settings')" :variant="$onAdminSettings ? 'filled' : 'text'" :aria-current="$onAdminSettings ? 'page' : null" />
|
||||
@endif
|
||||
|
||||
<span class="ms-1 inline-flex">
|
||||
<x-account-menu :name="auth()->user()->name" :email="auth()->user()->email" position="top-end">
|
||||
<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-button icon="upload" :aria-label="__('Upload')" :link="route('upload')" :variant="$onUpload ? 'filled' : 'text'" :aria-current="$onUpload ? 'page' : null" />
|
||||
<x-theme-toggle />
|
||||
|
||||
@if (Route::has('login') && ! request()->routeIs('login'))
|
||||
<x-button :label="__('Log in')" :link="route('login')" />
|
||||
@endif
|
||||
@endauth
|
||||
</x-toolbar>
|
||||
</nav>
|
||||
@@ -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::view('dashboard', 'dashboard')
|
||||
->middleware(['auth', 'verified'])
|
||||
->name('dashboard');
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started.
|
||||
*/
|
||||
function ready(mixed $page): mixed
|
||||
{
|
||||
return $page->waitForEvent('networkidle')
|
||||
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Sessions have to outlive a request here: a sign-in, a verified share password.
|
||||
config(['session.driver' => 'file']);
|
||||
Storage::fake('shares');
|
||||
});
|
||||
|
||||
test('files dragged over the drop zone turn its shape into a burst', function () {
|
||||
$page = ready(visit('/upload'));
|
||||
|
||||
$burst = "getComputedStyle(document.querySelectorAll('[data-test=drop-zone] span.absolute')[1]).opacity";
|
||||
|
||||
$page->assertScript("{$burst} === '0'");
|
||||
|
||||
$page->script("window.eval(\"document.querySelector('[data-test=drop-zone]').dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true }))\")");
|
||||
|
||||
$page->assertScript("{$burst} === '1'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
// Pest's in-process server does not store a multipart upload, so the upload itself is covered by
|
||||
// FileUploadTest; this picks up where it ends, on the page the upload leads to.
|
||||
test('a new share\'s link can be copied from the page the upload leads to', function () {
|
||||
$share = app(ShareService::class)->createShare(
|
||||
[['file' => UploadedFile::fake()->create('contract.pdf', 80), 'relativePath' => null]],
|
||||
[],
|
||||
);
|
||||
|
||||
$page = ready(visit(route('share.created', $share, false)));
|
||||
|
||||
$page->assertSee('Share Created!')
|
||||
->assertScript("document.querySelector('[data-test=\"share-link\"]').value.includes('/s/')");
|
||||
|
||||
$page->script("window.eval(\"Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async (text) => { window.copied = text } } })\")");
|
||||
|
||||
$page->click('[data-field-copy]')
|
||||
->assertScript("typeof window.copied === 'string' && window.copied.includes('/s/')")
|
||||
->assertSee('Copied to the clipboard');
|
||||
});
|
||||
|
||||
test('a new share\'s QR code opens in a dialog and saves as a PNG', function () {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
|
||||
$page = ready(visit(route('share.created', $share, false)));
|
||||
|
||||
$page->click('[data-test="show-qr-code"]')
|
||||
->assertScript("document.querySelector('[data-test=\"qr-code-dialog\"]').open")
|
||||
->assertScript("getComputedStyle(document.querySelector('[data-qr-code]')).backgroundColor === 'rgb(255, 255, 255)'")
|
||||
->assertScript("document.querySelector('[data-qr-code] svg').getBoundingClientRect().width > 200")
|
||||
->assertSee('Recipients also need the password.');
|
||||
|
||||
// Record what would be saved instead of saving it.
|
||||
$page->script('window.eval("URL.revokeObjectURL = () => {}; HTMLAnchorElement.prototype.click = function () { window.saved = { name: this.download, href: this.href } }")');
|
||||
|
||||
$page->click('[data-test="download-qr-code"]')
|
||||
->assertScript("window.eval('window.saved?.name') === 'share-{$share->token}.png'");
|
||||
|
||||
// A QR code is roughly a third to a half dark; a blank or failed drawing is not.
|
||||
$page->script("window.eval(\"(async () => { const blob = await (await fetch(window.saved.href)).blob(); const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; let dark = 0; for (let i = 0; i < pixels.length; i += 4) { if (pixels[i] < 128) { dark++ } } window.png = { type: blob.type, width: bitmap.width, dark: dark / (pixels.length / 4) } })()\")");
|
||||
|
||||
$page->assertScript("window.eval('window.png?.type') === 'image/png'")
|
||||
->assertScript("window.eval('window.png.width') === 1024")
|
||||
->assertScript("window.eval('window.png.dark') > 0.2 && window.eval('window.png.dark') < 0.6")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
test('the share sheet gets the link, and says so only when it fails for another reason than a cancel', function () {
|
||||
$share = Share::factory()->create();
|
||||
$actions = "Alpine.\$data(document.querySelector('[data-test=share-actions]'))";
|
||||
|
||||
$page = ready(visit(route('share.created', $share, false)));
|
||||
|
||||
// Shown only where the browser has a share sheet.
|
||||
$page->assertScript("window.eval(\"getComputedStyle(document.querySelector('[data-test=share-sheet]').parentElement).display === 'none'\") === (typeof navigator.share !== 'function')");
|
||||
|
||||
$page->script("window.eval(\"navigator.share = async (data) => { window.shared = data }; {$actions}.share()\")");
|
||||
$page->assertScript("window.eval('window.shared?.url') === '".route('share.download', $share)."'");
|
||||
|
||||
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Cancelled', 'AbortError') }; {$actions}.share()\")");
|
||||
$page->wait(0.3)->assertDontSee('The share sheet could not open.');
|
||||
|
||||
$page->script("window.eval(\"navigator.share = async () => { throw new DOMException('Not allowed', 'NotAllowedError') }; {$actions}.share()\")");
|
||||
$page->assertSee('The share sheet could not open.');
|
||||
});
|
||||
|
||||
test('a recipient on a phone unlocks a password-protected share and sees its files', function () {
|
||||
$share = app(ShareService::class)->createShare(
|
||||
[['file' => UploadedFile::fake()->create('holiday-photos.zip', 120), 'relativePath' => null]],
|
||||
['password' => 'correct horse'],
|
||||
);
|
||||
|
||||
$page = ready(visit(route('share.download', $share, false))->resize(393, 852));
|
||||
|
||||
$page->assertSee('Password Required')
|
||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth')
|
||||
->assertNoJavaScriptErrors()
|
||||
->type('input[type="password"]', 'correct horse')
|
||||
->press('Unlock')
|
||||
->assertSee('Shared Files')
|
||||
->assertSee('holiday-photos.zip')
|
||||
->assertScript("document.querySelectorAll('[popover]').length === 0")
|
||||
->assertScript('document.documentElement.scrollWidth <= window.innerWidth');
|
||||
});
|
||||
|
||||
test('an admin sorts the shares table and deletes a share through its dialog', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
||||
$doomed = Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
$page = ready(visit('/admin/dashboard'));
|
||||
|
||||
$page->click('th button:has-text("Downloads")')
|
||||
->assertScript("document.querySelector('tbody tr td').textContent.trim() === 'zzzzzzzzzzzzzzzz'");
|
||||
|
||||
$page->click("[data-test=\"delete-share-{$doomed->id}\"]")
|
||||
->assertScript("[...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
||||
->click('[data-test="confirm-delete-share"]')
|
||||
->assertScript("! [...document.querySelectorAll('dialog')].some((dialog) => dialog.open)")
|
||||
->assertDontSee('zzzzzzzzzzzzzzzz');
|
||||
|
||||
expect(Share::query()->find($doomed->id))->toBeNull();
|
||||
});
|
||||
|
||||
test('a first visit follows the system theme, and Appearance switches it', function () {
|
||||
ready(visit('/upload')->inDarkMode())
|
||||
->assertScript("document.documentElement.dataset.theme === 'dark'")
|
||||
->assertScript("document.documentElement.dataset.themeChoice === 'system'");
|
||||
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$page = ready(visit('/settings/appearance')->inDarkMode());
|
||||
|
||||
$page->click('[data-theme-option="light"]')
|
||||
->assertScript("document.documentElement.dataset.theme === 'light'")
|
||||
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
||||
});
|
||||
@@ -54,7 +54,9 @@ test('admin can delete share', function () {
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminDashboard::class)
|
||||
->call('deleteShare', $shareId);
|
||||
->set('deletingShareId', $shareId)
|
||||
->call('deleteShare', $shareId)
|
||||
->assertSet('deletingShareId', null);
|
||||
|
||||
expect(Share::query()->find($shareId))->toBeNull();
|
||||
});
|
||||
@@ -69,3 +71,28 @@ test('admin dashboard shows shares table', function () {
|
||||
$response->assertOk();
|
||||
$response->assertSee('testtoken12345678');
|
||||
});
|
||||
|
||||
test('the shares table sorts only by its own columns', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 9]);
|
||||
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 1]);
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminDashboard::class)
|
||||
->set('sortBy', ['column' => 'download_count', 'direction' => 'asc'])
|
||||
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
||||
->set('sortBy', ['column' => 'token; drop table shares', 'direction' => 'sideways'])
|
||||
->assertOk();
|
||||
|
||||
expect(Share::query()->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('without shares the dashboard shows an empty state instead of the table', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$this->actingAs($admin)->get(route('admin.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('No shares yet')
|
||||
->assertDontSee('<table', false);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
use App\Livewire\Admin\AdminSettings;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('admin settings requires authentication', function () {
|
||||
@@ -41,7 +43,8 @@ test('admin can save settings', function () {
|
||||
->set('maxSizePerShare', 5)
|
||||
->set('defaultExpiration', '7d')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('toast', type: 'success', title: 'Settings saved successfully.');
|
||||
|
||||
expect(Setting::get('max_file_size'))->toBe((string) (min(200, $phpMaxMb) * 1024 * 1024));
|
||||
expect(Setting::get('max_storage_quota'))->toBe((string) (50 * 1024 * 1024 * 1024));
|
||||
@@ -73,8 +76,11 @@ test('admin can clear system password', function () {
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('confirmingPasswordRemoval', true)
|
||||
->call('clearSystemPassword')
|
||||
->assertHasNoErrors();
|
||||
->assertHasNoErrors()
|
||||
->assertSet('confirmingPasswordRemoval', false)
|
||||
->assertDispatched('toast', type: 'success', title: 'System password cleared.');
|
||||
|
||||
expect(Setting::get('system_password'))->toBeNull();
|
||||
});
|
||||
@@ -103,3 +109,22 @@ test('settings validation rejects invalid values', function () {
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['maxFileSize', 'maxStorageQuota']);
|
||||
});
|
||||
|
||||
test('admin can remove the logo through its dialog', function () {
|
||||
Storage::fake('public');
|
||||
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
$path = UploadedFile::fake()->image('logo.png')->store('branding', 'public');
|
||||
Setting::set('site_logo', $path);
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSeeHtml('data-test="remove-logo"')
|
||||
->set('confirmingLogoRemoval', true)
|
||||
->call('removeLogo')
|
||||
->assertSet('confirmingLogoRemoval', false)
|
||||
->assertDispatched('toast', type: 'success', title: 'Logo removed.');
|
||||
|
||||
expect(Setting::get('site_logo'))->toBeNull();
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* The page's main navigation, the floating toolbar at the bottom.
|
||||
*/
|
||||
function toolbar(string $html): string
|
||||
{
|
||||
preg_match('/<nav aria-label="Main">.*?<\/nav>/s', $html, $matches);
|
||||
|
||||
return $matches[0] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The opening tag of the toolbar's link to a URL.
|
||||
*/
|
||||
function toolbarLink(string $html, string $url): string
|
||||
{
|
||||
preg_match('/<a\s[^>]*href="'.preg_quote($url, '/').'"[^>]*>/', toolbar($html), $matches);
|
||||
|
||||
return $matches[0] ?? '';
|
||||
}
|
||||
|
||||
test('pages have a floating toolbar at the bottom instead of a top app bar', function () {
|
||||
$html = $this->get(route('upload'))->assertOk()->getContent();
|
||||
|
||||
expect($html)->not->toContain('data-app-bar')
|
||||
->and(toolbar($html))->toContain('role="toolbar"')->toContain('data-toolbar-place="bottom"');
|
||||
});
|
||||
|
||||
test('a guest gets the upload page, the theme toggle and a way to log in, without tooltips', function () {
|
||||
$html = $this->get(route('upload'))->getContent();
|
||||
|
||||
expect(toolbarLink($html, route('upload')))->toContain('aria-current="page"')->toContain('aria-label="Upload"')
|
||||
->and(toolbarLink($html, route('login')))->not->toBe('')
|
||||
->and(toolbar($html))->toContain('Log in')->toContain('data-theme-toggle')->not->toContain('popover')->not->toContain('data-account-menu');
|
||||
|
||||
expect(toolbar($this->get(route('login'))->getContent()))->not->toContain(route('login').'"');
|
||||
});
|
||||
|
||||
test('an admin gets the admin pages and the account menu, the current page marked', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
$html = $this->actingAs($admin)->get(route('admin.dashboard'))->assertOk()->getContent();
|
||||
|
||||
expect(toolbarLink($html, route('admin.dashboard')))->toContain('aria-current="page"')
|
||||
->and(toolbarLink($html, route('upload')))->not->toContain('aria-current')
|
||||
->and(toolbarLink($html, route('admin.settings')))->not->toContain('aria-current')
|
||||
->and(toolbar($html))->toContain('data-account-menu')->toContain('data-test="logout-button"')->not->toContain('Log in');
|
||||
});
|
||||
|
||||
test('a user who is not an admin gets no admin pages', function () {
|
||||
$html = $this->actingAs(User::factory()->create(['is_admin' => false]))->get(route('profile.edit'))->assertOk()->getContent();
|
||||
|
||||
expect(toolbarLink($html, route('upload')))->not->toBe('')
|
||||
->and(toolbar($html))->not->toContain(route('admin.dashboard'))->not->toContain(route('admin.settings'));
|
||||
});
|
||||
@@ -19,7 +19,7 @@ test('users can authenticate using the login screen', function () {
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('dashboard', absolute: false));
|
||||
->assertRedirect(route('admin.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticated();
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ test('email can be verified', function () {
|
||||
Event::assertDispatched(Verified::class);
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
$response->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
|
||||
});
|
||||
|
||||
test('email is not verified with invalid hash', function () {
|
||||
@@ -60,7 +60,7 @@ test('already verified user visiting verification link is redirected without fir
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl)
|
||||
->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
->assertRedirect(route('admin.dashboard', absolute: false).'?verified=1');
|
||||
|
||||
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||
Event::assertNotDispatched(Verified::class);
|
||||
|
||||
@@ -58,3 +58,14 @@ test('password can be reset with valid token', function () {
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('reset password mail wears the material theme in the app scheme', function () {
|
||||
$user = User::factory()->create();
|
||||
$scheme = json_decode(file_get_contents(resource_path('css/material-scheme.json')), true);
|
||||
|
||||
$html = (string) (new ResetPassword('token'))->toMail($user)->render();
|
||||
|
||||
expect($html)
|
||||
->toContain('background-color: '.$scheme['light']['primary'])
|
||||
->toContain(route('password.reset', ['token' => 'token', 'email' => $user->email]));
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
test('guests are redirected to the login page', function () {
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('login'));
|
||||
test('the starter placeholder dashboard is gone', function () {
|
||||
$this->actingAs(User::factory()->admin()->create())
|
||||
->get('/dashboard')
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('authenticated users can visit the dashboard', function () {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
test('a signed-in admin lands on the admin dashboard', function () {
|
||||
$admin = User::factory()->admin()->create();
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertOk();
|
||||
$this->post(route('login.store'), ['email' => $admin->email, 'password' => 'password'])
|
||||
->assertRedirect(route('admin.dashboard', absolute: false));
|
||||
|
||||
$this->get(route('admin.dashboard'))->assertOk();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use NoNameWeb\LivewireMaterial\Testing\DesignGuard;
|
||||
|
||||
test('views and code use only what the design system compiles', function () {
|
||||
expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])->violations())->toBe([]);
|
||||
});
|
||||
|
||||
test('nothing of maryUI or daisyUI is left behind', function () {
|
||||
$leftovers = collect(['resources/views', 'resources/js', 'resources/css', 'app'])
|
||||
->flatMap(fn (string $path) => File::allFiles(base_path($path)))
|
||||
->filter(fn (SplFileInfo $file): bool => preg_match('/\b(base-content|bg-base-\d|btn(-[a-z]+)?|x-mary-|daisyui|robsontenorio)\b/', $file->getContents()) === 1)
|
||||
->map(fn (SplFileInfo $file): string => str_replace(base_path().'/', '', $file->getPathname()))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($leftovers)->toBe([]);
|
||||
});
|
||||
@@ -17,7 +17,8 @@ test('password can be updated', function () {
|
||||
->set('password_confirmation', 'new-password')
|
||||
->call('updatePassword');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$response->assertHasNoErrors()
|
||||
->assertDispatched('toast', type: 'success', title: 'Saved.');
|
||||
|
||||
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@ test('profile information can be updated', function () {
|
||||
->set('email', 'test@example.com')
|
||||
->call('updateProfileInformation');
|
||||
|
||||
$response->assertHasNoErrors();
|
||||
$response->assertHasNoErrors()
|
||||
->assertDispatched('profile-updated')
|
||||
->assertDispatched('toast', type: 'success', title: 'Saved.');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Share;
|
||||
use App\Services\QrCodeService;
|
||||
|
||||
test('the share created page offers the link as a QR code and through the share sheet', function () {
|
||||
$share = Share::factory()->create();
|
||||
$url = route('share.download', $share);
|
||||
|
||||
$response = $this->get(route('share.created', $share));
|
||||
|
||||
$response->assertOk()
|
||||
->assertSee($url, false)
|
||||
->assertSee('data-test="show-qr-code"', false)
|
||||
->assertSee('data-test="share-sheet"', false)
|
||||
->assertSee('share-'.$share->token.'.png')
|
||||
->assertSee('<div data-qr-code class="mx-auto aspect-square w-full max-w-80 rounded-corner-lg bg-white p-2 [&>svg]:size-full">'.app(QrCodeService::class)->svg($url).'</div>', false)
|
||||
->assertDontSee('Recipients also need the password.');
|
||||
});
|
||||
|
||||
test('the QR code dialog reminds that a protected share also needs its password', function () {
|
||||
$share = Share::factory()->withPassword()->create();
|
||||
|
||||
$this->get(route('share.created', $share))
|
||||
->assertOk()
|
||||
->assertSee('Recipients also need the password.');
|
||||
});
|
||||
+1
-1
@@ -21,7 +21,7 @@ pest()->extend(TestCase::class)
|
||||
// EnsureSetupComplete middleware redirects to /setup unless an admin exists.
|
||||
User::factory()->admin()->create(['email' => 'admin-setup@test.com']);
|
||||
})
|
||||
->in('Feature');
|
||||
->in('Feature', 'Browser');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Services\QrCodeService;
|
||||
|
||||
test('it draws a 1024 pixel QR code as inline SVG, black on white', function () {
|
||||
$svg = (new QrCodeService)->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP');
|
||||
|
||||
expect($svg)->toStartWith('<svg ')
|
||||
->not->toContain('<?xml')
|
||||
->toContain('width="1024" height="1024"')
|
||||
->toContain('fill="#ffffff"')
|
||||
->toContain('fill="#000000"');
|
||||
});
|
||||
|
||||
test('the same contents give the same code, other contents another', function () {
|
||||
$service = new QrCodeService;
|
||||
|
||||
expect($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
|
||||
->toBe($service->svg('https://share.example.com/s/aBcDeFgHiJkLmNoP'))
|
||||
->not->toBe($service->svg('https://share.example.com/s/zYxWvUtSrQpOnMlK'));
|
||||
});
|
||||
Reference in New Issue
Block a user