🛠️ Add services index and landing page components with dynamic links and new Polish translations

This commit is contained in:
HolgerHatGarKeineNode
2025-12-07 00:01:15 +01:00
parent bc700a1f2c
commit aef4deedd6
25 changed files with 2427 additions and 878 deletions

View File

@@ -62,6 +62,17 @@
</flux:navlist.item>
</flux:navlist.group>
<flux:navlist.group :heading="__('Community & Dienste')" class="grid">
<flux:navlist.item icon="server" :href="route_with_country('services.index')"
:current="request()->routeIs('services.index')"
wire:navigate
badge="{{ \App\Models\SelfHostedService::query()->count() }}">
<div class="flex items-center space-x-2">
<span>{{ __('Self Hosted Services') }}</span>
</div>
</flux:navlist.item>
</flux:navlist.group>
<flux:navlist.group :heading="__('Kurse')" class="grid">
<flux:navlist.item icon="academic-cap" :href="route_with_country('courses.index')"
:current="request()->routeIs('courses.index')"

View File

@@ -0,0 +1,216 @@
<?php
use App\Attributes\SeoDataAttribute;
use App\Enums\SelfHostedServiceType;
use App\Models\SelfHostedService;
use App\Traits\SeoTrait;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new
#[SeoDataAttribute(key: 'services_create')]
class extends Component {
use WithFileUploads;
use SeoTrait;
#[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;
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
{
$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');
}
session()->flash('status', __('Service erfolgreich erstellt!'));
redirect()->route('services.index', ['country' => request()->route('country')]);
}
public function with(): array
{
return [
'types' => collect(SelfHostedServiceType::cases())->map(fn($c) => [
'value' => $c->value, 'label' => ucfirst($c->value)
]),
];
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Service anlegen') }}</flux:heading>
<form wire:submit="save" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="logo">
<div class="
relative flex items-center justify-center size-20 rounded transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
@if($logo)
<img src="{{ $logo?->temporaryUrl() }}" alt="Logo"
class="size-full object-cover rounded"/>
@else
<flux:icon name="cube" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" placeholder="Name" required/>
<flux:description>{{ __('Der Name des Services') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Typ') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="type" placeholder="{{ __('Bitte wählen') }}" required>
<flux:select.option :value="null"></flux:select.option>
@foreach($types as $t)
<flux:select.option value="{{ $t['value'] }}">{{ $t['label'] }}</flux:select.option>
@endforeach
</flux:select>
<flux:description>{{ __('Art des Services') }}</flux:description>
<flux:error name="type"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Anonym einstellen') }}</flux:label>
<flux:switch wire:model="anonymous"/>
<flux:description>{{ __('Service ohne Autorenangabe einstellen') }}</flux:description>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea rows="4" wire:model="intro"/>
<flux:description>{{ __('Kurze Beschreibung des Services') }}</flux:description>
<flux:error name="intro"/>
</flux:field>
</flux:fieldset>
<!-- URLs -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('URLs & Erreichbarkeit') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('URL (Clearnet)') }}</flux:label>
<flux:input wire:model="url_clearnet" type="url" placeholder="https://..."/>
<flux:description>{{ __('Normale Web-URL') }}</flux:description>
<flux:error name="url_clearnet"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (Onion/Tor)') }}</flux:label>
<flux:input wire:model="url_onion" placeholder="http://...onion"/>
<flux:description>{{ __('Tor Hidden Service URL') }}</flux:description>
<flux:error name="url_onion"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (I2P)') }}</flux:label>
<flux:input wire:model="url_i2p" placeholder="..."/>
<flux:description>{{ __('I2P Adresse') }}</flux:description>
<flux:error name="url_i2p"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (pkdns)') }}</flux:label>
<flux:input wire:model="url_pkdns" placeholder="..."/>
<flux:description>{{ __('Pkarr DNS Adresse') }}</flux:description>
<flux:error name="url_pkdns"/>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Kontaktinformation') }}</flux:label>
<flux:textarea rows="3" wire:model="contact"
placeholder="{{ __('Signal: username, SimpleX: https://..., Email: ...') }}"/>
<flux:description>{{ __('Beliebige Kontaktinformationen (Signal, SimpleX, Email, etc.)') }}</flux:description>
<flux:error name="contact"/>
</flux:field>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Service erstellen') }}
</flux:button>
</div>
</form>
</div>

View File

@@ -0,0 +1,256 @@
<?php
use App\Attributes\SeoDataAttribute;
use App\Enums\SelfHostedServiceType;
use App\Models\SelfHostedService;
use App\Traits\SeoTrait;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
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 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);
}
protected function authorizeAccess(): void
{
// Allow edit if user is the creator or if service was created anonymously
if (!is_null($this->service->created_by) && auth()->id() !== $this->service->created_by) {
abort(403);
}
}
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');
}
session()->flash('status', __('Service erfolgreich aktualisiert!'));
}
public function with(): array
{
return [
'types' => collect(SelfHostedServiceType::cases())->map(fn($c) => ['value' => $c->value, 'label' => ucfirst($c->value)]),
];
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Service bearbeiten') }}: {{ $service->name }}</flux:heading>
<form wire:submit="save" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="logo">
<div class="
relative flex items-center justify-center size-20 rounded transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
@if (!$logo && $service->getFirstMedia('logo'))
<img src="{{ $service->getFirstMediaUrl('logo') }}" alt="Logo"
class="size-full object-cover rounded"/>
@elseif($logo)
<img src="{{ $logo?->temporaryUrl() }}" alt="Logo"
class="size-full object-cover rounded"/>
@else
<flux:icon name="cube" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('ID') }}</flux:label>
<flux:input value="{{ $service->id }}" disabled/>
<flux:description>{{ __('System-generierte ID (nur lesbar)') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Der Name des Services') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Typ') }} <span class="text-red-500">*</span></flux:label>
<flux:select wire:model="type" placeholder="{{ __('Bitte wählen') }}" required>
<flux:select.option :value="null"></flux:select.option>
@foreach($types as $t)
<flux:select.option value="{{ $t['value'] }}">{{ $t['label'] }}</flux:select.option>
@endforeach
</flux:select>
<flux:description>{{ __('Art des Services') }}</flux:description>
<flux:error name="type"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Anonym') }}</flux:label>
<flux:switch wire:model="anonymous"/>
<flux:description>{{ __('Service ohne Autorenangabe') }}</flux:description>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea rows="4" wire:model="intro"/>
<flux:description>{{ __('Kurze Beschreibung des Services') }}</flux:description>
<flux:error name="intro"/>
</flux:field>
</flux:fieldset>
<!-- URLs -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('URLs & Erreichbarkeit') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('URL (Clearnet)') }}</flux:label>
<flux:input wire:model="url_clearnet" type="url" placeholder="https://..."/>
<flux:description>{{ __('Normale Web-URL') }}</flux:description>
<flux:error name="url_clearnet"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (Onion/Tor)') }}</flux:label>
<flux:input wire:model="url_onion" placeholder="http://...onion"/>
<flux:description>{{ __('Tor Hidden Service URL') }}</flux:description>
<flux:error name="url_onion"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (I2P)') }}</flux:label>
<flux:input wire:model="url_i2p" placeholder="..."/>
<flux:description>{{ __('I2P Adresse') }}</flux:description>
<flux:error name="url_i2p"/>
</flux:field>
<flux:field>
<flux:label>{{ __('URL (pkdns)') }}</flux:label>
<flux:input wire:model="url_pkdns" placeholder="..."/>
<flux:description>{{ __('Pkarr DNS Adresse') }}</flux:description>
<flux:error name="url_pkdns"/>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Kontaktinformation') }}</flux:label>
<flux:textarea rows="3" wire:model="contact" placeholder="{{ __('Signal: @username, SimpleX: https://..., Email: ...') }}"/>
<flux:description>{{ __('Beliebige Kontaktinformationen (Signal, SimpleX, Email, etc.)') }}</flux:description>
<flux:error name="contact"/>
</flux:field>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<div class="flex items-center gap-4">
@if (session('status'))
<flux:text class="text-green-600 dark:text-green-400 font-medium">
{{ session('status') }}
</flux:text>
@endif
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Service aktualisieren') }}
</flux:button>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,114 @@
<?php
use App\Attributes\SeoDataAttribute;
use App\Models\SelfHostedService;
use App\Traits\SeoTrait;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new
#[SeoDataAttribute(key: 'services_index')]
class extends Component {
use WithPagination;
use SeoTrait;
public string $country = 'de';
public string $search = '';
public function mount(): void
{
$this->country = request()->route('country', config('app.domain_country'));
}
public function with(): array
{
return [
'services' => SelfHostedService::query()
->when($this->search, fn($q) => $q->where('name', 'ilike', '%'.$this->search.'%'))
->orderBy('name')
->paginate(15),
];
}
}; ?>
<div>
<div class="flex items-center justify-between flex-col md:flex-row mb-6">
<flux:heading size="xl">{{ __('Self Hosted Services') }}</flux:heading>
<div class="flex flex-col md:flex-row items-center gap-4">
<flux:input wire:model.live="search" :placeholder="__('Suche nach Services...')" clearable />
@auth
<flux:button class="cursor-pointer" :href="route_with_country('services.create')" icon="plus" variant="primary">
{{ __('Service erstellen') }}
</flux:button>
@endauth
</div>
</div>
<flux:table :paginate="$services" class="mt-6">
<flux:table.columns>
<flux:table.column>{{ __('Name') }}</flux:table.column>
<flux:table.column>{{ __('Typ') }}</flux:table.column>
<flux:table.column>{{ __('Links') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($services as $service)
<flux:table.row :key="$service->id">
<flux:table.cell variant="strong" class="flex items-center gap-3">
<flux:avatar class="[:where(&)]:size-16" :href="route('services.landingpage', ['service' => $service, 'country' => $country])" src="{{ $service->getFirstMedia('logo') ? $service->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<a href="{{ route('services.landingpage', ['service' => $service, 'country' => $country]) }}">{{ $service->name }}</a>
</flux:table.cell>
<flux:table.cell>
@if($service->type)
<flux:badge size="sm">{{ ucfirst($service->type->value) }}</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<div class="flex gap-2">
@if($service->url_clearnet)
<flux:link :href="$service->url_clearnet" external variant="subtle" title="Clearnet">
<flux:icon.globe-alt variant="mini" />
</flux:link>
@endif
@if($service->url_onion)
<flux:link :href="$service->url_onion" external variant="subtle" title="Onion">
<flux:icon.lock-closed variant="mini" />
</flux:link>
@endif
@if($service->url_i2p)
<flux:link :href="$service->url_i2p" external variant="subtle" title="I2P">
<flux:icon.link variant="mini" />
</flux:link>
@endif
@if($service->url_pkdns)
<flux:link :href="$service->url_pkdns" external variant="subtle" title="pkdns">
<flux:icon.link variant="mini" />
</flux:link>
@endif
@if($service->contact_url)
<flux:link :href="$service->contact_url" external variant="subtle" title="Kontakt">
<flux:icon.envelope variant="mini" />
</flux:link>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
@auth
@if(auth()->id() === $service->created_by)
<flux:button :href="route_with_country('services.edit', ['service' => $service])" size="xs" variant="filled" icon="pencil">
{{ __('Bearbeiten') }}
</flux:button>
@endif
@else
<flux:link :href="route('login')">{{ __('Log in') }}</flux:link>
@endauth
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>

View File

@@ -0,0 +1,96 @@
<?php
use App\Attributes\SeoDataAttribute;
use App\Models\SelfHostedService;
use App\Traits\SeoTrait;
use Livewire\Volt\Component;
new
#[SeoDataAttribute(key: 'services_landingpage')]
class extends Component {
use SeoTrait;
public SelfHostedService $service;
public $country = 'de';
public function mount(): void
{
$this->country = request()->route('country', config('app.domain_country'));
}
public function with(): array
{
return [
'service' => $this->service,
];
}
}; ?>
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-6">
<div class="flex items-center gap-4">
<flux:avatar class="[:where(&)]:size-24 [:where(&)]:text-base" size="xl"
src="{{ $service->getFirstMediaUrl('logo') }}"/>
<div>
<flux:heading size="xl" class="mb-1">{{ $service->name }}</flux:heading>
@if($service->type)
<flux:badge size="sm">{{ ucfirst($service->type->value) }}</flux:badge>
@endif
</div>
</div>
@if($service->intro)
<div>
<flux:heading size="lg" class="mb-2">{{ __('Über den Service') }}</flux:heading>
<x-markdown class="prose whitespace-pre-wrap">{!! $service->intro !!}</x-markdown>
</div>
@endif
</div>
<div class="space-y-4">
<flux:heading size="lg">{{ __('Links') }}</flux:heading>
<div class="grid grid-cols-1 gap-2">
@if($service->url_clearnet)
<flux:button href="{{ $service->url_clearnet }}" target="_blank" variant="ghost" class="justify-start">
<flux:icon.globe-alt class="w-5 h-5 mr-2" />
Clearnet
</flux:button>
@endif
@if($service->url_onion)
<flux:button href="{{ $service->url_onion }}" target="_blank" variant="ghost" class="justify-start">
<flux:icon.lock-closed class="w-5 h-5 mr-2" />
Onion / Tor
</flux:button>
@endif
@if($service->url_i2p)
<flux:button href="{{ $service->url_i2p }}" target="_blank" variant="ghost" class="justify-start">
<flux:icon.link class="w-5 h-5 mr-2" />
I2P
</flux:button>
@endif
@if($service->url_pkdns)
<flux:button href="{{ $service->url_pkdns }}" target="_blank" variant="ghost" class="justify-start">
<flux:icon.link class="w-5 h-5 mr-2" />
pkdns
</flux:button>
@endif
@if($service->contact_url)
<flux:button href="{{ $service->contact_url }}" target="_blank" variant="ghost" class="justify-start">
<flux:icon.envelope class="w-5 h-5 mr-2" />
{{ __('Kontakt') }}
</flux:button>
@endif
</div>
@auth
@if(auth()->id() === $service->created_by)
<flux:button :href="route_with_country('services.edit', ['service' => $service])" variant="primary" icon="pencil">
{{ __('Bearbeiten') }}
</flux:button>
@endif
@endauth
</div>
</div>
</div>