From c67bde8fc5023110c2c0fac2390fba4aaed5b204 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 16 May 2026 13:55:25 +0200 Subject: [PATCH] feat: Toaster, button-component, 10 new modals, links-page fix, vite-tuning - Toaster Livewire component (toast event, dismiss, auto-hide, 6 tests) - Blade component with wire:loading indicator + variants - QR modals: CreateQrCode, EditQrCode, DeleteQrCode (TDD, 8 tests) - Bio modals: CreateBioPage, EditBioPage, DeleteBioPage (TDD, 7 tests) - Domain modals: AddDomain, VerifyDomain, DeleteDomain (TDD, 7 tests) - Profile modal: DeleteAccount with password confirm (TDD, 4 tests) - Links page: fix openModal event name, wire up Edit/Delete to modals, toast on all CRUD - vite.config.js: add chart.js/auto + optimizeDeps.force - QrCode model: add newFactory() + QrCodeFactory - Layout: Toaster embedded before - 141 tests / 296 assertions all green, verify 29/29 Co-Authored-By: Claude Sonnet 4.6 --- app/Domains/QrCode/Models/QrCode.php | 9 +- app/Livewire/Components/Toaster.php | 34 +++++ app/Livewire/Modals/AddDomain.php | 41 ++++++ app/Livewire/Modals/CreateBioPage.php | 55 ++++++++ app/Livewire/Modals/CreateLink.php | 1 + app/Livewire/Modals/CreateQrCode.php | 52 ++++++++ app/Livewire/Modals/DeleteAccount.php | 47 +++++++ app/Livewire/Modals/DeleteBioPage.php | 49 +++++++ app/Livewire/Modals/DeleteDomain.php | 49 +++++++ app/Livewire/Modals/DeleteLink.php | 1 + app/Livewire/Modals/DeleteQrCode.php | 49 +++++++ app/Livewire/Modals/EditBioPage.php | 70 ++++++++++ app/Livewire/Modals/EditLink.php | 1 + app/Livewire/Modals/EditQrCode.php | 69 ++++++++++ app/Livewire/Modals/VerifyDomain.php | 57 +++++++++ app/Livewire/Pages/Links/Index.php | 16 +-- database/factories/QrCodeFactory.php | 25 ++++ .../views/components/ui/button.blade.php | 36 ++++++ resources/views/layouts/nimuli-app.blade.php | 1 + .../livewire/components/toaster.blade.php | 39 ++++++ .../livewire/modals/add-domain.blade.php | 24 ++++ .../livewire/modals/create-bio-page.blade.php | 30 +++++ .../livewire/modals/create-qr-code.blade.php | 39 ++++++ .../livewire/modals/delete-account.blade.php | 28 ++++ .../livewire/modals/delete-bio-page.blade.php | 20 +++ .../livewire/modals/delete-domain.blade.php | 20 +++ .../livewire/modals/delete-qr-code.blade.php | 20 +++ .../livewire/modals/edit-bio-page.blade.php | 34 +++++ .../livewire/modals/edit-qr-code.blade.php | 39 ++++++ .../livewire/modals/verify-domain.blade.php | 51 ++++++++ .../livewire/pages/links/index.blade.php | 42 +++--- tests/Feature/Livewire/BioModalsTest.php | 106 +++++++++++++++ tests/Feature/Livewire/DomainModalsTest.php | 106 +++++++++++++++ tests/Feature/Livewire/ProfileModalsTest.php | 41 ++++++ tests/Feature/Livewire/QrModalsTest.php | 121 ++++++++++++++++++ tests/Feature/Livewire/ToasterTest.php | 53 ++++++++ vite.config.js | 2 + 37 files changed, 1448 insertions(+), 29 deletions(-) create mode 100644 app/Livewire/Components/Toaster.php create mode 100644 app/Livewire/Modals/AddDomain.php create mode 100644 app/Livewire/Modals/CreateBioPage.php create mode 100644 app/Livewire/Modals/CreateQrCode.php create mode 100644 app/Livewire/Modals/DeleteAccount.php create mode 100644 app/Livewire/Modals/DeleteBioPage.php create mode 100644 app/Livewire/Modals/DeleteDomain.php create mode 100644 app/Livewire/Modals/DeleteQrCode.php create mode 100644 app/Livewire/Modals/EditBioPage.php create mode 100644 app/Livewire/Modals/EditQrCode.php create mode 100644 app/Livewire/Modals/VerifyDomain.php create mode 100644 database/factories/QrCodeFactory.php create mode 100644 resources/views/components/ui/button.blade.php create mode 100644 resources/views/livewire/components/toaster.blade.php create mode 100644 resources/views/livewire/modals/add-domain.blade.php create mode 100644 resources/views/livewire/modals/create-bio-page.blade.php create mode 100644 resources/views/livewire/modals/create-qr-code.blade.php create mode 100644 resources/views/livewire/modals/delete-account.blade.php create mode 100644 resources/views/livewire/modals/delete-bio-page.blade.php create mode 100644 resources/views/livewire/modals/delete-domain.blade.php create mode 100644 resources/views/livewire/modals/delete-qr-code.blade.php create mode 100644 resources/views/livewire/modals/edit-bio-page.blade.php create mode 100644 resources/views/livewire/modals/edit-qr-code.blade.php create mode 100644 resources/views/livewire/modals/verify-domain.blade.php create mode 100644 tests/Feature/Livewire/BioModalsTest.php create mode 100644 tests/Feature/Livewire/DomainModalsTest.php create mode 100644 tests/Feature/Livewire/ProfileModalsTest.php create mode 100644 tests/Feature/Livewire/QrModalsTest.php create mode 100644 tests/Feature/Livewire/ToasterTest.php diff --git a/app/Domains/QrCode/Models/QrCode.php b/app/Domains/QrCode/Models/QrCode.php index caa8f37..676ea3f 100644 --- a/app/Domains/QrCode/Models/QrCode.php +++ b/app/Domains/QrCode/Models/QrCode.php @@ -5,7 +5,7 @@ namespace App\Domains\QrCode\Models; use App\Domains\Link\Models\Link; use App\Domains\Workspace\Models\Workspace; use App\Models\User; -use Illuminate\Database\Eloquent\Factories\Factory; +use Database\Factories\QrCodeFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,9 +14,14 @@ use Illuminate\Support\Str; class QrCode extends Model { - /** @use HasFactory> */ + /** @use HasFactory */ use HasFactory, SoftDeletes; + protected static function newFactory(): QrCodeFactory + { + return QrCodeFactory::new(); + } + protected $guarded = ['id']; protected $casts = [ diff --git a/app/Livewire/Components/Toaster.php b/app/Livewire/Components/Toaster.php new file mode 100644 index 0000000..a3a5f53 --- /dev/null +++ b/app/Livewire/Components/Toaster.php @@ -0,0 +1,34 @@ +toasts[] = [ + 'id' => uniqid('t_', true), + 'message' => $message, + 'type' => $type, + 'duration' => $duration, + ]; + } + + public function dismiss(string $id): void + { + $this->toasts = array_values( + array_filter($this->toasts, fn ($t) => $t['id'] !== $id) + ); + } + + public function render(): \Illuminate\View\View + { + return view('livewire.components.toaster'); + } +} diff --git a/app/Livewire/Modals/AddDomain.php b/app/Livewire/Modals/AddDomain.php new file mode 100644 index 0000000..6286a07 --- /dev/null +++ b/app/Livewire/Modals/AddDomain.php @@ -0,0 +1,41 @@ + 'required|string|max:255|regex:/^[a-z0-9]([a-z0-9\-\.]+[a-z0-9])?$/i|unique:domains,hostname', + ]; + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + (new CreateDomain)->handle($workspace, $this->hostname); + + $this->dispatch('domainAdded'); + $this->dispatch('toast', message: 'Domain hinzugefügt', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.add-domain'); + } +} diff --git a/app/Livewire/Modals/CreateBioPage.php b/app/Livewire/Modals/CreateBioPage.php new file mode 100644 index 0000000..88f63c1 --- /dev/null +++ b/app/Livewire/Modals/CreateBioPage.php @@ -0,0 +1,55 @@ + 'required|max:200', + 'slug' => 'nullable|alpha_dash|max:80|unique:bio_pages,slug', + ]; + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + if (empty($this->slug)) { + $this->slug = Str::slug($this->title).'-'.Str::random(4); + } + + BioPage::create([ + 'workspace_id' => $workspace->id, + 'slug' => $this->slug, + 'title' => ['en' => $this->title, 'de' => $this->title], + 'theme' => [], + 'is_published' => false, + ]); + + $this->dispatch('bioPageCreated'); + $this->dispatch('toast', message: 'Bio Page erstellt', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.create-bio-page'); + } +} diff --git a/app/Livewire/Modals/CreateLink.php b/app/Livewire/Modals/CreateLink.php index 474b517..a6bd76e 100644 --- a/app/Livewire/Modals/CreateLink.php +++ b/app/Livewire/Modals/CreateLink.php @@ -46,6 +46,7 @@ class CreateLink extends ModalComponent ]); $this->dispatch('linkCreated'); + $this->dispatch('toast', message: 'Link erstellt', type: 'success'); $this->closeModal(); } diff --git a/app/Livewire/Modals/CreateQrCode.php b/app/Livewire/Modals/CreateQrCode.php new file mode 100644 index 0000000..9cad774 --- /dev/null +++ b/app/Livewire/Modals/CreateQrCode.php @@ -0,0 +1,52 @@ + 'required|url|max:2048', + 'label' => 'nullable|max:200', + 'type' => 'required|in:url,vcard,wifi,pdf,mp3,text', + ]; + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + QrCode::create([ + 'workspace_id' => $workspace->id, + 'type' => $this->type, + 'payload' => ['url' => $this->url], + 'is_dynamic' => true, + ]); + + $this->dispatch('qrCreated'); + $this->dispatch('toast', message: 'QR Code erstellt', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.create-qr-code'); + } +} diff --git a/app/Livewire/Modals/DeleteAccount.php b/app/Livewire/Modals/DeleteAccount.php new file mode 100644 index 0000000..3e9cabd --- /dev/null +++ b/app/Livewire/Modals/DeleteAccount.php @@ -0,0 +1,47 @@ +validate(); + + $user = Auth::user(); + + if (! Hash::check($this->password, $user->password)) { + throw ValidationException::withMessages([ + 'password' => __('auth.password'), + ]); + } + + Auth::logout(); + $user->delete(); + + session()->invalidate(); + session()->regenerateToken(); + + $this->redirect('/', navigate: false); + } + + public static function modalMaxWidth(): string + { + return 'sm'; + } + + public function render(): View + { + return view('livewire.modals.delete-account'); + } +} diff --git a/app/Livewire/Modals/DeleteBioPage.php b/app/Livewire/Modals/DeleteBioPage.php new file mode 100644 index 0000000..0e8d6fa --- /dev/null +++ b/app/Livewire/Modals/DeleteBioPage.php @@ -0,0 +1,49 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->bioUlid = $bioUlid; + $this->bioTitle = $bio->getTranslation('title', 'en') ?? $bio->slug; + } + + public function delete(): void + { + $workspace = app('current_workspace'); + + BioPage::where('ulid', $this->bioUlid) + ->where('workspace_id', $workspace->id) + ->firstOrFail() + ->delete(); + + $this->dispatch('bioPageDeleted', bioUlid: $this->bioUlid); + $this->dispatch('toast', message: 'Bio Page gelöscht', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'sm'; + } + + public function render(): View + { + return view('livewire.modals.delete-bio-page'); + } +} diff --git a/app/Livewire/Modals/DeleteDomain.php b/app/Livewire/Modals/DeleteDomain.php new file mode 100644 index 0000000..20c4bdd --- /dev/null +++ b/app/Livewire/Modals/DeleteDomain.php @@ -0,0 +1,49 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->domainId = $domainId; + $this->hostname = $domain->hostname; + } + + public function delete(): void + { + $workspace = app('current_workspace'); + + Domain::where('id', $this->domainId) + ->where('workspace_id', $workspace->id) + ->firstOrFail() + ->delete(); + + $this->dispatch('domainDeleted', domainId: $this->domainId); + $this->dispatch('toast', message: 'Domain gelöscht', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'sm'; + } + + public function render(): View + { + return view('livewire.modals.delete-domain'); + } +} diff --git a/app/Livewire/Modals/DeleteLink.php b/app/Livewire/Modals/DeleteLink.php index 0d3c564..19339de 100644 --- a/app/Livewire/Modals/DeleteLink.php +++ b/app/Livewire/Modals/DeleteLink.php @@ -33,6 +33,7 @@ class DeleteLink extends ModalComponent ->delete(); $this->dispatch('linkDeleted', linkUlid: $this->linkUlid); + $this->dispatch('toast', message: 'Link gelöscht', type: 'success'); $this->closeModal(); } diff --git a/app/Livewire/Modals/DeleteQrCode.php b/app/Livewire/Modals/DeleteQrCode.php new file mode 100644 index 0000000..1fe0525 --- /dev/null +++ b/app/Livewire/Modals/DeleteQrCode.php @@ -0,0 +1,49 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->qrUlid = $qrUlid; + $this->qrLabel = $qr->label ?? $qr->ulid; + } + + public function delete(): void + { + $workspace = app('current_workspace'); + + QrCode::where('ulid', $this->qrUlid) + ->where('workspace_id', $workspace->id) + ->firstOrFail() + ->delete(); + + $this->dispatch('qrDeleted', qrUlid: $this->qrUlid); + $this->dispatch('toast', message: 'QR Code gelöscht', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'sm'; + } + + public function render(): View + { + return view('livewire.modals.delete-qr-code'); + } +} diff --git a/app/Livewire/Modals/EditBioPage.php b/app/Livewire/Modals/EditBioPage.php new file mode 100644 index 0000000..1aff302 --- /dev/null +++ b/app/Livewire/Modals/EditBioPage.php @@ -0,0 +1,70 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->bioUlid = $bioUlid; + $this->title = $bio->getTranslation('title', 'en') ?? ''; + $this->slug = $bio->slug; + $this->isPublished = $bio->is_published; + } + + protected function rules(): array + { + return [ + 'title' => 'required|max:200', + 'slug' => 'nullable|alpha_dash|max:80', + 'isPublished' => 'boolean', + ]; + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + $bio = BioPage::where('ulid', $this->bioUlid) + ->where('workspace_id', $workspace->id) + ->firstOrFail(); + + $bio->update([ + 'title' => ['en' => $this->title, 'de' => $this->title], + 'slug' => $this->slug ?: $bio->slug, + 'is_published' => $this->isPublished, + ]); + + $this->dispatch('bioPageUpdated', bioUlid: $this->bioUlid); + $this->dispatch('toast', message: 'Bio Page aktualisiert', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.edit-bio-page'); + } +} diff --git a/app/Livewire/Modals/EditLink.php b/app/Livewire/Modals/EditLink.php index 8bad2eb..cdba945 100644 --- a/app/Livewire/Modals/EditLink.php +++ b/app/Livewire/Modals/EditLink.php @@ -62,6 +62,7 @@ class EditLink extends ModalComponent ]); $this->dispatch('linkUpdated', linkUlid: $this->linkUlid); + $this->dispatch('toast', message: 'Link aktualisiert', type: 'success'); $this->closeModal(); } diff --git a/app/Livewire/Modals/EditQrCode.php b/app/Livewire/Modals/EditQrCode.php new file mode 100644 index 0000000..48e5743 --- /dev/null +++ b/app/Livewire/Modals/EditQrCode.php @@ -0,0 +1,69 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->qrUlid = $qrUlid; + $this->url = $qr->payload['url'] ?? ''; + $this->label = $qr->label ?? ''; + $this->type = $qr->type; + } + + protected function rules(): array + { + return [ + 'url' => 'required|url|max:2048', + 'label' => 'nullable|max:200', + 'type' => 'required|in:url,vcard,wifi,pdf,mp3,text', + ]; + } + + public function save(): void + { + $this->validate(); + $workspace = app('current_workspace'); + + $qr = QrCode::where('ulid', $this->qrUlid) + ->where('workspace_id', $workspace->id) + ->firstOrFail(); + + $qr->update([ + 'type' => $this->type, + 'payload' => ['url' => $this->url], + ]); + + $this->dispatch('qrUpdated', qrUlid: $this->qrUlid); + $this->dispatch('toast', message: 'QR Code aktualisiert', type: 'success'); + $this->closeModal(); + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.edit-qr-code'); + } +} diff --git a/app/Livewire/Modals/VerifyDomain.php b/app/Livewire/Modals/VerifyDomain.php new file mode 100644 index 0000000..8aee8d4 --- /dev/null +++ b/app/Livewire/Modals/VerifyDomain.php @@ -0,0 +1,57 @@ +where('workspace_id', $workspace->id) + ->firstOrFail(); + + $this->domainId = $domainId; + $this->hostname = $domain->hostname; + $this->verificationToken = $domain->verification_token; + } + + public function verify(): void + { + $workspace = app('current_workspace'); + $domain = Domain::where('id', $this->domainId) + ->where('workspace_id', $workspace->id) + ->firstOrFail(); + + $verified = (new VerifyDomainAction)->handle($domain); + + if ($verified) { + $this->dispatch('domainVerified', domainId: $this->domainId); + $this->dispatch('toast', message: 'Domain verifiziert', type: 'success'); + $this->closeModal(); + } else { + $this->dispatch('toast', message: 'DNS-Eintrag nicht gefunden. Bitte warten und erneut versuchen.', type: 'error'); + } + } + + public static function modalMaxWidth(): string + { + return 'md'; + } + + public function render(): View + { + return view('livewire.modals.verify-domain'); + } +} diff --git a/app/Livewire/Pages/Links/Index.php b/app/Livewire/Pages/Links/Index.php index cde407f..ff34bec 100644 --- a/app/Livewire/Pages/Links/Index.php +++ b/app/Livewire/Pages/Links/Index.php @@ -2,9 +2,9 @@ namespace App\Livewire\Pages\Links; -use App\Domains\Link\Actions\DeleteLink; use App\Domains\Link\Models\Link; use Illuminate\View\View; +use Livewire\Attributes\On; use Livewire\Component; use Livewire\WithPagination; @@ -16,8 +16,6 @@ class Index extends Component public string $statusFilter = ''; - protected $listeners = ['linkCreated' => '$refresh']; - public function updatingSearch(): void { $this->resetPage(); @@ -28,15 +26,11 @@ class Index extends Component $this->resetPage(); } - public function deleteLink(int $id, DeleteLink $action): void + #[On('linkCreated')] + #[On('linkUpdated')] + #[On('linkDeleted')] + public function refresh(): void { - $workspace = app('current_workspace'); - $link = Link::where('id', $id) - ->where('workspace_id', $workspace->id) - ->firstOrFail(); - - $this->authorize('update', $workspace); - $action->handle($link); $this->resetPage(); } diff --git a/database/factories/QrCodeFactory.php b/database/factories/QrCodeFactory.php new file mode 100644 index 0000000..a96ebc2 --- /dev/null +++ b/database/factories/QrCodeFactory.php @@ -0,0 +1,25 @@ + (string) Str::ulid(), + 'workspace_id' => Workspace::factory(), + 'type' => 'url', + 'payload' => ['url' => fake()->url()], + 'style' => null, + 'is_dynamic' => true, + ]; + } +} diff --git a/resources/views/components/ui/button.blade.php b/resources/views/components/ui/button.blade.php new file mode 100644 index 0000000..badae2f --- /dev/null +++ b/resources/views/components/ui/button.blade.php @@ -0,0 +1,36 @@ +@props([ + 'action' => null, + 'label' => 'Speichern', + 'loadingLabel' => 'Lädt...', + 'variant' => 'primary', + 'type' => 'button', +]) + +@php +$base = 'relative inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-medium transition disabled:opacity-60 disabled:cursor-not-allowed'; +$variants = [ + 'primary' => 'bg-accent-gradient text-white hover:opacity-90', + 'secondary' => 'bg-s2 border border-white/[.06] text-t1 hover:bg-s3', + 'danger' => 'bg-red text-white hover:opacity-90', + 'ghost' => 'text-t2 hover:text-t1 hover:bg-s2', +]; +$cls = $base . ' ' . ($variants[$variant] ?? $variants['primary']); +@endphp + + diff --git a/resources/views/layouts/nimuli-app.blade.php b/resources/views/layouts/nimuli-app.blade.php index 5d3e1f1..94fbd1d 100644 --- a/resources/views/layouts/nimuli-app.blade.php +++ b/resources/views/layouts/nimuli-app.blade.php @@ -134,6 +134,7 @@ @livewire('wire-elements-modal') + @livewire('components.toaster') @livewireScripts