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 /.sisyphus
/.opencode /.opencode
.switch-omo-config* .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,151 +142,107 @@ class extends Component {
<div> <div>
@if($isAllowed) @if($isAllowed)
<div class="xl:flex"> <div class="lg:flex gap-8">
<!-- Left + Middle content -->
<div class="md:flex flex-1">
<!-- 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">
<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>
<!-- 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> </div>
<!-- Links --> <!-- Category filter (horizontal, scrollable on mobile) -->
<div <div class="mb-6 flex flex-nowrap gap-2 overflow-x-auto no-scrollbar pb-1">
class="flex flex-nowrap overflow-x-scroll no-scrollbar md:block md:overflow-auto px-4 md:space-y-3 -mx-4"> <flux:badge
<!-- Group 1 --> as="button"
<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" wire:click="clearFilter"
@class([ :color="$selectedCategory === null ? 'amber' : 'zinc'"
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer', :variant="$selectedCategory === null ? 'solid' : 'outline'"
'bg-amber-500 text-white' => $selectedCategory === null, size="sm"
'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, class="shrink-0 cursor-pointer"
])
> >
<i class="fa-sharp-duotone fa-solid fa-layer-group shrink-0 fill-current mr-2"></i> Alle
<span>Alle</span> </flux:badge>
</button>
</li>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category) @foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<li class="mr-0.5 md:mr-0 md:mb-0.5" <flux:badge
wire:key="category_{{ $category['value'] }}"> wire:key="cat_{{ $category['value'] }}"
<button as="button"
type="button"
wire:click="filterByCategory({{ $category['value'] }})" wire:click="filterByCategory({{ $category['value'] }})"
@class([ :color="$selectedCategory === $category['value'] ? 'amber' : 'zinc'"
'inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium transition-colors cursor-pointer', :variant="$selectedCategory === $category['value'] ? 'solid' : 'outline'"
'bg-amber-500 text-white' => $selectedCategory === $category['value'], size="sm"
'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'], class="shrink-0 cursor-pointer"
])
> >
<i class="fa-sharp-duotone fa-solid fa-{{ $category['icon'] }} shrink-0 fill-current mr-2"></i> <i class="fa-sharp-duotone fa-solid fa-{{ $category['icon'] }} mr-1"></i>
<span>{{ $category['label'] }}</span> {{ $category['label'] }}
</button> </flux:badge>
</li>
@endforeach @endforeach
</ul>
</div>
</div>
</div>
</div>
</div> </div>
<!-- Middle content --> <!-- News list -->
<div class="flex-1 md:ml-8 xl:mx-4 2xl:mx-8"> <div class="space-y-4">
<div class="md:py-8">
<div class="space-y-2">
@forelse($this->filteredNews as $post) @forelse($this->filteredNews as $post)
<flux:card wire:key="post_{{ $post->id }}"> <flux:card wire:key="post_{{ $post->id }}" class="space-y-0">
<!-- Avatar --> <!-- Header row: avatar + meta + actions -->
<div class="shrink-0 mt-1.5"> <div class="flex items-start gap-3">
<img class="w-8 h-8 rounded-full" <flux:avatar
src="{{ $post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}" size="sm"
width="32" height="32" :src="$post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg')"
alt="{{ $post->einundzwanzigPleb->profile?->name }}"> :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> </div>
<!-- Content --> <flux:badge
<div class="grow"> as="button"
<!-- Category Badge -->
<div class="mb-2">
<button
type="button"
wire:click="filterByCategory({{ $post->category->value }})" 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" :color="$post->category->color()"
size="sm"
class="cursor-pointer"
> >
<i class="fa-sharp-duotone fa-solid fa-{{ $post->category->icon() }} mr-1"></i> <i class="fa-sharp-duotone fa-solid fa-{{ $post->category->icon() }} mr-1"></i>
{{ $post->category->label() }} {{ $post->category->label() }}
</button> </flux:badge>
</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>
<!-- 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>
<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"> <!-- Actions -->
<span <div class="mt-4 flex items-center gap-2">
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')) @if($post->getFirstMedia('pdf'))
<flux:button <flux:button
xs size="sm"
variant="ghost"
target="_blank" target="_blank"
:href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])" :href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
icon="cloud-arrow-down"> icon="document-arrow-down"
Öffnen >
PDF öffnen
</flux:button> </flux:button>
@endif @endif
@if($canEdit) @if($canEdit)
<flux:spacer />
<flux:modal.trigger name="delete-news-{{ $post->id }}"> <flux:modal.trigger name="delete-news-{{ $post->id }}">
<flux:button <flux:button
xs size="sm"
variant="danger" variant="danger"
icon="trash" icon="trash"
wire:click="confirmDelete({{ $post->id }})"> wire:click="confirmDelete({{ $post->id }})"
>
Löschen Löschen
</flux:button> </flux:button>
</flux:modal.trigger> </flux:modal.trigger>
@@ -314,46 +270,38 @@ class extends Component {
</flux:card> </flux:card>
@empty @empty
<flux:card> <flux:card>
<div class="py-6 text-center">
@if($selectedCategory !== null) @if($selectedCategory !== null)
<p>Keine News in dieser Kategorie vorhanden.</p> <flux:icon name="funnel" class="mx-auto mb-3 text-zinc-300 dark:text-zinc-600" />
<flux:button wire:click="clearFilter" size="sm" class="mt-2"> <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 Alle anzeigen
</flux:button> </flux:button>
@else @else
<p>Keine News vorhanden.</p> <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 @endif
</div>
</flux:card> </flux:card>
@endforelse @endforelse
</div> </div>
</div>
</div> </div>
</div> <!-- Sidebar: create form (board members only) -->
<!-- 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) @if($canEdit)
<flux:card> <div class="w-full lg:w-80 shrink-0 mt-8 lg:mt-0">
<div <div class="lg:sticky lg:top-16">
class="text-xs font-semibold text-zinc-400 dark:text-zinc-200 uppercase mb-4"> <flux:card class="space-y-4">
News anlegen <flux:heading>News anlegen</flux:heading>
</div> <flux:separator />
<div class="mt-4 flex flex-col space-y-2">
<flux:file-upload wire:model="file" label="PDF hochladen"> <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.dropzone heading="Datei hier ablegen oder klicken" text="PDF bis 10MB" />
</flux:file-upload> </flux:file-upload>
@error('file') <flux:error name="file" />
<span class="text-red-500">{{ $message }}</span>
@enderror
<div class="mt-3 flex flex-col gap-2">
@if ($file) @if ($file)
<flux:file-item <flux:file-item
:heading="$file->getClientOriginalName()" :heading="$file->getClientOriginalName()"
@@ -364,8 +312,7 @@ class extends Component {
</x-slot> </x-slot>
</flux:file-item> </flux:file-item>
@endif @endif
</div>
<div>
<flux:field> <flux:field>
<flux:label>Kategorie</flux:label> <flux:label>Kategorie</flux:label>
<flux:select <flux:select
@@ -379,42 +326,36 @@ class extends Component {
/> />
@endforeach @endforeach
</flux:select> </flux:select>
<flux:error name="form.category"/> <flux:error name="form.category" />
</flux:field> </flux:field>
</div>
<div>
<flux:field> <flux:field>
<flux:label>Titel</flux:label> <flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel"/> <flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name"/> <flux:error name="form.name" />
</flux:field> </flux:field>
</div>
<div>
<flux:field> <flux:field>
<flux:label>Beschreibung</flux:label> <flux:label>Beschreibung</flux:label>
<flux:description>optional</flux:description> <flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4" <flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
placeholder="Beschreibung..."/> <flux:error name="form.description" />
<flux:error name="form.description"/>
</flux:field> </flux:field>
</div>
<flux:button wire:click="save" class="w-full"> <flux:button wire:click="save" variant="primary" class="w-full">
Hinzufügen Hinzufügen
</flux:button> </flux:button>
</div>
</flux:card> </flux:card>
</div>
</div>
@endif @endif
</div>
</div>
</div>
</div>
</div> </div>
@else @else
<div class=""> <div class="max-w-2xl mx-auto">
<flux:callout variant="warning" icon="exclamation-circle"> <flux:callout variant="warning" icon="exclamation-circle">
<flux:heading>Zugriff auf News nicht möglich</flux:heading> <flux:callout.heading>Zugriff auf News nicht möglich</flux:callout.heading>
<flux:callout.text>
<p>Um die News einzusehen, benötigst du:</p> <p>Um die News einzusehen, benötigst du:</p>
<ul class="list-disc ml-5 mt-2 space-y-1"> <ul class="list-disc ml-5 mt-2 space-y-1">
<li>Einen Vereinsstatus von "Aktives Mitglied"</li> <li>Einen Vereinsstatus von "Aktives Mitglied"</li>
@@ -427,6 +368,7 @@ class extends Component {
Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest. Bitte kontaktiere den Vorstand, wenn du denkst, dass du berechtigt sein solltest.
@endif @endif
</p> </p>
</flux:callout.text>
</flux:callout> </flux:callout>
</div> </div>
@endif @endif

View File

@@ -112,3 +112,103 @@ it('displays news list', function () {
->assertSee($news1->name) ->assertSee($news1->name)
->assertSee($news2->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');
});