Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461bc23f0a | ||
|
|
cd97612b4d | ||
|
|
c95d0c43c2 | ||
|
|
09e24ade14 | ||
|
|
21bea9646d | ||
|
|
27e322c352 |
@@ -5,5 +5,5 @@ paths:
|
||||
|
||||
# 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.
|
||||
## Regenerate the colour profiles, never hand-edit the scheme
|
||||
material-scheme.css and material-scheme.json are generated together by `php artisan material:scheme` (no seed) from the eight `profiles` in config/livewire-material.php — all Vibrant (chosen over Tonal Spot, which read washed out on indigo) except Graphite (Neutral); `profile` is the default, indigo. The JSON colours the Markdown mail theme and the fallback error pages and lists the profiles Admin settings offers and validates against, so a hand edit to the CSS alone leaves them out of step. Change the config and rerun the command; a profile only exists once generated. The admin's choice is the `color_profile` setting, read through `Scheme::resolveProfileUsing()` in AppServiceProvider.
|
||||
|
||||
@@ -6,4 +6,4 @@ paths:
|
||||
# Website
|
||||
|
||||
## website/ is the live site, uploaded by hand
|
||||
website/ is a faithful copy of sealshare.nonameweb.ch (METANET hosting), hand-written HTML/CSS with no build step, uploaded wholesale when it changes. Colours in css/theme.css are copied from resources/css/material-scheme.json — copy them again whenever the scheme is regenerated. The comparison tables are dated and every competitor value has a source from the product's own site, docs or repo; an unsourced value is "—", never a guess. Never call SealShare's encryption end-to-end (it encrypts at rest on the server). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
|
||||
website/ is a faithful copy of sealshare.nonameweb.ch (METANET hosting), hand-written HTML/CSS with no build step, uploaded wholesale when it changes. Colours in css/theme.css are copied from the indigo profile (the JSON's top-level light/dark) in resources/css/material-scheme.json — copy them again if indigo is regenerated differently; the site does not follow the admin's colour profile. The comparison tables are dated and every competitor value has a source from the product's own site, docs or repo; an unsourced value is "—", never a guess. Never call SealShare's encryption end-to-end (it encrypts at rest on the server). Nothing may load from another host except plausible.io. tests/Feature/WebsiteTest.php guards all of this.
|
||||
|
||||
@@ -46,6 +46,34 @@ 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.
|
||||
|
||||
### Colour profiles
|
||||
|
||||
An installation that switches between several schemes lists them in `config/livewire-material.php` and runs the command without a seed, which generates every profile into the same stylesheet, keyed by `<html data-scheme>`:
|
||||
|
||||
```php
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
],
|
||||
'profile' => 'indigo', // the default; else the first
|
||||
```
|
||||
|
||||
```bash
|
||||
php artisan material:scheme
|
||||
```
|
||||
|
||||
- Names are lowercase letters, digits and dashes. Regenerate after changing the list; only generated profiles exist for the picker, the resolver and the stylesheet.
|
||||
- The application says which profile is active, once, in a service provider. The closure runs every time a colour is drawn (head script, mail, error page), so it may read the database; a name that is not a generated profile, or a closure that throws, falls back to the default:
|
||||
|
||||
```php
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
```
|
||||
|
||||
- `<x-theme-script>` writes the active profile to `<html data-scheme>` before the first paint; mails and error pages draw it too. `Scheme::profiles()` lists the generated profiles (name ⇒ label, light and dark roles) and `Scheme::profile()` names the active one — validate a stored choice with `Rule::in(array_keys(Scheme::profiles()))`.
|
||||
- Choose with `<x-scheme-picker wire:model="colorProfile" />` (see Components). Never set `data-scheme` on an element inside the page expecting a different profile there: profiles key on `<html>`.
|
||||
|
||||
## 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.
|
||||
@@ -62,7 +90,7 @@ Tailwind's default palette is cleared: every colour class names an M3 role. `tex
|
||||
|
||||
## 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.
|
||||
`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. With colour profiles it also holds `scheme` (the profile on screen) and `previewScheme(name)`, which shows another profile on this page without storing anything.
|
||||
|
||||
## Toasts
|
||||
|
||||
@@ -724,6 +752,14 @@ An avatar that opens a menu: `name`, `email`, `avatar` (image URL or initials; d
|
||||
|
||||
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-scheme-picker>`
|
||||
|
||||
A choice of colour profile (see Colour profiles): a swatch per generated profile — its name and its primary, secondary and tertiary colour — over native radios. `wire:model` or `x-model` (with `name`) binds the chosen name; choosing previews it on the page at once; storing it is the application's. `label`, `hint`, `name`, `profiles` (default `Scheme::profiles()`). A validation error for the bound property replaces the hint. Without profiles it renders nothing.
|
||||
|
||||
```blade
|
||||
<x-scheme-picker :label="__('Colour profile')" wire:model="colorProfile" :hint="__('Applies to every page after saving')" />
|
||||
```
|
||||
|
||||
### `<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"`.
|
||||
|
||||
@@ -67,12 +67,14 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# The runner is an arm64 server, so the amd64 image is emulated. On its 6.8 kernel, recent
|
||||
# QEMU builds segfault compiling PHP extensions (docker/buildx#3170); QEMU 8 is pinned.
|
||||
# The runner is an arm64 server, so the amd64 image's final stage is emulated. QEMU 8.x
|
||||
# crashes running x86_64 programs on an arm64 host (QEMU issue 2168, "QEMU internal
|
||||
# SIGSEGV {code=MAPERR, addr=0x20}") and 10.2 segfaults on this runner too; 9.2.2 was
|
||||
# checked on the runner's host: node, composer and install-php-extensions all run.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
image: tonistiigi/binfmt:qemu-v8.1.5
|
||||
image: tonistiigi/binfmt:qemu-v9.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -27,3 +27,6 @@ frankenphp
|
||||
frankenphp-worker.php
|
||||
|
||||
/tests/Browser/Screenshots
|
||||
|
||||
# Planning notes stay local
|
||||
/docs/plans
|
||||
|
||||
@@ -7,10 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.0.1] - 2026-09-13
|
||||
|
||||
### Fixed
|
||||
|
||||
- The 2.0.0 Docker image did not start: the entrypoint's `php artisan view:cache` failed with "Unable to locate a class or view for component [showcase::example]", because Livewire Material only registered its showcase components while the showcase was enabled, which it is not in production. Livewire Material 1.1.1 registers them always, and a test now caches every view as the entrypoint does.
|
||||
|
||||
## [2.0.0] - 2026-09-13
|
||||
|
||||
### Added
|
||||
|
||||
- Eight colour profiles — Indigo (the default), Blue, Teal, Green, Amber, Rose, Violet and Graphite. An admin picks one in Admin settings, previews it on the page, and after saving every page, mail and error page uses it; light and dark stay each visitor's own choice.
|
||||
- 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).
|
||||
|
||||
@@ -106,5 +113,6 @@ 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://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...main
|
||||
[Unreleased]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.1...main
|
||||
[2.0.1]: https://gitea.nonameweb.ch/noNameWEB/SealShare/compare/v2.0.0...v2.0.1
|
||||
[2.0.0]: https://gitea.nonameweb.ch/noNameWEB/SealShare/releases/tag/v2.0.0
|
||||
|
||||
@@ -195,7 +195,7 @@ This application uses `nonameweb/livewire-material`: Material 3 Expressive compo
|
||||
|
||||
- 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.
|
||||
- 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. With colour profiles (`livewire-material.profiles`), run it without a seed after changing them; the active profile comes from `Scheme::resolveProfileUsing()`.
|
||||
- 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`.
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# ============================================
|
||||
# Stage 1: Install PHP dependencies
|
||||
# ============================================
|
||||
FROM composer:2 AS vendor
|
||||
# Built on the build machine's own platform: vendor/ is plain PHP, the same for every target, so a
|
||||
# multi-arch build runs it once and never under emulation.
|
||||
FROM --platform=$BUILDPLATFORM composer:2 AS vendor
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -21,8 +23,9 @@ 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
|
||||
# After Composer: the stylesheet and script import Livewire Material from vendor/. On the build
|
||||
# machine's platform too: the output is CSS and JavaScript, whatever the target.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS assets
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ A simple, self-hosted file sharing solution built with Laravel. Upload files, ge
|
||||
- **Admin Dashboard** — View, manage, and delete all shares
|
||||
- **Admin Settings** — Configure upload limits, storage quotas, branding, and more
|
||||
- **Site Branding** — Custom logo, title, and description
|
||||
- **Colour Profiles** — Eight Material 3 colour profiles (Indigo, Blue, Teal, Green, Amber, Rose, Violet, Graphite); the admin picks one for every page, mail and error page
|
||||
- **System Password** — Optional global password gate to restrict upload access
|
||||
- **User Authentication** — Login, password reset, email verification
|
||||
- **Two-Factor Authentication** — TOTP-based 2FA via Laravel Fortify
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace App\Livewire\Admin;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use NoNameWeb\LivewireMaterial\Concerns\Toasts;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
#[Layout('layouts.app')]
|
||||
class AdminSettings extends Component
|
||||
@@ -16,6 +18,9 @@ class AdminSettings extends Component
|
||||
use Toasts;
|
||||
use WithFileUploads;
|
||||
|
||||
/** The colour profile every page, mail and error page wears (config/livewire-material.php). */
|
||||
public string $colorProfile = '';
|
||||
|
||||
public string $systemPassword = '';
|
||||
|
||||
public string $defaultExpiration = '';
|
||||
@@ -44,6 +49,7 @@ class AdminSettings extends Component
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->colorProfile = Scheme::profile() ?? '';
|
||||
$this->defaultExpiration = Setting::get('default_expiration', '') ?? '';
|
||||
$this->maxFileSize = min(
|
||||
(int) Setting::get('max_file_size', 100 * 1024 * 1024) / (1024 * 1024),
|
||||
@@ -83,6 +89,7 @@ class AdminSettings extends Component
|
||||
$phpMaxMb = self::phpMaxUploadMb();
|
||||
|
||||
$this->validate([
|
||||
'colorProfile' => ['required', 'string', Rule::in(array_keys(Scheme::profiles()))],
|
||||
'maxFileSize' => ['required', 'integer', 'min:1', 'max:'.$phpMaxMb],
|
||||
'maxStorageQuota' => ['required', 'integer', 'min:1'],
|
||||
'maxFilesPerShare' => ['required', 'integer', 'min:1'],
|
||||
@@ -98,6 +105,7 @@ class AdminSettings extends Component
|
||||
Setting::set('system_password', Hash::make($this->systemPassword));
|
||||
}
|
||||
|
||||
Setting::set('color_profile', $this->colorProfile);
|
||||
Setting::set('default_expiration', $this->defaultExpiration ?: null);
|
||||
Setting::set('max_file_size', $this->maxFileSize * 1024 * 1024);
|
||||
Setting::set('max_storage_quota', $this->maxStorageQuota * 1024 * 1024 * 1024);
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -24,6 +26,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureDefaults();
|
||||
|
||||
// The colour profile the admin chose in Admin settings; asked each time a page, mail or
|
||||
// error page draws its colours, so a new choice applies at once in every Octane worker.
|
||||
Scheme::resolveProfileUsing(fn (): ?string => Setting::get('color_profile'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2999,11 +2999,11 @@
|
||||
},
|
||||
{
|
||||
"name": "nonameweb/livewire-material",
|
||||
"version": "1.0.1",
|
||||
"version": "1.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://gitea.nonameweb.ch/noNameWEB/livewire-material.git",
|
||||
"reference": "7798352cfc71643a88d4658f9704e65c4e24e8da"
|
||||
"reference": "db179e6b4e380bbd6aba2d3766ffcc84e58a8dd2"
|
||||
},
|
||||
"require": {
|
||||
"laravel/framework": "^13.0",
|
||||
@@ -3083,7 +3083,7 @@
|
||||
"material-design",
|
||||
"tailwindcss"
|
||||
],
|
||||
"time": "2026-09-13T09:16:52+00:00"
|
||||
"time": "2026-09-13T15:40:33+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nunomaduro/termwind",
|
||||
|
||||
@@ -107,6 +107,32 @@ return [
|
||||
|
||||
'scheme' => resource_path('css/material-scheme.json'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Colour profiles
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The profiles an admin chooses between in Admin settings. Each one is a
|
||||
| 'label', a 'seed' (#rrggbb), a 'variant' and an optional 'contrast'.
|
||||
| `php artisan material:scheme` (without a seed) generates them all into
|
||||
| resources/css/material-scheme.css; regenerate after changing this list.
|
||||
| 'profile' is the default, until an admin chooses.
|
||||
|
|
||||
*/
|
||||
|
||||
'profiles' => [
|
||||
'indigo' => ['label' => 'Indigo', 'seed' => '#4f46e5', 'variant' => 'vibrant'],
|
||||
'blue' => ['label' => 'Blue', 'seed' => '#0b57d0', 'variant' => 'vibrant'],
|
||||
'teal' => ['label' => 'Teal', 'seed' => '#00897b', 'variant' => 'vibrant'],
|
||||
'green' => ['label' => 'Green', 'seed' => '#2e7d32', 'variant' => 'vibrant'],
|
||||
'amber' => ['label' => 'Amber', 'seed' => '#e8710a', 'variant' => 'vibrant'],
|
||||
'rose' => ['label' => 'Rose', 'seed' => '#c2185b', 'variant' => 'vibrant'],
|
||||
'violet' => ['label' => 'Violet', 'seed' => '#6750a4', 'variant' => 'vibrant'],
|
||||
'graphite' => ['label' => 'Graphite', 'seed' => '#5f6368', 'variant' => 'neutral'],
|
||||
],
|
||||
|
||||
'profile' => 'indigo',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# SealShare on Livewire Material (2.0.0)
|
||||
|
||||
> The package itself — its decisions, the wave plan (Phases 1–10) and its tests — moved to
|
||||
> the package repo on 2026-09-13: [noNameWEB/livewire-material · docs/plans/livewire-material.md](https://gitea.nonameweb.ch/noNameWEB/livewire-material/src/branch/main/docs/plans/livewire-material.md).
|
||||
> This file keeps what SealShare does once the package reaches `1.0.0`.
|
||||
|
||||
## Goal
|
||||
|
||||
SealShare's UI is maryUI 2.9 on daisyUI 5 — a generic web-page look. After this change it runs
|
||||
on **`nonameweb/livewire-material` `^1.0`**: a clean, calm indigo Material 3 Expressive app with
|
||||
a top app bar, light / dark / system theme, and two Expressive moments — the upload drop zone
|
||||
and "link ready" — shipped as SealShare 2.0.0.
|
||||
|
||||
## Context
|
||||
|
||||
**Stacks.** SealShare: Laravel 13.31, Livewire 4.4, maryUI 2.9.10 (no prefix), daisyUI 5.7,
|
||||
Tailwind 4.3, Pest 5.1, Octane on FrankenPHP, PHP 8.5; public on GitHub under MIT, image
|
||||
published to `ghcr.io/surtic86/sealshare`. ReStride: same Laravel / Livewire / Tailwind / Pest,
|
||||
private on `gitea.nonameweb.ch`, CI through Gitea act_runner.
|
||||
|
||||
**SealShare's UI surface** (inventory, 2026-09-13):
|
||||
|
||||
- maryUI tags: `button` 30, `input` 18, `password` 16, `icon` 14, `card` 9 (6 `actions`
|
||||
slots), `menu`/`menu-item` 1/4 (settings nav), `theme-toggle` 3, `toggle` 2, `select` 2,
|
||||
`modal` 2, `table` 1 (`:headers :rows :sort-by with-pagination`, `@scope`), `textarea` 1,
|
||||
`toast` 1 (never triggered).
|
||||
- Raw daisyUI: `btn` (+ `-primary/-ghost/-sm/-xs/-error/-outline/-disabled`), `alert` ×6,
|
||||
`card`/`card-body` (4 admin stat tiles), `join` (2 copy fields), `progress` ×2,
|
||||
`loading` ×2, `badge-success/-error`, `divider`, `link link-primary` ×5, `label`,
|
||||
`file-input`, `checkbox`; tokens `bg-base-*`, `border-base-300`, `text-error/success`,
|
||||
`border-primary(/50)`, `bg-primary/5`; raw `text-green-600`, `bg-white` (QR code).
|
||||
Secondary text is `opacity-50/60/70`.
|
||||
- 20 Heroicons (outline), through `blade-heroicons` pulled in transitively by maryUI.
|
||||
- No `Mary\` PHP coupling. Admin settings flashes `session('message')` into an alert
|
||||
(`AdminSettings.php:116,128,135`). 3 `wire:confirm`.
|
||||
- Layouts: `layouts/app` → `app/sidebar` (centered `max-w-5xl` + footer nav), used by the
|
||||
Livewire pages and all settings SFCs (`config/livewire.php:47`); `layouts/auth` →
|
||||
`auth/simple`. The theme script sits *outside* `<head>` and hard-codes dark, while maryUI's
|
||||
toggle defaults from the OS. `partials/head` loads Instrument Sans from fonts.bunny.net.
|
||||
- Dead: `/dashboard` (starter placeholder, and Fortify's `home`), `welcome`,
|
||||
`pages/auth/register` (still referenced by `Fortify::registerView`,
|
||||
`FortifyServiceProvider.php:52`), `layouts/app/header`, `layouts/auth/{card,split}`,
|
||||
`components/app-logo`, `components/desktop-user-menu`, `components/placeholder-pattern`;
|
||||
the `alpinejs` npm dependency; the Flux credentials step in `tests.yml` and `docker.yml`.
|
||||
- Settings `profile` and `password` show "Saved." through `components/action-message`,
|
||||
listening for `profile-updated` / `password-updated`; `partials/settings-heading` uses a
|
||||
daisyUI `divider`. The 3 `wire:confirm` are admin settings (remove logo, clear system
|
||||
password) and admin dashboard (delete share). `AdminDashboard::headers()` exists only for
|
||||
maryUI's table.
|
||||
- Tests assert text only, never markup; no browser tests.
|
||||
- Docker: the image's caches run in `docker/entrypoint.sh` (`config:cache`, `route:cache`,
|
||||
`view:cache`); `docker/dev-entrypoint.sh` runs `npm run build` against the host's mounted
|
||||
`vendor/` without a `composer install`. The Flux credentials step is in `tests.yml`,
|
||||
`docker.yml` **and** `lint.yml`.
|
||||
- Screens: setup, system password, upload, share created, share download, admin dashboard,
|
||||
admin settings, settings (profile, password, appearance, two-factor), Fortify pages (login,
|
||||
forgot, reset, 2FA challenge, confirm, verify email). Stock Laravel error pages and mails.
|
||||
|
||||
**Constraints found.**
|
||||
|
||||
- SealShare's `Dockerfile` builds assets (stage 1) **before** `composer install` (stage 2);
|
||||
CSS imported from `vendor/` needs the order swapped.
|
||||
- Laravel **replaces** the `errors` view namespace at render time with
|
||||
`config('view.paths')` + `/errors` and the framework's own
|
||||
(`Illuminate/Foundation/Exceptions/RegisterErrorViewPaths.php`), so error views a package
|
||||
adds with `addNamespace('errors', …)` are wiped; only a path in `view.paths` survives.
|
||||
- The package lives at `https://gitea.nonameweb.ch/noNameWEB/livewire-material.git` (public,
|
||||
anonymous reads verified 2026-09-13).
|
||||
|
||||
## Decisions
|
||||
|
||||
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), 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
|
||||
account menu (Upload, Admin dashboard, Admin settings, Settings, theme, Log out) for users;
|
||||
centered content; Admin and Settings sub-pages as secondary tabs (menu picker on a phone);
|
||||
auth pages a centered card under the same bar. No rail, no bottom bar.
|
||||
- **Expressive components plus two hero moments** — an Expressive shape behind the upload icon
|
||||
that morphs while files are dragged over, the wavy progress indicator for uploads, a
|
||||
shape-backed check when the link is ready; admin stats count up once. Instant under
|
||||
`prefers-reduced-motion`.
|
||||
- **The public download page uses no anchored components** (no menus, no tooltips) — it must
|
||||
work for recipients on iOS below 18.4.
|
||||
- **Starter-kit cleanup during the conversion** — delete the placeholder `/dashboard`, point
|
||||
Fortify `home` at the admin dashboard, delete the unused views and the `registerView`
|
||||
binding, drop `alpinejs` from npm and the Flux step from CI.
|
||||
- **Confirmations become M3 basic dialogs** (the 3 `wire:confirm`) — the browser's native
|
||||
confirm cannot be themed and reads as a different app. *(Not asked in the interview; object
|
||||
in review if you prefer the native confirm.)*
|
||||
- **Save feedback becomes a snackbar** through the package's `Toasts` concern — admin
|
||||
settings' flashed `session('message')` alert and settings' "Saved." `action-message` alike.
|
||||
*(Follows from the snackbar; not asked separately.)*
|
||||
- **The font is self-hosted** — the fonts.bunny.net request goes, which also suits a
|
||||
privacy-minded self-hosted app.
|
||||
- **Tests: updated feature tests, the package's guard as `DesignLanguageTest`, Livewire tests
|
||||
for changed behaviour, and four browser tests** with `pestphp/pest-plugin-browser` (new dev
|
||||
dependency, approved).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- ReStride adopting the package — its own plan, after `1.0.0`.
|
||||
- Moving SealShare's repository, CI and image registry to Gitea — its own plan.
|
||||
- Everything the package plan puts out of scope.
|
||||
- Changes to SealShare's features, routes or information architecture beyond the cleanup above.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
Step numbers continue the original plan's, so references elsewhere stay valid.
|
||||
|
||||
### Phase 11 — SealShare 2.0.0 (after `1.0.0`)
|
||||
|
||||
34. **Branch** `material` from `main`; open the PR so CI runs.
|
||||
35. **Dependencies.** Add the `vcs` repository and `composer require nonameweb/livewire-material:^1.0`;
|
||||
`composer remove robsontenorio/mary` (drops `blade-heroicons` with it);
|
||||
`npm remove daisyui alpinejs`; `composer require --dev pestphp/pest-plugin-browser`.
|
||||
maryUI goes **first** because its class components would shadow the package's same-named
|
||||
anonymous ones; the branch is therefore red from here until step 44, which is accepted —
|
||||
it merges once, green (Decisions: one pass).
|
||||
36. **CI and Docker.** Remove the Flux credentials step from `.github/workflows/tests.yml`,
|
||||
`docker.yml` and `lint.yml`; install Playwright browsers in `tests.yml`. `Dockerfile`: run
|
||||
the Composer stage first and `COPY --from=vendor /app/vendor ./vendor` into the Node stage
|
||||
before `npm run build`. `docker/dev-entrypoint.sh`: run `composer install` when `vendor/` is
|
||||
missing, before `npm run build`. No `icons:cache` anywhere: the package draws its symbols
|
||||
without blade-icons.
|
||||
37. **Styles and scheme.** `resources/css/app.css`: `@import 'tailwindcss'`, the package entry
|
||||
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=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']`.
|
||||
39. **Layouts.** Rebuild `layouts/app.blade.php` (absorbing `app/sidebar`): `<x-app-bar>` with
|
||||
`app-logo-icon` / branding logo and site title, `<x-theme-toggle>` for guests or
|
||||
`<x-account-menu>` for users (Upload, Admin dashboard, Admin settings, Settings, theme, Log
|
||||
out through `App\Livewire\Actions\Logout`), centered content, `<x-toast>`.
|
||||
`layouts/auth.blade.php` (absorbing `auth/simple`): the same bar and a centered card.
|
||||
40. **Cleanup.** Delete the `/dashboard` route, `dashboard.blade.php`, `placeholder-pattern`,
|
||||
`welcome`, `pages/auth/register` and its `Fortify::registerView` line,
|
||||
`layouts/app/{header,sidebar}`, `layouts/auth/{card,split,simple}`, `app-logo`,
|
||||
`desktop-user-menu`. Fortify `home` → `/admin/dashboard`. Update `AuthenticationTest:22`
|
||||
and `EmailVerificationTest:32,63` to the new redirect; `DashboardTest` is rewritten to
|
||||
assert that a signed-in admin lands on the admin dashboard and `/dashboard` is gone
|
||||
(replacing its placeholder tests, approved in the interview). `RegistrationTest` stays.
|
||||
41. **Public pages.** `livewire/file-uploader`: drop zone with `<x-shape>` behind the upload
|
||||
icon morphing while `dragging`, existing Alpine folder walking and `livewire-upload-*`
|
||||
wiring kept, wavy `<x-progress>`, `<x-loading>` for processing, selected files as
|
||||
`<x-list>`, Share Options `<x-card>` (`<x-toggle>`, `<x-select>`, number `<x-input>`s),
|
||||
`<x-alert>` for storage full, filled primary "Create Share Link".
|
||||
`share-created`: shape-backed check, `<x-input copyable>` for the link, four `<x-stat>`,
|
||||
info `<x-alert>`, "Upload More". `share-download`: password `<x-card>` with
|
||||
`<x-password>`, files as `<x-list>` with download icon buttons, "Download All" — no menus
|
||||
or tooltips. `system-password-prompt`, `setup-wizard` onto fields and buttons.
|
||||
42. **Auth pages** (`login`, `forgot-password`, `reset-password`, `two-factor-challenge`,
|
||||
`confirm-password`, `verify-email`): fields, `<x-checkbox>` for remember me, `link` utility
|
||||
for text links, `auth-session-status` onto `<x-alert>` (drops `text-green-600`).
|
||||
43. **Settings.** `pages/settings/layout` → `<x-section-nav>`; `partials/settings-heading`
|
||||
drops the daisyUI divider for `<x-divider>`; `profile` and `password` show "Saved." as a
|
||||
snackbar through `Toasts` (the `profile-updated` / `password-updated` dispatches stay for
|
||||
any listener) and `components/action-message` is deleted;
|
||||
`appearance` → Light / Dark / System `<x-group>` on `$store.theme` (the only toggle on the
|
||||
page); `two-factor` → `<x-badge>` status, `<x-modal fullscreen>` setup with the QR on a
|
||||
white token surface, `<x-input copyable>` key, recovery codes; `delete-user-form` →
|
||||
`<x-modal>` with a `danger` action.
|
||||
44. **Admin.** `admin-dashboard`: four `<x-stat>` (counting up once), disk usage
|
||||
`<x-progress>`, hand-written `<x-table>` with `<x-sort-header>` and pagination (the
|
||||
`@scope` cells become plain Blade and `AdminDashboard::headers()` goes), view and delete
|
||||
icon buttons, delete confirmation in a basic `<x-modal>` instead of `wire:confirm`.
|
||||
`admin-settings`: cards, `<x-textarea>`, `<x-file>` for the logo with preview,
|
||||
`<x-toggle>`, `<x-select>`, `<x-input suffix>`; "Remove the logo?" and "Remove the system
|
||||
password?" become basic dialogs instead of `wire:confirm`; `AdminSettings` uses `Toasts`
|
||||
instead of `session()->flash('message')` (3 places) and the alert block goes. Keep every
|
||||
existing `data-test` attribute on the element that now plays its role.
|
||||
45. **Error pages and mail.** Confirm the package's error views render in SealShare's theme;
|
||||
set `config/mail.php` `markdown.theme` to `livewire-material::mail.theme`; check the
|
||||
password-reset and verify-email mails.
|
||||
46. **Guards.** `tests/Feature/DesignLanguageTest.php` using `DesignGuard` over
|
||||
`resources/views` and `app/` — no maryUI, no daisyUI, only declared colours, only existing
|
||||
icons. A grep for `base-content|bg-base|btn|mary` returns nothing.
|
||||
47. **Rules and AI.** `php artisan boost:update --discover` to install the package guideline and
|
||||
skill; `record-rule` for SealShare: the scheme is regenerated with `material:scheme`,
|
||||
never hand-edited; the download page stays free of anchored components; the theme key.
|
||||
48. **Docs.** README tech stack and the "Dark Mode" feature line; CHANGELOG `2.0.0`.
|
||||
49. **Ship.** Full suite green on the PR; merge; tag `v2.0.0` (publishes the image through
|
||||
`docker.yml`).
|
||||
|
||||
## Testing
|
||||
|
||||
- Feature tests updated where redirects or text change: `AuthenticationTest`,
|
||||
`EmailVerificationTest`, `DashboardTest` (rewritten), `AdminSettingsTest` (asserts the
|
||||
toast is dispatched instead of the flash), `TwoFactorAuthenticationTest`,
|
||||
`AdminDashboardTest`, `ShareDownloadTest`.
|
||||
- `DesignLanguageTest` through the package guard.
|
||||
- Livewire tests: admin settings save/remove-logo/clear-password dispatch toasts; profile and
|
||||
password updates dispatch the "Saved." toast (`ProfileUpdateTest`, `PasswordUpdateTest`);
|
||||
delete share, remove logo and clear system password go through their dialogs' confirm
|
||||
actions.
|
||||
- Browser tests (`tests/Browser`): upload by drop and by Browse → progress → share created →
|
||||
copy link; the password-protected download page at 393px; admin table sort and delete
|
||||
dialog; a first visit follows the OS theme and Appearance switches it.
|
||||
- Narrow runs per step; the full suite on the PR's CI.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Scope and time.** The whole catalogue (~45 components plus extras) comes before SealShare
|
||||
changes at all, so its starter-kit bugs (the placeholder `/dashboard`) stay until then.
|
||||
Mitigation: waves tagged `0.x`, each reviewed in the showcase; SealShare keeps working
|
||||
meanwhile.
|
||||
- **Gitea becomes a build dependency.** Every SealShare CI run and Docker build fetches the
|
||||
package from `gitea.nonameweb.ch`; an outage or a sign-in setting reverting breaks builds.
|
||||
Mitigation: dist archives cached by Composer in CI; revisit Packagist if it bites.
|
||||
- **iOS / Safari below 18.4.** Anchored menus and tooltips do not position there. Mitigation:
|
||||
SealShare's download page uses none; native `<select>` stays the fallback everywhere.
|
||||
- **Scheme and spring values are tuned by eye**; Tonal Spot may read washed out on indigo —
|
||||
the Vibrant comparison in step 37 is the check.
|
||||
@@ -1,257 +0,0 @@
|
||||
# Screenshots and the SealShare website
|
||||
|
||||
## Goal
|
||||
|
||||
Two things that feed each other. First, one command — `composer screenshots` — produces every
|
||||
screenshot of SealShare from fixed demo data, desktop and phone, light and dark, ready for the web.
|
||||
Second, a static website at **sealshare.nonameweb.ch**, made the way mailifysms.nonameweb.ch is:
|
||||
hand-written HTML and CSS in `website/`, uploaded by hand. The site presents SealShare as what it
|
||||
is — software a company installs to run **its own upload platform**, so it exchanges files with
|
||||
customers securely without relying on an outside service — shows the screenshots, compares
|
||||
SealShare with hosted transfer services and with other self-hosted tools, and tells how to install
|
||||
it. It goes live with 2.0.0. The README gets a few of the same screenshots, and its encryption
|
||||
wording is corrected.
|
||||
|
||||
## Context
|
||||
|
||||
**MailifySMS, the model** (`../MailifySMS`):
|
||||
|
||||
- `website/` holds `index.html`, `privacy_policy.html`, `terms_and_conditions.html`,
|
||||
`css/theme.css` (a palette sampled from the app's screenshots), `css/device-frame.css` (a phone
|
||||
bezel shared with the store canvases), self-hosted Poppins (`fonts/`, OFL) and `img/`
|
||||
(`icon.png`, `hero.jpg`, `screenshots/{light,dark}/NN-name.png` at 540px).
|
||||
- `index.html`: sticky nav with a phone toggle, hero, "How it works", "Key features", a screenshot
|
||||
gallery with a Light/Dark switch (`data-light`/`data-dark` on each `<img>`), FAQ accordion,
|
||||
contact card (`surtic86@gmail.com`), footer (quick links, legal). Plausible:
|
||||
`<script defer data-domain="mailifysms.nonameweb.ch" src="https://plausible.io/js/script.js">`.
|
||||
The page's JS is one inline `<script>` at the end.
|
||||
- `CLAUDE.md` records that `website/` "is a faithful copy of what is deployed, images included, so
|
||||
it can be uploaded wholesale". There is no deploy automation.
|
||||
- `tools/screenshots.sh` (macOS only) drives an emulator and headless Chrome; documented in
|
||||
`CLAUDE.md` § Screenshots, with the reasons behind each quirk.
|
||||
|
||||
**Hosting.** `*.nonameweb.ch` is a wildcard DNS record to `80.74.140.2` (METANET shared hosting,
|
||||
nginx), the same as mailifysms. `sealshare.nonameweb.ch` resolves already; HTTP serves the host's
|
||||
placeholder, HTTPS has no certificate. Creating the site and its Let's Encrypt certificate is done
|
||||
in the hosting panel.
|
||||
|
||||
**SealShare.**
|
||||
|
||||
- Laravel 13.31, Livewire 4.4, Livewire Material 1.0.1, Pest 5.1 with `pestphp/pest-plugin-browser`
|
||||
(Playwright 1.63). The browser tests run the app in-process, so factories, `Storage::fake()` and
|
||||
`travelTo()` shape what the browser sees. `tests/Pest.php` applies `Tests\TestCase` and
|
||||
`RefreshDatabase` to `Feature` and `Browser`, and creates an admin in `beforeEach` (the setup
|
||||
gate).
|
||||
- Device presets: `visit()->on()->macbook14()` is 1512×982 at 2× (a 3024×1964 capture);
|
||||
`on()->iPhone15Pro()` is 393×852 at 3× (1179×2556). `inLightMode()` / `inDarkMode()`,
|
||||
`screenshot(fullPage, filename)`. Screenshots are written to `tests/Browser/Screenshots/<name>.png`;
|
||||
the directory is created, subdirectories in the name are not — names must be flat. Pest empties
|
||||
that directory when a browser run starts, and has no reduced-motion emulation.
|
||||
- Pest's in-process server does not store a multipart upload, so a browser test cannot select files
|
||||
through the file input.
|
||||
- PHP here has GD with WebP and PNG support; the production image does not need it (this is a
|
||||
development tool).
|
||||
- Colours: `resources/css/material-scheme.json` (seed `#4f46e5`, Vibrant) holds the light and dark
|
||||
roles as hexes. Font: Google Sans Flex, `vendor/nonameweb/livewire-material/resources/fonts/google-sans-flex/GoogleSansFlex-Latin.woff2`
|
||||
with its `OFL.txt`. Logo: `resources/views/components/app-logo-icon.blade.php` (SVG).
|
||||
- **Encryption, as the code does it:** `ShareService::createShare()` encrypts each uploaded file on
|
||||
the server with AES-256-GCM (chunked) through `FileEncryptionService`. Without a share password
|
||||
the key is stored in `shares.encryption_key`; with one, the key is derived with PBKDF2-SHA256 and
|
||||
never stored. The server sees the plaintext while uploading and downloading. The README calls
|
||||
this "End-to-End Encryption", which it is not.
|
||||
- The upload page is public, optionally behind the system password (`SystemPasswordGate`); a
|
||||
customer given that password can upload and send the link back.
|
||||
- Install today (README): `ghcr.io/surtic86/sealshare`, clone from GitHub. Gitea
|
||||
(`gitea.nonameweb.ch/noNameWEB/SealShare`) is now the public repository; the image registry for
|
||||
2.0.0 is settled separately.
|
||||
- `.gitignore` does not ignore `tests/Browser/Screenshots`; `.dockerignore` excludes `tests` and
|
||||
`*.md` but would copy a `website/` directory into the image.
|
||||
|
||||
**Peers.** Pingvin Share has been archived since June 2025 (its README points to forks such as
|
||||
Pingvin Share X). PsiTransfer, Gokapi and Erugo are single-purpose self-hosted share tools; Gokapi
|
||||
advertises end-to-end encryption.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Positioning: software you host, not a service** — the site says plainly that SealShare is not
|
||||
hosted by anyone but the company that installs it: its own upload platform for exchanging files
|
||||
with customers, data on its own server, no dependence on an external service.
|
||||
- **Pages: `index.html` and `privacy.html`** — one landing page, and a short privacy page because
|
||||
the site uses Plausible. No terms page: the software is MIT-licensed and no service is offered.
|
||||
- **A comparison with hosted transfer services and with self-hosted share tools** — two tables on
|
||||
the landing page. Cloud suites (Nextcloud-style) are left out.
|
||||
- **Hand-written HTML and CSS, like MailifySMS** — no build step; `website/` is uploaded as it is.
|
||||
- **Colours copied from `material-scheme.json`, not sampled** — `website/css/theme.css` lists the
|
||||
roles it uses with the scheme's hexes, light by default and dark under
|
||||
`@media (prefers-color-scheme: dark)`; it names the seed and variant it was copied from, so a
|
||||
regenerated scheme is copied again. The site follows the visitor's system theme and has no
|
||||
toggle of its own.
|
||||
- **Google Sans Flex, self-hosted** — the app's font, copied with its `OFL.txt` into
|
||||
`website/fonts/`; nothing is loaded from Google.
|
||||
- **Uploaded by hand, like MailifySMS** — `website/` is a faithful copy of what is live. You create
|
||||
the subdomain and certificate once in the hosting panel and upload the folder when it changes.
|
||||
No hosting credentials anywhere in the repository or CI.
|
||||
- **Plausible** — `data-domain="sealshare.nonameweb.ch"`, the same script as MailifySMS; the site
|
||||
must be added in the Plausible account.
|
||||
- **English only.**
|
||||
- **Screenshots: desktop and phone, each in light and dark (20 images)** —
|
||||
desktop (MacBook 14, 2×): `01-upload` (files selected, options filled), `02-share-created`,
|
||||
`03-qr-code` (the dialog), `04-download` (the recipient's file list), `05-admin-dashboard`,
|
||||
`06-admin-settings`; phone (iPhone 15 Pro, 3×): `01-upload`, `02-password` (the recipient's
|
||||
password prompt), `03-download`, `04-qr-code`.
|
||||
- **Screenshots run as Pest browser tests in `tests/Screenshots/`, started by `composer screenshots`**
|
||||
— the directory is not one of phpunit.xml's test suites, so `php artisan test`, the Browser
|
||||
suite and CI never run it. It reuses Playwright and the in-process server.
|
||||
- **Fixed demo data** — factories and `ShareService` with fixed names, sizes and tokens, time
|
||||
frozen with `travelTo()`, the site title and branding at their defaults, one admin
|
||||
("Alex Morgan"). Every run produces the same images unless the UI changed.
|
||||
- **Images published as WebP by the test run itself** — after each capture a small helper resizes
|
||||
it with GD into `website/img/screenshots/{desktop,phone}/{light,dark}/NN-name-<width>.webp` at two widths
|
||||
(desktop 1600 and 800 px, phone 1080 and 540 px) for `srcset`. Raw PNGs stay in
|
||||
`tests/Browser/Screenshots/`, which is gitignored; only the WebP files are committed.
|
||||
- **Device frames in CSS** — `website/css/device-frame.css` draws a laptop and a phone around the
|
||||
screenshots; the hero shows the desktop upload and the phone download screenshots framed, in
|
||||
the visitor's theme. No generated hero image.
|
||||
- **README shows three screenshots** — desktop upload, desktop share created, phone download (light),
|
||||
referenced from `website/img/screenshots/…`, so each image exists once in the repository.
|
||||
- **Encryption is described accurately, on the site and in the README** — "encrypted at rest with
|
||||
AES-256-GCM; with a share password the key is never stored". The comparison marks end-to-end
|
||||
encryption "no" for SealShare. The README's "End-to-End Encryption" line is corrected.
|
||||
- **Built on `material`, live with 2.0.0** — screenshots show the 2.0.0 interface; the site links
|
||||
the Gitea repository, and its install commands are the README's at release, whatever registry
|
||||
2.0.0 ships with.
|
||||
- **Comparison facts are researched, dated and sourced** — from each product's own site,
|
||||
documentation or repository; the tables say "as of <month year>" and link every source. Hosted
|
||||
services: WeTransfer, SwissTransfer, Dropbox Transfer, Google Drive links. Self-hosted tools:
|
||||
open source, single-purpose, installable with Docker, with a release in the 12 months before the
|
||||
research — expected Pingvin Share X, PsiTransfer, Gokapi and Erugo; any that fails the rule is
|
||||
dropped and named in the commit message. Criteria (rows): where files are stored, who operates
|
||||
it, recipient needs an account, password protection, expiry, download limit, encryption at rest,
|
||||
end-to-end encryption, folder upload, custom branding, maximum file size, licence and cost. A
|
||||
value that cannot be sourced is "—", never guessed.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- A terms page, a documentation section, a German version.
|
||||
- Deploy automation (Gitea Actions, SFTP scripts), and creating the subdomain, certificate or
|
||||
Plausible site — those are yours in the hosting panel and the Plausible account.
|
||||
- Comparing with Nextcloud, ownCloud or other cloud suites.
|
||||
- Store-style canvases with captions, a generated hero image, video or animated screenshots.
|
||||
- Running the screenshots in CI, or checking them against earlier runs (visual regression).
|
||||
- The image registry move and the install commands' final form (settled with 2.0.0).
|
||||
- Implementing end-to-end encryption.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Housekeeping.** `.gitignore`: `/tests/Browser/Screenshots`. `.dockerignore`: `website`.
|
||||
2. **Screenshot helper.** `tests/Screenshots/Publisher.php` (`Tests\Screenshots\Publisher`):
|
||||
`publish(string $capture, string $device, string $theme, string $name, array $widths): void`
|
||||
reads `tests/Browser/Screenshots/<capture>.png` with GD, and for each width writes
|
||||
`website/img/screenshots/<device>/<theme>/<name>-<width>.webp` (quality 82, aspect kept,
|
||||
directories created). It throws when the capture is missing, so a failed shot fails the run.
|
||||
3. **Demo data.** `tests/Screenshots/DemoData.php`: `admin()`, `shares()` (eight shares with fixed
|
||||
tokens, file names such as `Q3-report.pdf`, `Contract 2026.pdf`, `Product photos/…`, sizes,
|
||||
download counts and expiries, one password-protected, one expired), created through factories
|
||||
and `ShareService` with `Storage::fake('shares')` so the files exist encrypted.
|
||||
4. **Screenshot tests.** `tests/Screenshots/ScreenshotsTest.php`, with `tests/Pest.php` extended to
|
||||
`->in('Feature', 'Browser', 'Screenshots')`:
|
||||
- `beforeEach`: `config(['session.driver' => 'file'])`, `travelTo('2026-10-01 09:30')`, demo
|
||||
data; a `ready()` wait as in `SealShareTest`.
|
||||
- One test per device and theme (four tests), each visiting the pages in turn, waiting for
|
||||
`networkidle` and fonts (`document.fonts.ready`), hiding the text caret, capturing
|
||||
viewport-sized (not full-page) shots, and calling `Publisher::publish()` right after each.
|
||||
- The upload shot with files selected: create Livewire temporary uploads on the fake
|
||||
`livewire-tmp` disk and set the uploader's property through `$wire.$set` with
|
||||
`livewire-file:` references, then fill the options. If Livewire refuses that, the shot shows
|
||||
the drop zone with the options filled instead, and the plan's risk note is updated.
|
||||
- QR dialog: `click('[data-test="show-qr-code"]')`; password prompt: the protected share on the
|
||||
phone; admin pages as the admin.
|
||||
5. **Command.** `composer.json` script `"screenshots"`: `Composer\\Config::disableProcessTimeout`,
|
||||
`npm run build`, `@php vendor/bin/pest tests/Screenshots` — the build first, so the shots show
|
||||
the current assets. Playwright's Chromium must be installed (`npx playwright install chromium`),
|
||||
as for the browser tests.
|
||||
6. **Website scaffold.** `website/`:
|
||||
- `css/theme.css` — tokens copied from `material-scheme.json` (with seed and variant noted),
|
||||
Google Sans Flex `@font-face`, layout, nav, hero, sections, cards, tables (scrolling
|
||||
sideways on a phone), FAQ (`<details>`), footer; light and dark through
|
||||
`prefers-color-scheme`.
|
||||
- `css/device-frame.css` — laptop and phone frames.
|
||||
- `fonts/GoogleSansFlex-Latin.woff2`, `fonts/OFL.txt`; `img/logo.svg` (from `app-logo-icon`),
|
||||
`img/icon.png` (favicon, from `public/`).
|
||||
7. **Landing page.** `website/index.html` (Plausible in `<head>`, one inline script at the end):
|
||||
- nav: Why, Features, Screenshots, Compare, Install, FAQ, Gitea;
|
||||
- hero: "Your own secure upload platform" — self-hosted file exchange with customers, no
|
||||
outside service; buttons "Install" (to #install) and "Source on Gitea"; framed desktop and
|
||||
phone screenshots as `<picture>` elements whose `<source media="(prefers-color-scheme: dark)">`
|
||||
picks the dark captures;
|
||||
- "Why run your own": your server, your domain and branding, customers upload and download
|
||||
without accounts, encrypted at rest, no per-seat pricing;
|
||||
- "How it works": upload → link or QR code → the customer downloads, with expiry, download
|
||||
limit and password;
|
||||
- features (from the README, accurate encryption wording);
|
||||
- screenshots: Desktop/Phone and Light/Dark switches over one gallery (Light/Dark starting on
|
||||
the visitor's system theme), `srcset` for both widths, `loading="lazy"`, descriptive `alt`;
|
||||
- compare: the two dated tables with sources (step 8);
|
||||
- install: the README's Docker quick start and a link to the full instructions on Gitea;
|
||||
- FAQ: "Is it end-to-end encrypted?" (no — at rest, and what a password adds), "Can customers
|
||||
send files to us?" (yes, through the upload page, optionally behind the system password),
|
||||
"How big can files be?" (the README's large-file limits), "What does it cost?" (MIT, your
|
||||
hosting), "Who runs it?" (you);
|
||||
- contact (`surtic86@gmail.com`, as MailifySMS) and footer (Gitea, licence, privacy, noNameWEB).
|
||||
8. **Comparison research.** For each product, record every criterion with its source URL and the
|
||||
date checked; apply the self-hosted selection rule; fill the tables. Keep the notes in the
|
||||
commit message, not in the repository.
|
||||
9. **Privacy page.** `website/privacy.html`: who runs the site (contact), the host (METANET, server
|
||||
logs), Plausible (cookieless, no personal data, EU-hosted, link to its data policy), no other
|
||||
third parties, fonts served locally, contact for questions; dated.
|
||||
10. **README and changelog.** Correct the encryption lines (the intro sentence stays accurate;
|
||||
"End-to-End Encryption" becomes "Encryption at Rest", described as in Decisions), add a
|
||||
Screenshots section with the three images, add the website link. CHANGELOG `2.0.0` "Fixed":
|
||||
the README no longer calls the encryption end-to-end. The website and the screenshot tooling
|
||||
get no changelog entry — they do not change the application.
|
||||
11. **Project notes.** `record-rule` for `website/**`: `website/` is a faithful copy of what is live,
|
||||
uploaded by hand; its colours are copied from `material-scheme.json` and must be copied again
|
||||
when the scheme is regenerated; the comparison is dated and every value sourced. And for
|
||||
`tests/Screenshots/**`: run with `composer screenshots` whenever the interface changes, before
|
||||
a release; the demo data is fixed so runs are reproducible.
|
||||
|
||||
## Testing
|
||||
|
||||
- `tests/Unit/ScreenshotPublisherTest.php`: a generated PNG is written as WebP at each requested
|
||||
width with the aspect ratio kept, into the device/theme directory; a missing capture throws.
|
||||
- `tests/Feature/WebsiteTest.php` guards `website/` without a browser:
|
||||
- every local `src`, `href`, `srcset` entry and CSS `url()` resolves to a file in `website/`;
|
||||
- every screenshot the gallery or README references exists for both widths and both themes;
|
||||
- no request goes to a host other than `plausible.io` (no Google Fonts, no CDN);
|
||||
- `index.html` and `privacy.html` have a `<title>`, `lang="en"` and a meta description;
|
||||
- the README has no "End-to-End Encryption" line, the site's features section does not say
|
||||
"end-to-end", and SealShare's end-to-end cell in the comparison (marked
|
||||
`data-compare="sealshare-e2e"`) reads "No" — the FAQ may still ask the question.
|
||||
- The screenshot run itself is the test of step 4: it fails when a page, selector or capture
|
||||
breaks. It is run by hand before a release, not in CI.
|
||||
- The site is looked at in Chrome, Firefox and Safari, light and dark, at phone width, before
|
||||
uploading.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **Selecting files in the upload shot** works: the files are stored with Livewire's own
|
||||
`FileUploadConfiguration::storeTemporaryFile()` and handed to `_finishUpload` by their signed
|
||||
names; Livewire's temporary-upload cleanup is turned off for the run, because under the frozen
|
||||
clock it deletes them.
|
||||
- **Comparison accuracy and fairness.** Other products change; the tables are dated and sourced,
|
||||
and re-checked when the site is updated. Swiss unfair-competition law expects comparisons to be
|
||||
accurate and not misleading — values that cannot be sourced stay "—".
|
||||
- **Install commands depend on the registry move.** Until it is settled, the install section copies
|
||||
the current README; it is updated before the site goes live with 2.0.0.
|
||||
- **Screenshot determinism.** Relative dates ("in 3 days") depend on `travelTo()`; animations
|
||||
(the share-created shape, counting stats, dialog entry) are waited out — Pest has no
|
||||
reduced-motion emulation — by waiting on `document.getAnimations().length === 0` before each
|
||||
capture.
|
||||
- **The host's name in the privacy page** (METANET) is inferred from the server's reverse DNS
|
||||
(`urbanus.ch-meta.net`); confirm it before the page goes live.
|
||||
- **The contact address** is the one MailifySMS publishes (`surtic86@gmail.com`); change it in
|
||||
step 7 if SealShare should have its own.
|
||||
- **Image weight.** Twenty screenshots at two widths as WebP should stay under ~4 MB in total; if
|
||||
not, lower the quality or drop the larger phone width.
|
||||
- **Colours drift** when the scheme is regenerated; the rule in step 11 and the note in
|
||||
`theme.css` are the guard.
|
||||
@@ -1,149 +0,0 @@
|
||||
# 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.
|
||||
@@ -2,6 +2,10 @@
|
||||
<h1 class="mb-6 type-headline-md">{{ __('System Settings') }}</h1>
|
||||
|
||||
<form wire:submit="saveSettings" class="grid gap-6">
|
||||
<x-card :title="__('Colour profile')" variant="outlined">
|
||||
<x-scheme-picker wire:model="colorProfile" :hint="__('Choosing one previews it here. After saving, every page, mail and error page uses it.')" data-test="color-profile" />
|
||||
</x-card>
|
||||
|
||||
<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.')" />
|
||||
@@ -99,7 +103,7 @@
|
||||
/>
|
||||
</x-card>
|
||||
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" />
|
||||
<x-button type="submit" :label="__('Save Settings')" variant="filled" icon="check" spinner="saveSettings" class="w-full" data-test="save-settings" />
|
||||
</form>
|
||||
|
||||
<x-modal wire:model="confirmingLogoRemoval" :title="__('Remove the logo?')" icon="delete">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\Share;
|
||||
use App\Models\User;
|
||||
use App\Services\ShareService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
/**
|
||||
* A page of SealShare, once it can be used: loaded, with Alpine and Livewire started.
|
||||
@@ -152,3 +154,25 @@ test('a first visit follows the system theme, and Appearance switches it', funct
|
||||
->assertScript("document.documentElement.dataset.theme === 'light'")
|
||||
->assertScript("localStorage.getItem('sealshare-theme') === 'light'");
|
||||
});
|
||||
|
||||
test('an admin previews a colour profile, saves it, and every page wears it', function () {
|
||||
$this->actingAs(User::factory()->admin()->create());
|
||||
|
||||
$page = ready(visit('/admin/settings'));
|
||||
|
||||
$page->assertScript("document.documentElement.getAttribute('data-scheme') === 'indigo'")
|
||||
->click('[data-test="color-profile"] [data-scheme-option="teal"]')
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'");
|
||||
|
||||
expect(Setting::get('color_profile'))->toBeNull();
|
||||
|
||||
$page->click('[data-test="save-settings"]')
|
||||
->assertSee('Settings saved successfully.');
|
||||
|
||||
expect(Setting::get('color_profile'))->toBe('teal');
|
||||
|
||||
ready(visit('/upload'))
|
||||
->assertScript("document.documentElement.getAttribute('data-scheme') === 'teal'")
|
||||
->assertScript("getComputedStyle(document.documentElement).getPropertyValue('--md-sys-color-primary').trim() === '".Scheme::profiles()['teal']['light']['primary']."'")
|
||||
->assertNoJavaScriptErrors();
|
||||
});
|
||||
|
||||
@@ -128,3 +128,32 @@ test('admin can remove the logo through its dialog', function () {
|
||||
expect(Setting::get('site_logo'))->toBeNull();
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
});
|
||||
|
||||
test('admin chooses the colour profile, starting from the saved one', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('colorProfile', 'indigo')
|
||||
->set('colorProfile', 'teal')
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect(Setting::get('color_profile'))->toBe('teal');
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->assertSet('colorProfile', 'teal');
|
||||
});
|
||||
|
||||
test('a colour profile that was not generated is refused', function () {
|
||||
$admin = User::query()->where('is_admin', true)->first();
|
||||
|
||||
Livewire::actingAs($admin)
|
||||
->test(AdminSettings::class)
|
||||
->set('colorProfile', 'ocean')
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['colorProfile' => 'in']);
|
||||
|
||||
expect(Setting::get('color_profile'))->toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use NoNameWeb\LivewireMaterial\Support\Scheme;
|
||||
|
||||
test('every page wears the colour profile the admin saved, indigo until then', function () {
|
||||
$this->get(route('upload'))->assertOk()->assertSee('({"scheme":"indigo",', false);
|
||||
|
||||
Setting::set('color_profile', 'graphite');
|
||||
|
||||
$this->get(route('upload'))->assertSee('({"scheme":"graphite",', false);
|
||||
$this->get('/s/does-not-exist')->assertNotFound()->assertSee('({"scheme":"graphite",', false);
|
||||
});
|
||||
|
||||
test('a saved profile that no longer exists falls back to the default', function () {
|
||||
Setting::set('color_profile', 'ocean');
|
||||
|
||||
expect(Scheme::profile())->toBe('indigo');
|
||||
$this->get(route('upload'))->assertSee('({"scheme":"indigo",', false);
|
||||
});
|
||||
|
||||
test('mails take the saved colour profile', function () {
|
||||
Setting::set('color_profile', 'rose');
|
||||
|
||||
$html = (string) (new ResetPassword('token'))->toMail(User::factory()->create())->render();
|
||||
|
||||
expect($html)->toContain('background-color: '.Scheme::profiles()['rose']['light']['primary'])
|
||||
->not->toContain('background-color: '.Scheme::profiles()['indigo']['light']['primary']);
|
||||
});
|
||||
|
||||
test('the stylesheet carries all eight profiles', function () {
|
||||
expect(array_keys(Scheme::profiles()))->toBe(['indigo', 'blue', 'teal', 'green', 'amber', 'rose', 'violet', 'graphite'])
|
||||
->and(file_get_contents(resource_path('css/material-scheme.css')))->toContain("[data-scheme='graphite'][data-theme='dark']");
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/*
|
||||
* docker/entrypoint.sh caches the configuration, routes and views on every start, with the
|
||||
* Livewire Material showcase off (it only runs locally). A view that does not compile there stops
|
||||
* the container from booting, so the tests compile them as production does.
|
||||
*/
|
||||
|
||||
test('every view compiles as the production entrypoint caches them', function () {
|
||||
expect(config('livewire-material.showcase.enabled'))->toBeFalse();
|
||||
|
||||
$compiled = storage_path('framework/testing/view-cache-'.uniqid());
|
||||
File::ensureDirectoryExists($compiled);
|
||||
config(['view.compiled' => $compiled]);
|
||||
|
||||
try {
|
||||
$this->artisan('view:cache')->assertSuccessful();
|
||||
} finally {
|
||||
File::deleteDirectory($compiled);
|
||||
}
|
||||
});
|
||||
@@ -107,7 +107,8 @@ test('the pages are titled, described and in English', function () {
|
||||
->toContain('<html lang="en"')
|
||||
->toMatch('/<title>[^<]+<\/title>/')
|
||||
->toMatch('/<meta name="description" content="[^"]+"/')
|
||||
->toContain('data-domain="sealshare.nonameweb.ch"');
|
||||
->toContain('<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>')
|
||||
->toContain('plausible.init()');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* SealShare website theme.
|
||||
*
|
||||
* The colours are not an approximation of the app's: they are copied from
|
||||
* resources/css/material-scheme.json, generated by `php artisan material:scheme "#4f46e5"
|
||||
* --variant=vibrant` (Material 3, spec 2025, contrast 0). When the scheme is regenerated, copy the
|
||||
* roles below again — the site does not read the file. Light is the default, dark follows the
|
||||
* the indigo profile in resources/css/material-scheme.json (seed #4f46e5, Vibrant, Material 3 spec
|
||||
* 2025, contrast 0; the file's top-level light and dark), generated by `php artisan material:scheme`
|
||||
* from the app's colour profiles. When indigo is regenerated differently, copy the roles below again —
|
||||
* the site does not read the file, and does not follow the colour profile an admin chooses. Light is the default, dark follows the
|
||||
* visitor's system setting; the site has no theme toggle of its own.
|
||||
*
|
||||
* Shared by index.html and privacy.html.
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
@@ -12,7 +12,12 @@
|
||||
<meta property="og:title" content="SealShare — your own secure upload platform">
|
||||
<meta property="og:description" content="SealShare is self-hosted file sharing: your company's own upload platform to exchange files with customers, encrypted at rest, on your own server and domain. Open source, one Docker image.">
|
||||
<meta property="og:image" content="https://sealshare.nonameweb.ch/img/screenshots/desktop/light/01-upload-1600.webp">
|
||||
<script defer data-domain="sealshare.nonameweb.ch" src="https://plausible.io/js/script.js"></script>
|
||||
<!-- Privacy-friendly analytics by Plausible -->
|
||||
<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="img/icon.png">
|
||||
<link rel="preload" href="fonts/GoogleSansFlex-Latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
@@ -253,7 +258,7 @@
|
||||
<article class="feature">
|
||||
<span class="badge-icon badge-icon--tertiary" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-palette"/></svg></span>
|
||||
<h3>Your branding</h3>
|
||||
<p>Your logo, site title and description on the upload page.</p>
|
||||
<p>Your logo, site title and description on the upload page, in one of eight colour profiles.</p>
|
||||
</article>
|
||||
<article class="feature">
|
||||
<span class="badge-icon" aria-hidden="true"><svg viewBox="0 0 100 100"><use href="#s-cookie-6"/></svg><svg class="icon" aria-hidden="true"><use href="#i-shield-lock"/></svg></span>
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
<meta name="description" content="How the SealShare website handles visitor data: server logs at the host and Plausible analytics, nothing else.">
|
||||
<meta name="theme-color" content="#faf4ff" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#10062d" media="(prefers-color-scheme: dark)">
|
||||
<script defer data-domain="sealshare.nonameweb.ch" src="https://plausible.io/js/script.js"></script>
|
||||
<!-- Privacy-friendly analytics by Plausible -->
|
||||
<script async src="https://plausible.io/js/pa-zWB4R2-rrBeLfwms3uIBT.js"></script>
|
||||
<script>
|
||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||
plausible.init()
|
||||
</script>
|
||||
<link rel="icon" href="img/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="img/icon.png">
|
||||
<link rel="preload" href="fonts/GoogleSansFlex-Latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
||||