Die News Ansicht verschönern (vibe-kanban 7c9cbf57)

Nutze Livewire Flux UI um die Ansicht der News Items zu verschönern. Teste alles am besten auch eine Validierung mit playwright, ob alles passt und richtig in mobile und Desktop angezeigt wird.
This commit is contained in:
vk
2026-02-12 23:20:23 +01:00
parent 0c64fe55d7
commit ff7b9a3493
4 changed files with 318 additions and 271 deletions

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ yarn-error.log
/.sisyphus
/.opencode
.switch-omo-config*
/.playwright-mcp
/*.png

View File

@@ -0,0 +1,3 @@
[ 3014ms] [ERROR] Access to font at 'http://localhost/storage/fonts/440f07d668/sinconsolatav37qlddnthlqrwh-oj1uhjlkenvzkwgvkl3gzqmawlyya15idhuna.woff2' from origin 'http://127.0.0.1:8321' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://127.0.0.1:8321/association/news:654
[ 3015ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost/storage/fonts/440f07d668/sinconsolatav37qlddnthlqrwh-oj1uhjlkenvzkwgvkl3gzqmawlyya15idhuna.woff2:0
[ 3126ms] [ERROR] Failed to load resource: net::ERR_CONNECTION_CLOSED @ https://127.0.0.1:8321/favicon.ico:0

View File

@@ -142,291 +142,233 @@ class extends Component {
<div>
@if($isAllowed)
<div class="xl:flex">
<div class="lg:flex gap-8">
<!-- Left + Middle content -->
<div class="md:flex flex-1">
<!-- Main content -->
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<flux:heading size="xl">News</flux:heading>
</div>
<!-- Left content -->
<div class="w-full md:w-60 mb-8 md:mb-0">
<div
class="md:sticky md:top-16 md:h-[calc(100dvh-64px)] md:overflow-x-hidden md:overflow-y-auto no-scrollbar">
<div class="md:py-8">
<!-- Category filter (horizontal, scrollable on mobile) -->
<div class="mb-6 flex flex-nowrap gap-2 overflow-x-auto no-scrollbar pb-1">
<flux:badge
as="button"
wire:click="clearFilter"
:color="$selectedCategory === null ? 'amber' : 'zinc'"
:variant="$selectedCategory === null ? 'solid' : 'outline'"
size="sm"
class="shrink-0 cursor-pointer"
>
Alle
</flux:badge>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:badge
wire:key="cat_{{ $category['value'] }}"
as="button"
wire:click="filterByCategory({{ $category['value'] }})"
:color="$selectedCategory === $category['value'] ? 'amber' : 'zinc'"
:variant="$selectedCategory === $category['value'] ? 'solid' : 'outline'"
size="sm"
class="shrink-0 cursor-pointer"
>
<i class="fa-sharp-duotone fa-solid fa-{{ $category['icon'] }} mr-1"></i>
{{ $category['label'] }}
</flux:badge>
@endforeach
</div>
<div class="flex justify-between items-center md:block">
<!-- Title -->
<header class="mb-6">
<h1 class="text-2xl md:text-3xl text-zinc-800 dark:text-zinc-100 font-bold">
News
</h1>
</header>
</div>
<!-- Links -->
<div
class="flex flex-nowrap overflow-x-scroll no-scrollbar md:block md:overflow-auto px-4 md:space-y-3 -mx-4">
<!-- Group 1 -->
<div>
<div
class="text-xs font-semibold text-zinc-400 dark:text-zinc-500 uppercase mb-3 md:sr-only">
Kategorien
</div>
<ul class="flex flex-nowrap md:block mr-3 md:mr-0">
<li class="mr-0.5 md:mr-0 md:mb-0.5" wire:key="category_all">
<button
type="button"
wire:click="clearFilter"
@class([
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer',
'bg-amber-500 text-white' => $selectedCategory === null,
'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600' => $selectedCategory !== null,
])
>
<i class="fa-sharp-duotone fa-solid fa-layer-group shrink-0 fill-current mr-2"></i>
<span>Alle</span>
</button>
</li>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<li class="mr-0.5 md:mr-0 md:mb-0.5"
wire:key="category_{{ $category['value'] }}">
<button
type="button"
wire:click="filterByCategory({{ $category['value'] }})"
@class([
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer',
'bg-amber-500 text-white' => $selectedCategory === $category['value'],
'bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600' => $selectedCategory !== $category['value'],
])
>
<i class="fa-sharp-duotone fa-solid fa-{{ $category['icon'] }} shrink-0 fill-current mr-2"></i>
<span>{{ $category['label'] }}</span>
</button>
</li>
@endforeach
</ul>
<!-- News list -->
<div class="space-y-4">
@forelse($this->filteredNews as $post)
<flux:card wire:key="post_{{ $post->id }}" class="space-y-0">
<!-- Header row: avatar + meta + actions -->
<div class="flex items-start gap-3">
<flux:avatar
size="sm"
:src="$post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg')"
:name="$post->einundzwanzigPleb->profile?->name ?? 'Anonym'"
circle
/>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-1">
<flux:text class="text-sm font-medium text-zinc-900 dark:text-zinc-100">
{{ $post->einundzwanzigPleb?->profile?->name ?? str($post->einundzwanzigPleb?->npub)->limit(32) }}
</flux:text>
<flux:text class="text-xs text-zinc-400 dark:text-zinc-500">
{{ $post->created_at->format('d.m.Y') }}
</flux:text>
</div>
<flux:badge
as="button"
wire:click="filterByCategory({{ $post->category->value }})"
:color="$post->category->color()"
size="sm"
class="cursor-pointer"
>
<i class="fa-sharp-duotone fa-solid fa-{{ $post->category->icon() }} mr-1"></i>
{{ $post->category->label() }}
</flux:badge>
</div>
</div>
</div>
</div>
<!-- Middle content -->
<div class="flex-1 md:ml-8 xl:mx-4 2xl:mx-8">
<div class="md:py-8">
<div class="space-y-2">
@forelse($this->filteredNews as $post)
<flux:card wire:key="post_{{ $post->id }}">
<!-- Avatar -->
<div class="shrink-0 mt-1.5">
<img class="w-8 h-8 rounded-full"
src="{{ $post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
width="32" height="32"
alt="{{ $post->einundzwanzigPleb->profile?->name }}">
</div>
<!-- Content -->
<div class="grow">
<!-- Category Badge -->
<div class="mb-2">
<button
type="button"
wire:click="filterByCategory({{ $post->category->value }})"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<i class="fa-sharp-duotone fa-solid fa-{{ $post->category->icon() }} mr-1"></i>
{{ $post->category->label() }}
</button>
</div>
<!-- Title -->
<h2 class="font-semibold text-zinc-800 dark:text-zinc-100 mb-2">
{{ $post->name }}
</h2>
<p class="mb-6">
{{ $post->description }}
</p>
<!-- Footer -->
<footer class="flex flex-wrap text-sm">
<div
class="flex items-center after:block after:content-['·'] last:after:content-[''] after:text-sm after:text-zinc-400 dark:after:text-zinc-600 after:px-2">
<div
class="font-medium text-amber-500 hover:text-amber-600 dark:hover:text-amber-400">
<div class="flex items-center">
<svg class="mr-2 fill-current" width="16"
height="16"
xmlns="http://www.w3.org/2000/svg">
<path
d="M15.686 5.708 10.291.313c-.4-.4-.999-.4-1.399 0s-.4 1 0 1.399l.6.6-6.794 3.696-1-1C1.299 4.61.7 4.61.3 5.009c-.4.4-.4 1 0 1.4l1.498 1.498 2.398 2.398L.6 14.001 2 15.4l3.696-3.697L9.692 15.7c.5.5 1.199.2 1.398 0 .4-.4.4-1 0-1.4l-.999-.998 3.697-6.695.6.6c.599.6 1.199.2 1.398 0 .3-.4.3-1.1-.1-1.499Zm-7.193 6.095L4.196 7.507l6.695-3.697 1.298 1.299-3.696 6.694Z"></path>
</svg>
{{ $post->einundzwanzigPleb?->profile?->name ?? str($post->einundzwanzigPleb?->npub)->limit(32) }}
</div>
</div>
</div>
<div
class="flex items-center after:block after:content-['·'] last:after:content-[''] after:text-sm after:text-zinc-400 dark:after:text-zinc-600 after:px-2">
<span
class="text-zinc-500">{{ $post->created_at->format('d.m.Y') }}</span>
</div>
</footer>
</div>
<div class="mt-2 flex justify-end w-full space-x-2">
@if($post->getFirstMedia('pdf'))
<flux:button
xs
target="_blank"
:href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
icon="cloud-arrow-down">
Öffnen
</flux:button>
@endif
@if($canEdit)
<flux:modal.trigger name="delete-news-{{ $post->id }}">
<flux:button
xs
variant="danger"
icon="trash"
wire:click="confirmDelete({{ $post->id }})">
Löschen
</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-news-{{ $post->id }}" class="min-w-88">
<div class="space-y-6">
<div>
<flux:heading size="lg">News löschen?</flux:heading>
<flux:text class="mt-2">
Du bist dabei, diese News zu löschen.<br>
Diese Aktion kann nicht rückgängig gemacht werden.
</flux:text>
</div>
<div class="flex gap-2">
<flux:spacer />
<flux:modal.close>
<flux:button variant="ghost">Abbrechen</flux:button>
</flux:modal.close>
<flux:button wire:click="delete" variant="danger">Löschen</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>
</flux:card>
@empty
<flux:card>
@if($selectedCategory !== null)
<p>Keine News in dieser Kategorie vorhanden.</p>
<flux:button wire:click="clearFilter" size="sm" class="mt-2">
Alle anzeigen
</flux:button>
@else
<p>Keine News vorhanden.</p>
@endif
</flux:card>
@endforelse
</div>
</div>
</div>
</div>
<!-- Right content -->
<div class="w-full mt-8 sm:mt-0 xl:w-72">
<div
class="lg:sticky lg:top-16 lg:h-[calc(100dvh-64px)] lg:overflow-x-hidden lg:overflow-y-auto no-scrollbar">
<div class="md:py-8">
<!-- Blocks -->
<div class="space-y-4">
@if($canEdit)
<flux:card>
<div
class="text-xs font-semibold text-zinc-400 dark:text-zinc-200 uppercase mb-4">
News anlegen
</div>
<div class="mt-4 flex flex-col space-y-2">
<flux:file-upload wire:model="file" label="PDF hochladen">
<flux:file-upload.dropzone heading="Drop file here or click to browse" text="PDF bis 10MB" />
</flux:file-upload>
@error('file')
<span class="text-red-500">{{ $message }}</span>
@enderror
<div class="mt-3 flex flex-col gap-2">
@if ($file)
<flux:file-item
:heading="$file->getClientOriginalName()"
:size="$file->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeFile" aria-label="{{ 'Remove file: ' . $file->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endif
</div>
<div>
<flux:field>
<flux:label>Kategorie</flux:label>
<flux:select
wire:model="form.category"
placeholder="Wähle Kategorie"
>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/>
@endforeach
</flux:select>
<flux:error name="form.category"/>
</flux:field>
</div>
<div>
<flux:field>
<flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel"/>
<flux:error name="form.name"/>
</flux:field>
</div>
<div>
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4"
placeholder="Beschreibung..."/>
<flux:error name="form.description"/>
</flux:field>
</div>
<flux:button wire:click="save" class="w-full">
Hinzufügen
</flux:button>
</div>
</flux:card>
<!-- Body -->
<div class="mt-3">
<flux:heading class="mb-1">{{ $post->name }}</flux:heading>
@if($post->description)
<flux:text class="text-sm">{{ $post->description }}</flux:text>
@endif
</div>
</div>
<!-- Actions -->
<div class="mt-4 flex items-center gap-2">
@if($post->getFirstMedia('pdf'))
<flux:button
size="sm"
variant="ghost"
target="_blank"
:href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
icon="document-arrow-down"
>
PDF öffnen
</flux:button>
@endif
@if($canEdit)
<flux:spacer />
<flux:modal.trigger name="delete-news-{{ $post->id }}">
<flux:button
size="sm"
variant="danger"
icon="trash"
wire:click="confirmDelete({{ $post->id }})"
>
Löschen
</flux:button>
</flux:modal.trigger>
<flux:modal name="delete-news-{{ $post->id }}" class="min-w-88">
<div class="space-y-6">
<div>
<flux:heading size="lg">News löschen?</flux:heading>
<flux:text class="mt-2">
Du bist dabei, diese News zu löschen.<br>
Diese Aktion kann nicht rückgängig gemacht werden.
</flux:text>
</div>
<div class="flex gap-2">
<flux:spacer />
<flux:modal.close>
<flux:button variant="ghost">Abbrechen</flux:button>
</flux:modal.close>
<flux:button wire:click="delete" variant="danger">Löschen</flux:button>
</div>
</div>
</flux:modal>
@endif
</div>
</flux:card>
@empty
<flux:card>
<div class="py-6 text-center">
@if($selectedCategory !== null)
<flux:icon name="funnel" class="mx-auto mb-3 text-zinc-300 dark:text-zinc-600" />
<flux:heading>Keine News in dieser Kategorie</flux:heading>
<flux:text class="mt-1 text-sm">Versuche eine andere Kategorie oder zeige alle an.</flux:text>
<flux:button wire:click="clearFilter" size="sm" class="mt-4">
Alle anzeigen
</flux:button>
@else
<flux:icon name="newspaper" class="mx-auto mb-3 text-zinc-300 dark:text-zinc-600" />
<flux:heading>Noch keine News vorhanden</flux:heading>
<flux:text class="mt-1 text-sm">Hier werden zukünftige Neuigkeiten angezeigt.</flux:text>
@endif
</div>
</flux:card>
@endforelse
</div>
</div>
<!-- Sidebar: create form (board members only) -->
@if($canEdit)
<div class="w-full lg:w-80 shrink-0 mt-8 lg:mt-0">
<div class="lg:sticky lg:top-16">
<flux:card class="space-y-4">
<flux:heading>News anlegen</flux:heading>
<flux:separator />
<flux:file-upload wire:model="file" label="PDF hochladen">
<flux:file-upload.dropzone heading="Datei hier ablegen oder klicken" text="PDF bis 10MB" />
</flux:file-upload>
<flux:error name="file" />
@if ($file)
<flux:file-item
:heading="$file->getClientOriginalName()"
:size="$file->getSize()"
>
<x-slot name="actions">
<flux:file-item.remove wire:click="removeFile" aria-label="{{ 'Remove file: ' . $file->getClientOriginalName() }}" />
</x-slot>
</flux:file-item>
@endif
<flux:field>
<flux:label>Kategorie</flux:label>
<flux:select
wire:model="form.category"
placeholder="Wähle Kategorie"
>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/>
@endforeach
</flux:select>
<flux:error name="form.category" />
</flux:field>
<flux:field>
<flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name" />
</flux:field>
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
<flux:error name="form.description" />
</flux:field>
<flux:button wire:click="save" variant="primary" class="w-full">
Hinzufügen
</flux:button>
</flux:card>
</div>
</div>
@endif
</div>
</div>
@else
<div class="">
<div class="max-w-2xl mx-auto">
<flux:callout variant="warning" icon="exclamation-circle">
<flux:heading>Zugriff auf News nicht möglich</flux:heading>
<p>Um die News einzusehen, benötigst du:</p>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Einen Vereinsstatus von "Aktives Mitglied"</li>
<li>Eine bezahlte Mitgliedschaft für das aktuelle Jahr ({{ date('Y') }})</li>
</ul>
<p class="mt-3">
@if(!NostrAuth::check())
Bitte melde dich zunächst mit Nostr an.
@else
Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest.
@endif
</p>
<flux:callout.heading>Zugriff auf News nicht möglich</flux:callout.heading>
<flux:callout.text>
<p>Um die News einzusehen, benötigst du:</p>
<ul class="list-disc ml-5 mt-2 space-y-1">
<li>Einen Vereinsstatus von "Aktives Mitglied"</li>
<li>Eine bezahlte Mitgliedschaft für das aktuelle Jahr ({{ date('Y') }})</li>
</ul>
<p class="mt-3">
@if(!NostrAuth::check())
Bitte melde dich zunächst mit Nostr an.
@else
Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest.
@endif
</p>
</flux:callout.text>
</flux:callout>
</div>
@endif

View File

@@ -112,3 +112,103 @@ it('displays news list', function () {
->assertSee($news1->name)
->assertSee($news2->name);
});
it('shows warning callout when access is denied', function () {
$pleb = EinundzwanzigPleb::factory()->create([
'association_status' => AssociationStatus::PASSIVE,
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSet('isAllowed', false)
->assertSee('Zugriff auf News nicht möglich')
->assertSee('Aktives Mitglied');
});
it('shows nostr login hint when not authenticated', function () {
Livewire::test('association.news')
->assertSet('isAllowed', false)
->assertSee('Bitte melde dich zunächst mit Nostr an');
});
it('displays category badges as filters', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Alle')
->assertSee('Einundzwanzig')
->assertSee('Allgemeines')
->assertSee('Organisation');
});
it('filters news by category', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$newsOrg = Notification::factory()->create(['category' => NewsCategory::Organisation]);
$newsBtc = Notification::factory()->create(['category' => NewsCategory::Bitcoin]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee($newsOrg->name)
->assertSee($newsBtc->name)
->call('filterByCategory', NewsCategory::Organisation->value)
->assertSee($newsOrg->name)
->assertDontSee($newsBtc->name);
});
it('shows empty state when no news exist', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Noch keine News vorhanden');
});
it('shows filtered empty state with clear button', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->call('filterByCategory', NewsCategory::Bildung->value)
->assertSee('Keine News in dieser Kategorie')
->assertSee('Alle anzeigen');
});
it('displays news card with author name and date', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
$news = Notification::factory()->create([
'name' => 'Wichtige Neuigkeiten',
'description' => 'Hier steht die Beschreibung',
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('Wichtige Neuigkeiten')
->assertSee('Hier steht die Beschreibung')
->assertSee($news->created_at->format('d.m.Y'));
});
it('shows create form only for board members', function () {
$pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertDontSee('News anlegen');
});
it('displays create form for board members', function () {
$pleb = EinundzwanzigPleb::factory()->boardMember()->withPaidCurrentYear()->create();
NostrAuth::login($pleb->pubkey);
Livewire::test('association.news')
->assertSee('News anlegen')
->assertSee('Hinzufügen');
});