feat: Toaster, button-component, 10 new modals, links-page fix, vite-tuning
- Toaster Livewire component (toast event, dismiss, auto-hide, 6 tests) - <x-ui.button> 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 </body> - 141 tests / 296 assertions all green, verify 29/29 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
502a777afd
commit
c67bde8fc5
|
|
@ -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<Factory<static>> */
|
||||
/** @use HasFactory<QrCodeFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected static function newFactory(): QrCodeFactory
|
||||
{
|
||||
return QrCodeFactory::new();
|
||||
}
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Components;
|
||||
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Toaster extends Component
|
||||
{
|
||||
public array $toasts = [];
|
||||
|
||||
#[On('toast')]
|
||||
public function add(string $message, string $type = 'success', int $duration = 4000): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Domain\Actions\CreateDomain;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class AddDomain extends ModalComponent
|
||||
{
|
||||
public string $hostname = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'hostname' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateBioPage extends ModalComponent
|
||||
{
|
||||
public string $title = '';
|
||||
|
||||
public string $slug = '';
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ class CreateLink extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('linkCreated');
|
||||
$this->dispatch('toast', message: 'Link erstellt', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class CreateQrCode extends ModalComponent
|
||||
{
|
||||
public string $label = '';
|
||||
|
||||
public string $url = '';
|
||||
|
||||
public string $type = 'url';
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Attributes\Validate;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteAccount extends ModalComponent
|
||||
{
|
||||
#[Validate('required')]
|
||||
public string $password = '';
|
||||
|
||||
public function delete(): void
|
||||
{
|
||||
$this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteBioPage extends ModalComponent
|
||||
{
|
||||
public string $bioUlid = '';
|
||||
|
||||
public string $bioTitle = '';
|
||||
|
||||
public function mount(string $bioUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$bio = BioPage::where('ulid', $bioUlid)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Domain\Models\Domain;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteDomain extends ModalComponent
|
||||
{
|
||||
public int $domainId = 0;
|
||||
|
||||
public string $hostname = '';
|
||||
|
||||
public function mount(int $domainId): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$domain = Domain::where('id', $domainId)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class DeleteQrCode extends ModalComponent
|
||||
{
|
||||
public string $qrUlid = '';
|
||||
|
||||
public string $qrLabel = '';
|
||||
|
||||
public function mount(string $qrUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$qr = QrCode::where('ulid', $qrUlid)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EditBioPage extends ModalComponent
|
||||
{
|
||||
public string $bioUlid = '';
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $slug = '';
|
||||
|
||||
public bool $isPublished = false;
|
||||
|
||||
public function mount(string $bioUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$bio = BioPage::where('ulid', $bioUlid)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ class EditLink extends ModalComponent
|
|||
]);
|
||||
|
||||
$this->dispatch('linkUpdated', linkUlid: $this->linkUlid);
|
||||
$this->dispatch('toast', message: 'Link aktualisiert', type: 'success');
|
||||
$this->closeModal();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class EditQrCode extends ModalComponent
|
||||
{
|
||||
public string $qrUlid = '';
|
||||
|
||||
public string $url = '';
|
||||
|
||||
public string $label = '';
|
||||
|
||||
public string $type = 'url';
|
||||
|
||||
public function mount(string $qrUlid): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$qr = QrCode::where('ulid', $qrUlid)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Domains\Domain\Actions\VerifyDomain as VerifyDomainAction;
|
||||
use App\Domains\Domain\Models\Domain;
|
||||
use Illuminate\View\View;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class VerifyDomain extends ModalComponent
|
||||
{
|
||||
public int $domainId = 0;
|
||||
|
||||
public string $hostname = '';
|
||||
|
||||
public string $verificationToken = '';
|
||||
|
||||
public function mount(int $domainId): void
|
||||
{
|
||||
$workspace = app('current_workspace');
|
||||
$domain = Domain::where('id', $domainId)
|
||||
->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class QrCodeFactory extends Factory
|
||||
{
|
||||
protected $model = QrCode::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'ulid' => (string) Str::ulid(),
|
||||
'workspace_id' => Workspace::factory(),
|
||||
'type' => 'url',
|
||||
'payload' => ['url' => fake()->url()],
|
||||
'style' => null,
|
||||
'is_dynamic' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
<button
|
||||
{{ $attributes->merge(['type' => $type, 'class' => $cls]) }}
|
||||
@if($action) wire:loading.attr="disabled" wire:target="{{ $action }}" @endif
|
||||
>
|
||||
<span @if($action) wire:loading.remove wire:target="{{ $action }}" @endif class="flex items-center gap-2">
|
||||
{{ $slot->isNotEmpty() ? $slot : $label }}
|
||||
</span>
|
||||
@if($action)
|
||||
<span wire:loading wire:target="{{ $action }}" class="flex items-center gap-2">
|
||||
<svg class="animate-spin h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ $loadingLabel }}
|
||||
</span>
|
||||
@endif
|
||||
</button>
|
||||
|
|
@ -134,6 +134,7 @@
|
|||
</div>
|
||||
|
||||
@livewire('wire-elements-modal')
|
||||
@livewire('components.toaster')
|
||||
@livewireScripts
|
||||
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2 pointer-events-none">
|
||||
@foreach($toasts as $t)
|
||||
<div wire:key="{{ $t['id'] }}"
|
||||
x-data="{ show: true }"
|
||||
x-init="setTimeout(() => { show = false; setTimeout(() => $wire.dismiss('{{ $t['id'] }}'), 300); }, {{ $t['duration'] }})"
|
||||
x-show="show"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 translate-y-2"
|
||||
class="pointer-events-auto min-w-64 max-w-sm px-4 py-3 rounded-xl border shadow-lg backdrop-blur-sm
|
||||
@if($t['type']==='success') bg-green/10 border-green/30 text-green
|
||||
@elseif($t['type']==='error') bg-red/10 border-red/30 text-red
|
||||
@elseif($t['type']==='warning') bg-amber/10 border-amber/30 text-amber
|
||||
@else bg-blue/10 border-blue/30 text-blue @endif"
|
||||
x-cloak>
|
||||
<div class="flex items-center gap-3">
|
||||
{{-- Icon --}}
|
||||
<span class="flex-shrink-0 w-4 h-4">
|
||||
@if($t['type']==='success')
|
||||
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 8l3.5 3.5L13 5"/></svg>
|
||||
@elseif($t['type']==='error')
|
||||
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 5v3m0 3h.01M4.93 4.93l6.14 6.14"/></svg>
|
||||
@elseif($t['type']==='warning')
|
||||
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 3l5.5 10H2.5L8 3zm0 4v2.5m0 1.5h.01"/></svg>
|
||||
@else
|
||||
<svg fill="none" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"><circle cx="8" cy="8" r="5"/><path stroke-linecap="round" d="M8 6v2.5m0 1.5h.01"/></svg>
|
||||
@endif
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium">{{ $t['message'] }}</span>
|
||||
<button wire:click="dismiss('{{ $t['id'] }}')"
|
||||
class="flex-shrink-0 opacity-50 hover:opacity-100 transition-opacity text-lg leading-none"
|
||||
aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Add Custom Domain</h3>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Hostname <span class="text-red">*</span></label>
|
||||
<input wire:model="hostname" type="text" placeholder="links.yourdomain.com"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('hostname')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
<p class="mt-1.5 text-xs text-t3">Enter the domain you want to use (e.g. links.example.com)</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 text-xs text-t2 space-y-1">
|
||||
<p class="font-medium text-t1">After adding, you'll need to:</p>
|
||||
<p>1. Add a CNAME record pointing to <span class="font-mono text-blue">nimu.li</span></p>
|
||||
<p>2. Add a TXT record for verification</p>
|
||||
<p>3. Click "Verify" to activate SSL</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="save" action="save" label="Add Domain" loading-label="Adding..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Create Bio Page</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Title <span class="text-red">*</span></label>
|
||||
<input wire:model="title" type="text" placeholder="My Bio Page"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('title')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Slug (optional)</label>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="text-t3 text-xs shrink-0">nimu.li/bio/</span>
|
||||
<input wire:model="slug" type="text" placeholder="my-bio"
|
||||
class="flex-1 px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-t3">Leave empty to auto-generate.</p>
|
||||
@error('slug')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="save" action="save" label="Create Bio Page" loading-label="Creating..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Create QR Code</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Destination URL <span class="text-red">*</span></label>
|
||||
<input wire:model="url" type="url" placeholder="https://example.com"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('url')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Label (optional)</label>
|
||||
<input wire:model="label" type="text" placeholder="My QR Code"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('label')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Type</label>
|
||||
<select wire:model="type"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
|
||||
<option value="url">URL</option>
|
||||
<option value="vcard">vCard</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="save" action="save" label="Create QR Code" loading-label="Creating..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9V5m0 8h.01M4.93 4.93l10.14 10.14M10 18a8 8 0 100-16 8 8 0 000 16z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Account</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
This will permanently delete your account and all data. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Confirm your password</label>
|
||||
<input wire:model="password" type="password" placeholder="••••••••"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-red/50 focus:border-red/50" />
|
||||
@error('password')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete My Account" loading-label="Deleting..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Bio Page</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Delete <span class="text-t1 font-medium">{{ $bioTitle }}</span>? All blocks and data will be permanently removed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete Bio Page" loading-label="Deleting..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="10" cy="10" r="8"/><path stroke-linecap="round" stroke-linejoin="round" d="M10 6v4m0 3h.01"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete Domain</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Remove <span class="text-t1 font-medium font-mono">{{ $hostname }}</span>? Links using this domain will stop working.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Remove Domain" loading-label="Removing..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-red/10 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red" fill="none" viewBox="0 0 20 20" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 2h2m-5 2h8l-1 12H6L5 4zm3 4v6m2-6v6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-base font-semibold text-t1">Delete QR Code</h3>
|
||||
<p class="mt-1.5 text-sm text-t2">
|
||||
Delete <span class="text-t1 font-medium">{{ $qrLabel }}</span>? This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-6 justify-end">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="delete" action="delete" variant="danger" label="Delete QR Code" loading-label="Deleting..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Edit Bio Page</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Title <span class="text-red">*</span></label>
|
||||
<input wire:model="title" type="text"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('title')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Slug</label>
|
||||
<input wire:model="slug" type="text"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('slug')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input wire:model="isPublished" type="checkbox" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-s3 rounded-full peer peer-checked:bg-blue peer-focus:ring-2 peer-focus:ring-blue/30 transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
<span class="text-sm text-t2">Published</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="save" action="save" label="Save Changes" loading-label="Saving..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Edit QR Code</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Destination URL <span class="text-red">*</span></label>
|
||||
<input wire:model="url" type="url" placeholder="https://example.com"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('url')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Label (optional)</label>
|
||||
<input wire:model="label" type="text" placeholder="My QR Code"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t1 placeholder-t3
|
||||
focus:outline-none focus:ring-1 focus:ring-blue/50 focus:border-blue/50" />
|
||||
@error('label')<p class="mt-1 text-xs text-red">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-t2 mb-1.5">Type</label>
|
||||
<select wire:model="type"
|
||||
class="w-full px-3 py-2 bg-s2 border border-white/[.06] rounded-lg text-sm text-t2 focus:outline-none focus:ring-1 focus:ring-blue/50 cursor-pointer">
|
||||
<option value="url">URL</option>
|
||||
<option value="vcard">vCard</option>
|
||||
<option value="wifi">WiFi</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="mp3">MP3</option>
|
||||
<option value="text">Text</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Cancel" />
|
||||
<x-ui.button wire:click="save" action="save" label="Save Changes" loading-label="Saving..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<div class="p-6 space-y-5">
|
||||
<h3 class="text-base font-semibold text-t1">Verify Domain</h3>
|
||||
<p class="text-sm text-t2">Add the following DNS records to <span class="text-t1 font-medium">{{ $hostname }}</span>:</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
{{-- CNAME --}}
|
||||
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 space-y-2">
|
||||
<div class="text-xs font-medium text-t2 uppercase tracking-wider">CNAME Record</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Name</div>
|
||||
<code class="text-t1 font-mono">{{ $hostname }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Type</div>
|
||||
<code class="text-t1 font-mono">CNAME</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Value</div>
|
||||
<code class="text-blue font-mono">nimu.li</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- TXT Verification --}}
|
||||
<div class="bg-s2 border border-white/[.06] rounded-lg p-4 space-y-2">
|
||||
<div class="text-xs font-medium text-t2 uppercase tracking-wider">TXT Record (verification)</div>
|
||||
<div class="grid grid-cols-3 gap-3 text-xs">
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Name</div>
|
||||
<code class="text-t1 font-mono">_nimuli.{{ $hostname }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Type</div>
|
||||
<code class="text-t1 font-mono">TXT</code>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-t3 mb-1">Value</div>
|
||||
<code class="text-blue font-mono text-[10px] break-all">nimuli-verify={{ $verificationToken }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-t3">DNS propagation can take up to 48 hours. Click verify once records are set.</p>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<x-ui.button wire:click="closeModal" variant="ghost" label="Close" />
|
||||
<x-ui.button wire:click="verify" action="verify" label="Check & Verify" loading-label="Checking DNS..." />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
{{-- Header --}}
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-t1">Links</h1>
|
||||
<button @click="$dispatch('open-modal', {component: 'modals.create-link'})"
|
||||
class="px-4 py-2 bg-blue text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
|
||||
<button @click="$dispatch('openModal', {component: 'modals.create-link'})"
|
||||
class="px-4 py-2 bg-accent-gradient text-white rounded-lg text-sm font-medium hover:opacity-90 transition-opacity">
|
||||
+ New Link
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
<tr class="text-t3 text-xs uppercase tracking-wider">
|
||||
<th class="px-4 py-3 text-left font-medium">Link</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Target</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Tags</th>
|
||||
<th class="px-4 py-3 text-left font-medium hidden md:table-cell">Tags</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Clicks</th>
|
||||
<th class="px-4 py-3 text-center font-medium">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
|
|
@ -38,14 +38,15 @@
|
|||
</thead>
|
||||
<tbody class="divide-y divide-white/[.04]">
|
||||
@forelse($links as $link)
|
||||
<tr class="hover:bg-white/[.02] transition-colors">
|
||||
{{-- Link: slug + title --}}
|
||||
<tr class="hover:bg-white/[.02] transition-colors group">
|
||||
{{-- Slug + title --}}
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-t3 text-xs">nimu.li/</span><span class="font-mono text-blue text-sm">{{ $link->slug }}</span>
|
||||
<span class="text-t3 text-xs">nimu.li/</span>
|
||||
<span class="font-mono text-blue text-sm">{{ $link->slug }}</span>
|
||||
</div>
|
||||
@if($link->title)
|
||||
<div class="text-t3 text-xs mt-0.5">{{ $link->title }}</div>
|
||||
<div class="text-t3 text-xs mt-0.5 truncate max-w-xs">{{ $link->title }}</div>
|
||||
@endif
|
||||
</td>
|
||||
|
||||
|
|
@ -55,7 +56,7 @@
|
|||
</td>
|
||||
|
||||
{{-- Tags --}}
|
||||
<td class="px-4 py-3">
|
||||
<td class="px-4 py-3 hidden md:table-cell">
|
||||
@if(!empty($link->tags))
|
||||
<div class="flex flex-wrap gap-1">
|
||||
@foreach($link->tags as $tag)
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
|
||||
{{-- Clicks --}}
|
||||
<td class="px-4 py-3 text-right font-mono text-t1 text-sm">
|
||||
{{ $link->clicks_count ?? 0 }}
|
||||
{{ number_format($link->clicks_count ?? 0) }}
|
||||
</td>
|
||||
|
||||
{{-- Status Badge --}}
|
||||
|
|
@ -85,11 +86,17 @@
|
|||
|
||||
{{-- Actions --}}
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<a href="#" class="text-t2 text-xs hover:text-t1 transition-colors">Edit</a>
|
||||
<button wire:click="deleteLink({{ $link->id }})"
|
||||
wire:confirm="Delete this link? This action cannot be undone."
|
||||
class="text-red text-xs hover:opacity-80 transition-opacity">Delete</button>
|
||||
<div class="flex items-center justify-end gap-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click="$dispatch('openModal', {component: 'modals.edit-link', arguments: {linkUlid: '{{ $link->ulid }}'}})"
|
||||
class="text-t2 text-xs hover:text-t1 transition-colors">
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
@click="$dispatch('openModal', {component: 'modals.delete-link', arguments: {linkUlid: '{{ $link->ulid }}'}})"
|
||||
class="text-red text-xs hover:opacity-80 transition-opacity">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -100,7 +107,9 @@
|
|||
<svg class="w-10 h-10 text-t3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" />
|
||||
</svg>
|
||||
<p class="text-t3 text-sm">No links yet. Create your first short link.</p>
|
||||
<p class="text-t3 text-sm">No links yet.</p>
|
||||
<button @click="$dispatch('openModal', {component: 'modals.create-link'})"
|
||||
class="text-blue text-sm hover:underline">Create your first short link</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -109,6 +118,7 @@
|
|||
</table>
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
@if($links->hasPages())
|
||||
<div class="mt-4">{{ $links->links() }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Bio\Models\BioPage;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Livewire\Modals\CreateBioPage;
|
||||
use App\Livewire\Modals\DeleteBioPage;
|
||||
use App\Livewire\Modals\EditBioPage;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeUserWithWsForBio(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
|
||||
WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
return [$user, $ws];
|
||||
}
|
||||
|
||||
// CreateBioPage
|
||||
|
||||
it('renders create bio page modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateBioPage::class)
|
||||
->assertOk()
|
||||
->assertSee('Bio Page');
|
||||
});
|
||||
|
||||
it('validates required title on create bio page', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateBioPage::class)
|
||||
->call('save')
|
||||
->assertHasErrors(['title' => 'required']);
|
||||
});
|
||||
|
||||
it('creates bio page and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateBioPage::class)
|
||||
->set('title', 'My Bio Page')
|
||||
->call('save')
|
||||
->assertDispatched('bioPageCreated')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertDatabaseHas('bio_pages', [
|
||||
'workspace_id' => $ws->id,
|
||||
]);
|
||||
});
|
||||
|
||||
// EditBioPage
|
||||
|
||||
it('renders edit bio page modal with existing data', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditBioPage::class, ['bioUlid' => $bio->ulid])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('updates bio page and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditBioPage::class, ['bioUlid' => $bio->ulid])
|
||||
->set('title', 'Updated Title')
|
||||
->call('save')
|
||||
->assertDispatched('bioPageUpdated')
|
||||
->assertDispatched('toast');
|
||||
});
|
||||
|
||||
// DeleteBioPage
|
||||
|
||||
it('renders delete bio page modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteBioPage::class, ['bioUlid' => $bio->ulid])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('deletes bio page and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForBio();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$bio = BioPage::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteBioPage::class, ['bioUlid' => $bio->ulid])
|
||||
->call('delete')
|
||||
->assertDispatched('bioPageDeleted')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertSoftDeleted('bio_pages', ['id' => $bio->id]);
|
||||
});
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\Domain\Models\Domain;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Livewire\Modals\AddDomain;
|
||||
use App\Livewire\Modals\DeleteDomain;
|
||||
use App\Livewire\Modals\VerifyDomain;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeUserWithWsForDomain(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
|
||||
WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
return [$user, $ws];
|
||||
}
|
||||
|
||||
// AddDomain
|
||||
|
||||
it('renders add domain modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(AddDomain::class)
|
||||
->assertOk()
|
||||
->assertSee('Domain');
|
||||
});
|
||||
|
||||
it('validates required hostname on add domain', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(AddDomain::class)
|
||||
->call('save')
|
||||
->assertHasErrors(['hostname' => 'required']);
|
||||
});
|
||||
|
||||
it('adds domain and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(AddDomain::class)
|
||||
->set('hostname', 'links.example.com')
|
||||
->call('save')
|
||||
->assertDispatched('domainAdded')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertDatabaseHas('domains', [
|
||||
'workspace_id' => $ws->id,
|
||||
'hostname' => 'links.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
// VerifyDomain
|
||||
|
||||
it('renders verify domain modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(VerifyDomain::class, ['domainId' => $domain->id])
|
||||
->assertOk()
|
||||
->assertSee($domain->hostname);
|
||||
});
|
||||
|
||||
it('shows verification token in verify domain modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(VerifyDomain::class, ['domainId' => $domain->id])
|
||||
->assertSee($domain->verification_token);
|
||||
});
|
||||
|
||||
// DeleteDomain
|
||||
|
||||
it('renders delete domain modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteDomain::class, ['domainId' => $domain->id])
|
||||
->assertOk()
|
||||
->assertSee($domain->hostname);
|
||||
});
|
||||
|
||||
it('deletes domain and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForDomain();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$domain = Domain::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteDomain::class, ['domainId' => $domain->id])
|
||||
->call('delete')
|
||||
->assertDispatched('domainDeleted')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertDatabaseMissing('domains', ['id' => $domain->id]);
|
||||
});
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Modals\DeleteAccount;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders delete account modal', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteAccount::class)
|
||||
->assertOk()
|
||||
->assertSee('Delete Account');
|
||||
});
|
||||
|
||||
it('requires password confirmation to delete account', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteAccount::class)
|
||||
->call('delete')
|
||||
->assertHasErrors(['password' => 'required']);
|
||||
});
|
||||
|
||||
it('rejects wrong password on delete account', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteAccount::class)
|
||||
->set('password', 'wrong-password')
|
||||
->call('delete')
|
||||
->assertHasErrors(['password']);
|
||||
});
|
||||
|
||||
it('soft-deletes account with correct password and redirects', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteAccount::class)
|
||||
->set('password', 'password')
|
||||
->call('delete')
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertSoftDeleted('users', ['id' => $user->id]);
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
<?php
|
||||
|
||||
use App\Domains\QrCode\Models\QrCode;
|
||||
use App\Domains\Workspace\Models\Workspace;
|
||||
use App\Domains\Workspace\Models\WorkspaceMember;
|
||||
use App\Livewire\Modals\CreateQrCode;
|
||||
use App\Livewire\Modals\DeleteQrCode;
|
||||
use App\Livewire\Modals\EditQrCode;
|
||||
use App\Models\User;
|
||||
use Livewire\Livewire;
|
||||
|
||||
function makeUserWithWsForQr(): array
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ws = Workspace::factory()->create(['owner_id' => $user->id]);
|
||||
WorkspaceMember::create([
|
||||
'workspace_id' => $ws->id,
|
||||
'user_id' => $user->id,
|
||||
'role' => 'owner',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
return [$user, $ws];
|
||||
}
|
||||
|
||||
// CreateQrCode
|
||||
|
||||
it('renders create qr code modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateQrCode::class)
|
||||
->assertOk()
|
||||
->assertSee('QR Code');
|
||||
});
|
||||
|
||||
it('validates required url on create qr code', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateQrCode::class)
|
||||
->call('save')
|
||||
->assertHasErrors(['url' => 'required']);
|
||||
});
|
||||
|
||||
it('validates url format on create qr code', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateQrCode::class)
|
||||
->set('url', 'not-a-url')
|
||||
->call('save')
|
||||
->assertHasErrors(['url']);
|
||||
});
|
||||
|
||||
it('creates qr code and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
|
||||
Livewire::actingAs($user)->test(CreateQrCode::class)
|
||||
->set('url', 'https://example.com')
|
||||
->set('label', 'My QR')
|
||||
->call('save')
|
||||
->assertDispatched('qrCreated')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertDatabaseHas('qr_codes', [
|
||||
'workspace_id' => $ws->id,
|
||||
'type' => 'url',
|
||||
]);
|
||||
});
|
||||
|
||||
// EditQrCode
|
||||
|
||||
it('renders edit qr code modal with existing data', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$qr = QrCode::factory()->create(['workspace_id' => $ws->id, 'payload' => ['url' => 'https://old.com']]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditQrCode::class, ['qrUlid' => $qr->ulid])
|
||||
->assertOk()
|
||||
->assertSet('url', 'https://old.com');
|
||||
});
|
||||
|
||||
it('updates qr code and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$qr = QrCode::factory()->create(['workspace_id' => $ws->id, 'payload' => ['url' => 'https://old.com']]);
|
||||
|
||||
Livewire::actingAs($user)->test(EditQrCode::class, ['qrUlid' => $qr->ulid])
|
||||
->set('url', 'https://new.com')
|
||||
->call('save')
|
||||
->assertDispatched('qrUpdated')
|
||||
->assertDispatched('toast');
|
||||
|
||||
expect($qr->fresh()->payload['url'])->toBe('https://new.com');
|
||||
});
|
||||
|
||||
// DeleteQrCode
|
||||
|
||||
it('renders delete qr code modal', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$qr = QrCode::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteQrCode::class, ['qrUlid' => $qr->ulid])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('deletes qr code and dispatches event', function () {
|
||||
[$user, $ws] = makeUserWithWsForQr();
|
||||
app()->instance('current_workspace', $ws);
|
||||
$qr = QrCode::factory()->create(['workspace_id' => $ws->id]);
|
||||
|
||||
Livewire::actingAs($user)->test(DeleteQrCode::class, ['qrUlid' => $qr->ulid])
|
||||
->call('delete')
|
||||
->assertDispatched('qrDeleted')
|
||||
->assertDispatched('toast');
|
||||
|
||||
$this->assertSoftDeleted('qr_codes', ['id' => $qr->id]);
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Components\Toaster;
|
||||
use Livewire\Livewire;
|
||||
|
||||
it('renders toaster with empty toast list', function () {
|
||||
Livewire::test(Toaster::class)
|
||||
->assertSet('toasts', [])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('adds a success toast via toast event', function () {
|
||||
Livewire::test(Toaster::class)
|
||||
->dispatch('toast', message: 'Link erstellt', type: 'success')
|
||||
->assertCount('toasts', 1)
|
||||
->assertSee('Link erstellt');
|
||||
});
|
||||
|
||||
it('adds an error toast via toast event', function () {
|
||||
Livewire::test(Toaster::class)
|
||||
->dispatch('toast', message: 'Fehler beim Speichern', type: 'error')
|
||||
->assertCount('toasts', 1)
|
||||
->assertSee('Fehler beim Speichern');
|
||||
});
|
||||
|
||||
it('adds multiple toasts', function () {
|
||||
Livewire::test(Toaster::class)
|
||||
->dispatch('toast', message: 'First', type: 'success')
|
||||
->dispatch('toast', message: 'Second', type: 'error')
|
||||
->assertCount('toasts', 2);
|
||||
});
|
||||
|
||||
it('dismisses a toast by id', function () {
|
||||
$toaster = Livewire::test(Toaster::class)
|
||||
->dispatch('toast', message: 'Wird gelöscht', type: 'info');
|
||||
|
||||
$id = $toaster->get('toasts')[0]['id'];
|
||||
|
||||
$toaster->call('dismiss', $id)
|
||||
->assertCount('toasts', 0);
|
||||
});
|
||||
|
||||
it('toast has required fields', function () {
|
||||
$toaster = Livewire::test(Toaster::class)
|
||||
->dispatch('toast', message: 'Test', type: 'warning');
|
||||
|
||||
$toast = $toaster->get('toasts')[0];
|
||||
|
||||
expect($toast)->toHaveKeys(['id', 'message', 'type', 'duration'])
|
||||
->and($toast['message'])->toBe('Test')
|
||||
->and($toast['type'])->toBe('warning')
|
||||
->and($toast['duration'])->toBeInt()->toBeGreaterThan(0);
|
||||
});
|
||||
|
|
@ -22,7 +22,9 @@ export default defineConfig({
|
|||
'laravel-echo',
|
||||
'pusher-js',
|
||||
'chart.js',
|
||||
'chart.js/auto',
|
||||
],
|
||||
force: true,
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
|
|
|
|||
Loading…
Reference in New Issue