From 14f717a2b9c9f8f5358d11f421f5ed4413accc14 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sun, 7 Dec 2025 01:06:20 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A0=EF=B8=8F=20Refactor=20service=20co?= =?UTF-8?q?mponents:=20Add=20dynamic=20type=20filters,=20restructure=20lan?= =?UTF-8?q?ding=20page=20UI,=20and=20introduce=20`ServiceForm`=20for=20imp?= =?UTF-8?q?roved=20form=20handling=20and=20validations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Enums/SelfHostedServiceType.php | 45 ++++- app/Livewire/Forms/ServiceForm.php | 119 ++++++++++++ app/Models/User.php | 2 +- .../factories/SelfHostedServiceFactory.php | 10 +- database/seeders/DatabaseSeeder.php | 7 +- .../views/livewire/services/create.blade.php | 126 +++---------- .../views/livewire/services/edit.blade.php | 138 +++----------- .../views/livewire/services/index.blade.php | 102 ++++++++--- .../livewire/services/landingpage.blade.php | 172 ++++++++++++------ .../Feature/Livewire/Services/CreateTest.php | 5 +- 10 files changed, 414 insertions(+), 312 deletions(-) create mode 100644 app/Livewire/Forms/ServiceForm.php diff --git a/app/Enums/SelfHostedServiceType.php b/app/Enums/SelfHostedServiceType.php index e2d4597..69467cf 100644 --- a/app/Enums/SelfHostedServiceType.php +++ b/app/Enums/SelfHostedServiceType.php @@ -4,8 +4,49 @@ namespace App\Enums; enum SelfHostedServiceType: string { - case Mempool = 'mempool'; - case LNbits = 'lnbits'; case Alby = 'alby'; + case BtcpayServer = 'btcpay_server'; + case ElectrumFulcrumServer = 'electrum_fulcrum_server'; + case LNbits = 'lnbits'; + case LnbitsServer = 'lnbits_server'; + case Mempool = 'mempool'; + case NostrBlossomServer = 'nostr_blossom_server'; + case NostrClient = 'nostr_client'; + case NostrRelayServer = 'nostr_relay_server'; + case PkarrDnsServer = 'pkarr_dns_server'; case Other = 'other'; + + public function color(): string + { + return match ($this) { + self::Mempool => 'blue', + self::LNbits => 'purple', + self::Alby => 'amber', + self::ElectrumFulcrumServer => 'cyan', + self::BtcpayServer => 'green', + self::LnbitsServer => 'violet', + self::NostrRelayServer => 'fuchsia', + self::NostrClient => 'pink', + self::NostrBlossomServer => 'rose', + self::PkarrDnsServer => 'orange', + self::Other => 'zinc', + }; + } + + public function label(): string + { + return match ($this) { + self::Mempool => 'Mempool', + self::LNbits => 'LNbits', + self::Alby => 'Alby', + self::ElectrumFulcrumServer => 'Electrum/Fulcrum Server', + self::BtcpayServer => 'BTCPay Server', + self::LnbitsServer => 'LNbits Server', + self::NostrRelayServer => 'Nostr Relay', + self::NostrClient => 'Nostr Client', + self::NostrBlossomServer => 'Nostr Blossom', + self::PkarrDnsServer => 'Pkarr DNS Server', + self::Other => 'Other', + }; + } } diff --git a/app/Livewire/Forms/ServiceForm.php b/app/Livewire/Forms/ServiceForm.php new file mode 100644 index 0000000..57685cc --- /dev/null +++ b/app/Livewire/Forms/ServiceForm.php @@ -0,0 +1,119 @@ + ['required', 'string', 'max:255'], + 'type' => [ + 'required', + 'in:' . collect(SelfHostedServiceType::cases())->map(fn($c) => $c->value)->implode(',') + ], + 'intro' => ['nullable', 'string'], + 'url_clearnet' => ['nullable', 'url', 'max:255'], + 'url_onion' => ['nullable', 'string', 'max:255'], + 'url_i2p' => ['nullable', 'string', 'max:255'], + 'url_pkdns' => ['nullable', 'string', 'max:255'], + 'contact' => ['nullable', 'string'], + 'anonymous' => ['boolean'], + ]; + } + + public function setService(SelfHostedService $service): void + { + $this->service = $service; + + $this->name = $service->name; + $this->intro = $service->intro; + $this->url_clearnet = $service->url_clearnet; + $this->url_onion = $service->url_onion; + $this->url_i2p = $service->url_i2p; + $this->url_pkdns = $service->url_pkdns; + $this->type = $service->type?->value; + $this->contact = $service->contact; + $this->anonymous = is_null($service->created_by); + } + + public function store(): SelfHostedService + { + $this->validate(); + $this->validateAtLeastOneUrl(); + + return SelfHostedService::create([ + 'name' => $this->name, + 'type' => $this->type, + 'intro' => $this->intro, + 'url_clearnet' => $this->url_clearnet, + 'url_onion' => $this->url_onion, + 'url_i2p' => $this->url_i2p, + 'url_pkdns' => $this->url_pkdns, + 'contact' => $this->contact, + 'created_by' => $this->anonymous ? null : auth()->id(), + ]); + } + + public function update(): void + { + $this->validate(); + $this->validateAtLeastOneUrl(); + + $this->service->update([ + 'name' => $this->name, + 'type' => $this->type, + 'intro' => $this->intro, + 'url_clearnet' => $this->url_clearnet, + 'url_onion' => $this->url_onion, + 'url_i2p' => $this->url_i2p, + 'url_pkdns' => $this->url_pkdns, + 'contact' => $this->contact, + 'created_by' => $this->anonymous ? null : ($this->service->created_by ?? auth()->id()), + ]); + } + + protected function validateAtLeastOneUrl(): void + { + if (empty($this->url_clearnet) && empty($this->url_onion) && empty($this->url_i2p) && empty($this->url_pkdns)) { + $this->addError('url_clearnet', __('Mindestens eine URL muss angegeben werden.')); + throw new \Illuminate\Validation\ValidationException( + \Illuminate\Support\Facades\Validator::make([], []) + ); + } + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 50e583d..288bd27 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,7 +72,7 @@ class User extends Authenticatable implements CipherSweetEncrypted ->addOptionalTextField('node_id') ->addOptionalTextField('email') ->addOptionalTextField('paynym') - ->addJsonField('lnbits', $map) + ->addNullableJsonField('lnbits', $map, strict: false) ->addBlindIndex('public_key', new BlindIndex('public_key_index')) ->addBlindIndex('lightning_address', new BlindIndex('lightning_address_index')) ->addBlindIndex('lnurl', new BlindIndex('lnurl_index')) diff --git a/database/factories/SelfHostedServiceFactory.php b/database/factories/SelfHostedServiceFactory.php index b695c19..9cf6adb 100644 --- a/database/factories/SelfHostedServiceFactory.php +++ b/database/factories/SelfHostedServiceFactory.php @@ -19,16 +19,16 @@ class SelfHostedServiceFactory extends Factory $name = $this->faker->unique()->company(); return [ - 'created_by' => User::factory(), + 'created_by' => $this->faker->optional()->numberBetween(1,9), 'name' => $name, 'slug' => str($name)->slug(), 'intro' => $this->faker->optional()->paragraph(), 'url_clearnet' => $this->faker->optional()->url(), - 'url_onion' => null, - 'url_i2p' => null, - 'url_pkdns' => null, + 'url_onion' => $this->faker->optional()->url(), + 'url_i2p' => $this->faker->optional()->url(), + 'url_pkdns' => $this->faker->optional()->url(), 'type' => $this->faker->randomElement(SelfHostedServiceType::cases())->value, - 'contact_url' => $this->faker->optional()->url(), + 'contact' => $this->faker->optional()->url(), ]; } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5b566ca..a010854 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,7 +2,7 @@ namespace Database\Seeders; -use App\Models\User; +use App\Models\SelfHostedService; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -13,9 +13,6 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + SelfHostedService::factory(10)->create(); } } diff --git a/resources/views/livewire/services/create.blade.php b/resources/views/livewire/services/create.blade.php index 3209e0d..a9efda9 100644 --- a/resources/views/livewire/services/create.blade.php +++ b/resources/views/livewire/services/create.blade.php @@ -2,7 +2,7 @@ use App\Attributes\SeoDataAttribute; use App\Enums\SelfHostedServiceType; -use App\Models\SelfHostedService; +use App\Livewire\Forms\ServiceForm; use App\Traits\SeoTrait; use Livewire\Attributes\Validate; use Livewire\Volt\Component; @@ -11,85 +11,30 @@ use Livewire\WithFileUploads; new #[SeoDataAttribute(key: 'services_create')] class extends Component { - use WithFileUploads; use SeoTrait; - #[Validate('image|max:10240')] // 10MB - public $logo; + public string $country = 'de'; + public ServiceForm $form; - public string $name = ''; - public ?string $intro = null; - public ?string $url_clearnet = null; - public ?string $url_onion = null; - public ?string $url_i2p = null; - public ?string $url_pkdns = null; - public ?string $type = null; - public ?string $contact = null; - public bool $anonymous = false; - - protected function rules(): array + public function mount(): void { - return [ - 'name' => ['required', 'string', 'max:255'], - 'type' => [ - 'required', 'in:'.collect(SelfHostedServiceType::cases())->map(fn($c) => $c->value)->implode(',') - ], - 'intro' => ['nullable', 'string'], - 'url_clearnet' => ['nullable', 'url', 'max:255'], - 'url_onion' => ['nullable', 'string', 'max:255'], - 'url_i2p' => ['nullable', 'string', 'max:255'], - 'url_pkdns' => ['nullable', 'string', 'max:255'], - 'contact' => ['nullable', 'string'], - 'anonymous' => ['boolean'], - ]; - } - - protected function validateAtLeastOneUrl(): void - { - if (empty($this->url_clearnet) && empty($this->url_onion) && empty($this->url_i2p) && empty($this->url_pkdns)) { - $this->addError('url_clearnet', __('Mindestens eine URL muss angegeben werden.')); - throw new \Illuminate\Validation\ValidationException( - validator([], []) - ); - } + $this->country = request()->route('country', config('app.domain_country')); } public function save(): void { - $validated = $this->validate(); - - $this->validateAtLeastOneUrl(); - - /** @var SelfHostedService $service */ - $service = SelfHostedService::create([ - 'name' => $validated['name'], - 'type' => $validated['type'], - 'intro' => $validated['intro'] ?? null, - 'url_clearnet' => $validated['url_clearnet'] ?? null, - 'url_onion' => $validated['url_onion'] ?? null, - 'url_i2p' => $validated['url_i2p'] ?? null, - 'url_pkdns' => $validated['url_pkdns'] ?? null, - 'contact' => $validated['contact'] ?? null, - 'created_by' => $this->anonymous ? null : auth()->id(), - ]); - - if ($this->logo) { - $service - ->addMedia($this->logo->getRealPath()) - ->usingFileName($this->logo->getClientOriginalName()) - ->toMediaCollection('logo'); - } + $service = $this->form->store(); session()->flash('status', __('Service erfolgreich erstellt!')); - redirect()->route('services.index', ['country' => request()->route('country')]); + redirect()->route('services.index', ['country' => $this->country]); } public function with(): array { return [ 'types' => collect(SelfHostedServiceType::cases())->map(fn($c) => [ - 'value' => $c->value, 'label' => ucfirst($c->value) + 'value' => $c->value, 'label' => $c->label() ]), ]; } @@ -105,57 +50,37 @@ class extends Component { {{ __('Grundlegende Informationen') }}
- - -
- @if($logo) - Logo - @else - - @endif - -
- -
-
-
- {{ __('Name') }} * - + {{ __('Der Name des Services') }} - + {{ __('Typ') }} * - + @foreach($types as $t) {{ $t['label'] }} @endforeach {{ __('Art des Services') }} - + {{ __('Anonym einstellen') }} - + {{ __('Service ohne Autorenangabe einstellen') }}
{{ __('Beschreibung') }} - + {{ __('Kurze Beschreibung des Services') }} - + @@ -166,39 +91,38 @@ class extends Component {
{{ __('URL (Clearnet)') }} - + {{ __('Normale Web-URL') }} - + {{ __('URL (Onion/Tor)') }} - + {{ __('Tor Hidden Service URL') }} - + {{ __('URL (I2P)') }} - + {{ __('I2P Adresse') }} - + {{ __('URL (pkdns)') }} - + {{ __('Pkarr DNS Adresse') }} - +
{{ __('Kontaktinformation') }} - + {{ __('Beliebige Kontaktinformationen (Signal, SimpleX, Email, etc.)') }} - + diff --git a/resources/views/livewire/services/edit.blade.php b/resources/views/livewire/services/edit.blade.php index db04924..bfefd06 100644 --- a/resources/views/livewire/services/edit.blade.php +++ b/resources/views/livewire/services/edit.blade.php @@ -2,6 +2,7 @@ use App\Attributes\SeoDataAttribute; use App\Enums\SelfHostedServiceType; +use App\Livewire\Forms\ServiceForm; use App\Models\SelfHostedService; use App\Traits\SeoTrait; use Livewire\Attributes\Validate; @@ -11,39 +12,17 @@ use Livewire\WithFileUploads; new #[SeoDataAttribute(key: 'services_edit')] class extends Component { - use WithFileUploads; use SeoTrait; public SelfHostedService $service; - #[Validate('image|max:10240')] // 10MB - public $logo; - - public string $name = ''; - public ?string $intro = null; - public ?string $url_clearnet = null; - public ?string $url_onion = null; - public ?string $url_i2p = null; - public ?string $url_pkdns = null; - public ?string $type = null; - public ?string $contact = null; - public bool $anonymous = false; + public ServiceForm $form; public function mount(): void { $this->authorizeAccess(); - $this->service->load('media'); - - $this->name = $this->service->name; - $this->intro = $this->service->intro; - $this->url_clearnet = $this->service->url_clearnet; - $this->url_onion = $this->service->url_onion; - $this->url_i2p = $this->service->url_i2p; - $this->url_pkdns = $this->service->url_pkdns; - $this->type = $this->service->type?->value ?? null; - $this->contact = $this->service->contact; - $this->anonymous = is_null($this->service->created_by); + $this->form->setService($this->service); } protected function authorizeAccess(): void @@ -54,59 +33,11 @@ class extends Component { } } - protected function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'type' => ['required', 'in:'.collect(SelfHostedServiceType::cases())->map(fn($c) => $c->value)->implode(',')], - 'intro' => ['nullable', 'string'], - 'url_clearnet' => ['nullable', 'url', 'max:255'], - 'url_onion' => ['nullable', 'string', 'max:255'], - 'url_i2p' => ['nullable', 'string', 'max:255'], - 'url_pkdns' => ['nullable', 'string', 'max:255'], - 'contact' => ['nullable', 'string'], - 'anonymous' => ['boolean'], - ]; - } - - protected function validateAtLeastOneUrl(): void - { - if (empty($this->url_clearnet) && empty($this->url_onion) && empty($this->url_i2p) && empty($this->url_pkdns)) { - $this->addError('url_clearnet', __('Mindestens eine URL muss angegeben werden.')); - throw new \Illuminate\Validation\ValidationException( - validator([], []) - ); - } - } - public function save(): void { $this->authorizeAccess(); - $validated = $this->validate(); - - $this->validateAtLeastOneUrl(); - - $this->service->update([ - 'name' => $validated['name'], - 'type' => $validated['type'], - 'intro' => $validated['intro'] ?? null, - 'url_clearnet' => $validated['url_clearnet'] ?? null, - 'url_onion' => $validated['url_onion'] ?? null, - 'url_i2p' => $validated['url_i2p'] ?? null, - 'url_pkdns' => $validated['url_pkdns'] ?? null, - 'contact' => $validated['contact'] ?? null, - 'created_by' => $this->anonymous ? null : ($this->service->created_by ?? auth()->id()), - ]); - - if ($this->logo) { - $this->service->clearMediaCollection('logo'); - $this->service->addMedia($this->logo->getRealPath()) - ->usingFileName($this->logo->getClientOriginalName()) - ->toMediaCollection('logo'); - $this->logo = null; - $this->service->load('media'); - } + $this->form->update(); session()->flash('status', __('Service erfolgreich aktualisiert!')); } @@ -114,7 +45,9 @@ class extends Component { public function with(): array { return [ - 'types' => collect(SelfHostedServiceType::cases())->map(fn($c) => ['value' => $c->value, 'label' => ucfirst($c->value)]), + 'types' => collect(SelfHostedServiceType::cases())->map(fn($c) => [ + 'value' => $c->value, 'label' => $c->label() + ]), ]; } }; ?> @@ -129,29 +62,6 @@ class extends Component { {{ __('Grundlegende Informationen') }}
- - -
- @if (!$logo && $service->getFirstMedia('logo')) - Logo - @elseif($logo) - Logo - @else - - @endif - -
- -
-
-
- {{ __('ID') }} @@ -160,35 +70,35 @@ class extends Component { {{ __('Name') }} * - + {{ __('Der Name des Services') }} - + {{ __('Typ') }} * - + @foreach($types as $t) {{ $t['label'] }} @endforeach {{ __('Art des Services') }} - + {{ __('Anonym') }} - + {{ __('Service ohne Autorenangabe') }}
{{ __('Beschreibung') }} - + {{ __('Kurze Beschreibung des Services') }} - + @@ -199,38 +109,38 @@ class extends Component {
{{ __('URL (Clearnet)') }} - + {{ __('Normale Web-URL') }} - + {{ __('URL (Onion/Tor)') }} - + {{ __('Tor Hidden Service URL') }} - + {{ __('URL (I2P)') }} - + {{ __('I2P Adresse') }} - + {{ __('URL (pkdns)') }} - + {{ __('Pkarr DNS Adresse') }} - +
{{ __('Kontaktinformation') }} - + {{ __('Beliebige Kontaktinformationen (Signal, SimpleX, Email, etc.)') }} - + diff --git a/resources/views/livewire/services/index.blade.php b/resources/views/livewire/services/index.blade.php index 3c559c9..8ad54f7 100644 --- a/resources/views/livewire/services/index.blade.php +++ b/resources/views/livewire/services/index.blade.php @@ -14,19 +14,29 @@ class extends Component { public string $country = 'de'; public string $search = ''; + public ?string $typeFilter = null; public function mount(): void { $this->country = request()->route('country', config('app.domain_country')); } + public function filterByType(?string $type): void + { + $this->typeFilter = $this->typeFilter === $type ? null : $type; + $this->resetPage(); + } + public function with(): array { return [ 'services' => SelfHostedService::query() + ->with('createdBy') ->when($this->search, fn($q) => $q->where('name', 'ilike', '%'.$this->search.'%')) + ->when($this->typeFilter, fn($q) => $q->where('type', $this->typeFilter)) ->orderBy('name') ->paginate(15), + 'types' => \App\Enums\SelfHostedServiceType::cases(), ]; } }; ?> @@ -44,68 +54,108 @@ class extends Component { + +
+ @foreach($types as $type) + + {{ $type->label() }} + + @endforeach + @if($typeFilter) + + + {{ __('Filter zurücksetzen') }} + + @endif +
+ {{ __('Name') }} {{ __('Typ') }} {{ __('Links') }} - {{ __('Aktionen') }} + {{ __('Erstellt von') }} + {{ __('Datum') }} @foreach ($services as $service) - - + {{ $service->name }} @if($service->type) - {{ ucfirst($service->type->value) }} + + {{ $service->type->label() }} + @endif -
+
@if($service->url_clearnet) - - + + + Clearnet @endif @if($service->url_onion) - - + + + Onion @endif @if($service->url_i2p) - - + + + I2P @endif @if($service->url_pkdns) - - - - @endif - @if($service->contact_url) - - + + + pkdns @endif
- @auth - @if(auth()->id() === $service->created_by) - - {{ __('Bearbeiten') }} - - @endif + @if($service->createdBy) +
+ + {{ Str::length($service->createdBy->name) > 10 ? Str::substr($service->createdBy->name, 0, 4) . '...' . Str::substr($service->createdBy->name, -3) : $service->createdBy->name }} +
@else - {{ __('Log in') }} - @endauth + {{ __('Anonymous') }} + @endif +
+ + +
+
+ + {{ $service->created_at->format('d.m.Y') }} +
+ @if($service->created_at->ne($service->updated_at)) +
+ + {{ $service->updated_at->format('d.m.Y') }} +
+ @endif +
@endforeach diff --git a/resources/views/livewire/services/landingpage.blade.php b/resources/views/livewire/services/landingpage.blade.php index 21bbe19..a172dea 100644 --- a/resources/views/livewire/services/landingpage.blade.php +++ b/resources/views/livewire/services/landingpage.blade.php @@ -16,6 +16,7 @@ class extends Component { public function mount(): void { + $this->service->load('createdBy'); $this->country = request()->route('country', config('app.domain_country')); } @@ -27,63 +28,11 @@ class extends Component { } }; ?> -
-
-
-
- -
- {{ $service->name }} - @if($service->type) - {{ ucfirst($service->type->value) }} - @endif -
-
- - @if($service->intro) -
- {{ __('Über den Service') }} - {!! $service->intro !!} -
- @endif -
- -
- {{ __('Links') }} -
- @if($service->url_clearnet) - - - Clearnet - - @endif - @if($service->url_onion) - - - Onion / Tor - - @endif - @if($service->url_i2p) - - - I2P - - @endif - @if($service->url_pkdns) - - - pkdns - - @endif - @if($service->contact_url) - - - {{ __('Kontakt') }} - - @endif -
- +
+ +
+
+ {{ $service->name }} @auth @if(auth()->id() === $service->created_by) @@ -92,5 +41,114 @@ class extends Component { @endif @endauth
+ + @if($service->type) + + {{ $service->type->label() }} + + @endif +
+ +
+ +
+ + @if($service->intro) + + {{ __('Beschreibung') }} +
+ {{ $service->intro }} +
+
+ @endif + + + @if($service->contact) + + {{ __('Kontakt') }} +
+ {{ $service->contact }} +
+
+ @endif +
+ + +
+ + + {{ __('Zugriff') }} +
+ @if($service->url_clearnet) + + + Clearnet + + @endif + @if($service->url_onion) + + + Onion / Tor + + @endif + @if($service->url_i2p) + + + I2P + + @endif + @if($service->url_pkdns) + + + pkdns + + @endif +
+
+ + + + {{ __('Informationen') }} +
+ +
+
{{ __('Erstellt von') }}
+ @if($service->createdBy) +
+ + {{ $service->createdBy->name }} +
+ @else + {{ __('Anonymous') }} + @endif +
+ + +
+
{{ __('Erstellt am') }}
+
+ + {{ $service->created_at->format('d.m.Y H:i') }} +
+
+ + + @if($service->created_at->ne($service->updated_at)) +
+
{{ __('Zuletzt aktualisiert') }}
+
+ + {{ $service->updated_at->format('d.m.Y H:i') }} +
+
+ @endif +
+
+ + + + {{ __('Zurück zur Übersicht') }} + +
diff --git a/tests/Feature/Livewire/Services/CreateTest.php b/tests/Feature/Livewire/Services/CreateTest.php index 99c7072..1b1ffa1 100644 --- a/tests/Feature/Livewire/Services/CreateTest.php +++ b/tests/Feature/Livewire/Services/CreateTest.php @@ -15,8 +15,11 @@ it('creates a self hosted service', function () { ->set('name', 'My Node') ->set('type', SelfHostedServiceType::Mempool->value) ->set('url_clearnet', 'https://example.com') - ->set('contact_url', 'https://contact.example.com') + ->set('contact', ['url' => 'https://contact.example.com']) ->call('save'); expect(SelfHostedService::where('name', 'My Node')->exists())->toBeTrue(); + + $service = SelfHostedService::where('name', 'My Node')->first(); + expect($service->getFirstMedia('logo'))->toBeNull(); });