Merge remote-tracking branch 'origin/master'

This commit is contained in:
HolgerHatGarKeineNode
2026-03-23 17:26:51 +00:00
64 changed files with 4250 additions and 1026 deletions

View File

@@ -81,4 +81,66 @@
.brand-icon {
@apply w-8 h-8 rounded-lg bg-orange-primary flex items-center justify-center;
}
/**
* News Category Badges
*
* Farbige Kategorie-Badges für News-Karten.
* Jede Farbe hat einen transparenten Hintergrund mit passender Textfarbe.
*/
.news-category-badge {
@apply bg-bg-elevated text-text-secondary border border-border-default;
}
.news-category-badge--amber {
background-color: #FF5C0033;
color: #FF5C00;
border: none;
}
.news-category-badge--zinc {
@apply bg-bg-elevated text-text-secondary border border-border-default;
}
.news-category-badge--cyan {
background-color: #06b6d433;
color: #06b6d4;
border: none;
}
.news-category-badge--orange {
background-color: #FF5C0033;
color: #FF5C00;
border: none;
}
.news-category-badge--green {
background-color: #22c55e33;
color: #22c55e;
border: none;
}
.news-category-badge--blue {
background-color: #3b82f633;
color: #3b82f6;
border: none;
}
.news-category-badge--purple {
background-color: #7c3aed33;
color: #7c3aed;
border: none;
}
.news-category-badge--emerald {
background-color: #10b98133;
color: #10b981;
border: none;
}
.news-category-badge--rose {
background-color: #f4365833;
color: #f43658;
border: none;
}
}

View File

@@ -24,7 +24,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main', 60, 'preview') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"
@@ -36,7 +36,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main', 60, 'preview') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"
@@ -78,10 +78,7 @@
<!-- Second row: Action buttons -->
<div class="flex flex-wrap gap-2">
@if(
($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id)
|| ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true))
)
@if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('delete', $project))
<flux:button
icon="trash"
size="xs"
@@ -89,7 +86,8 @@
wire:click="$dispatch('confirmDeleteProject', { id: {{ $project->id }} })">
Löschen
</flux:button>
@endif
@if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('update', $project))
<flux:button
icon="pencil"
size="xs"

View File

@@ -1,6 +1,8 @@
<?php
use App\Models\Election;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
use swentel\nostr\Filter\Filter;
@@ -49,18 +51,31 @@ new class extends Component {
$this->loadBoardEvents();
$this->loadVotes();
$this->loadBoardVotes();
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->currentPleb = $nostrUser->getPleb();
$this->isAllowed = Gate::forUser($nostrUser)->allows('update', $this->election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = \App\Models\EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$this->isAllowed = (bool) $this->currentPleb;
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', $this->election);
}
public function handleNostrLoggedOut(): void
{
NostrAuth::logout();
$this->currentPubkey = null;
$this->currentPleb = null;
$this->isAllowed = false;
@@ -139,17 +154,22 @@ new class extends Component {
public function loadNostrEvents($kinds): array
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
return [];
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
$filter = new Filter;
$filter->setKinds($kinds);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaySet = new RelaySet;
$relaySet->setRelays([new Relay(config('services.relay'))]);
$relaySet->setRelays([new Relay($relayUrl)]);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
return collect($response[config('services.relay')])
return collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (! isset($event->event)) {
return false;

View File

@@ -3,6 +3,7 @@
use App\Models\EinundzwanzigPleb;
use App\Models\Election;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -29,34 +30,31 @@ new class extends Component {
$this->elections = Election::query()
->get()
->toArray();
if (NostrAuth::check()) {
$this->currentPubkey = NostrAuth::pubkey();
$logPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
];
if (in_array($this->currentPubkey, $logPubkeys, true)) {
$this->isAllowed = true;
}
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->isAllowed = Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$logPubkeys = [
'0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033',
'430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279',
];
$this->isAllowed = in_array($pubkey, $logPubkeys, true);
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election);
}
public function handleNostrLoggedOut(): void
{
NostrAuth::logout();
$this->currentPubkey = null;
$this->currentPleb = null;
$this->isAllowed = false;
@@ -66,6 +64,9 @@ new class extends Component {
{
$election = $this->elections[$index];
$electionModel = Election::find($election['id']);
Gate::forUser(NostrAuth::user())->authorize('update', $electionModel);
$electionModel->candidates = $election['candidates'];
$electionModel->save();
}

View File

@@ -4,6 +4,8 @@ use App\Models\Election;
use App\Models\EinundzwanzigPleb;
use App\Models\Profile;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -55,7 +57,7 @@ new class extends Component {
];
#[Computed]
public function loadedEvents(): array
public function loadedEvents(): \Illuminate\Support\Collection
{
return collect($this->events)
->map(function ($event) {
@@ -82,12 +84,11 @@ new class extends Component {
})
->sortByDesc('created_at')
->unique(fn ($event) => $event['pubkey'].$event['type'])
->values()
->toArray();
->values();
}
#[Computed]
public function loadedBoardEvents(): array
public function loadedBoardEvents(): \Illuminate\Support\Collection
{
return collect($this->boardEvents)
->map(function ($event) {
@@ -113,16 +114,15 @@ new class extends Component {
];
})
->sortByDesc('created_at')
->values()
->toArray();
->values();
}
#[Computed]
public function electionConfig(): array
public function electionConfig(): \Illuminate\Support\Collection
{
$loadedEvents = $this->loadedEvents();
return collect(json_decode($this->election->candidates, true, 512, JSON_THROW_ON_ERROR))
return collect($this->election->candidates)
->map(function ($c) use ($loadedEvents) {
$candidates = Profile::query()
->whereIn('pubkey', $c['c'])
@@ -147,16 +147,15 @@ new class extends Component {
'c' => $c['c'],
'candidates' => $candidates,
];
})
->toArray();
});
}
#[Computed]
public function electionConfigBoard(): array
public function electionConfigBoard(): \Illuminate\Support\Collection
{
$loadedBoardEvents = $this->loadedBoardEvents();
return collect(json_decode($this->election->candidates, true, 512, JSON_THROW_ON_ERROR))
return collect($this->election->candidates)
->map(function ($c) use ($loadedBoardEvents) {
$candidates = Profile::query()
->whereIn('pubkey', $c['c'])
@@ -182,8 +181,7 @@ new class extends Component {
'c' => $c['c'],
'candidates' => $candidates,
];
})
->toArray();
});
}
public function mount(Election $election): void
@@ -200,14 +198,35 @@ new class extends Component {
if ($this->election->end_time?->isPast() || ! config('services.voting')) {
$this->isNotClosed = false;
}
$nostrUser = NostrAuth::user();
if ($nostrUser) {
$this->currentPubkey = $nostrUser->getPubkey();
$this->currentPleb = $nostrUser->getPleb();
$this->isAllowed = Gate::forUser($nostrUser)->allows('vote', $this->election);
}
}
public function handleNostrLoggedIn(string $pubkey): void
{
$executed = RateLimiter::attempt(
'nostr-login:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many login attempts.');
}
NostrAuth::login($pubkey);
$this->currentPubkey = $pubkey;
$this->currentPleb = EinundzwanzigPleb::query()
->where('pubkey', $pubkey)->first();
$this->isAllowed = (bool) $this->currentPleb;
$nostrUser = NostrAuth::user();
$this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('vote', $this->election);
}
public function handleNostrLoggedOut(): void
@@ -248,17 +267,22 @@ new class extends Component {
public function loadNostrEvents($kinds): array
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
return [];
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
$filter = new Filter;
$filter->setKinds($kinds);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaySet = new RelaySet;
$relaySet->setRelays([new Relay(config('services.relay'))]);
$relaySet->setRelays([new Relay($relayUrl)]);
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
return collect($response[config('services.relay')])
return collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (! isset($event->event)) {
return false;
@@ -279,6 +303,18 @@ new class extends Component {
public function vote($pubkey, $type, $board = false): void
{
Gate::forUser(NostrAuth::user())->authorize('vote', $this->election);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
if ($this->election->end_time?->isPast()) {
$this->isNotClosed = false;
@@ -303,6 +339,16 @@ new class extends Component {
public function signEvent($event): void
{
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
$note = new NostrEvent;
$note->setId($event['id']);
$note->setSignature($event['sig']);
@@ -311,8 +357,12 @@ new class extends Component {
$note->setPublicKey($event['pubkey']);
$note->setTags($event['tags']);
$note->setCreatedAt($event['created_at']);
$relayUrl = config('services.relay');
if (! $relayUrl) {
return;
}
$eventMessage = new EventMessage($note);
$relay = new Relay(config('services.relay'));
$relay = new Relay($relayUrl);
$relay->setMessage($eventMessage);
$relay->send();
\App\Support\Broadcast::on('votes')->as('newVote')->sendNow();

View File

@@ -219,7 +219,7 @@ new class extends Component
</flux:button>
</div>
<flux:table>
<flux:table id="einundzwanzig-pleb-table">
<flux:table.columns>
<flux:table.column>Avatar</flux:table.column>
<flux:table.column

View File

@@ -103,12 +103,12 @@ class extends Component {
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first();
$news = Notification::query()->create([
'name' => $this->form['name'],
'description' => $this->form['description'] ?? null,
'category' => $this->form['category'],
'einundzwanzig_pleb_id' => $currentPleb->id,
]);
$news = new Notification;
$news->name = $this->form['name'];
$news->description = $this->form['description'] ?? null;
$news->category = $this->form['category'];
$news->einundzwanzig_pleb_id = $currentPleb->id;
$news->save();
if ($this->file) {
$news
@@ -142,289 +142,243 @@ class extends Component {
<div>
@if($isAllowed)
<div class="xl:flex">
<div class="flex flex-col gap-6 lg:flex-row lg:gap-8">
<!-- Left + Middle content -->
<div class="md:flex flex-1">
<!-- Main content -->
<div class="flex-1 min-w-0 flex flex-col gap-6">
<!-- Page title -->
<h1 class="text-[28px] font-semibold text-text-primary">News</h1>
<!-- 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 pills -->
<div class="flex flex-nowrap gap-2 overflow-x-auto no-scrollbar pb-1">
<button
wire:click="clearFilter"
class="shrink-0 rounded-full px-4 py-1.5 text-[13px] font-semibold transition-colors cursor-pointer {{ $selectedCategory === null ? 'bg-orange-primary text-white' : 'border border-border-default text-text-secondary hover:text-text-primary' }}"
>
Alle
</button>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<button
wire:key="cat_{{ $category['value'] }}"
wire:click="filterByCategory({{ $category['value'] }})"
class="shrink-0 rounded-full px-4 py-1.5 text-[13px] transition-colors cursor-pointer {{ $selectedCategory === $category['value'] ? 'bg-orange-primary text-white font-semibold' : 'border border-border-default text-text-secondary hover:text-text-primary font-normal' }}"
>
{{ $category['emoji'] }} {{ $category['label'] }}
</button>
@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 -->
<!-- News list -->
<div class="flex flex-col gap-4">
@forelse($this->filteredNews as $post)
<div wire:key="post_{{ $post->id }}" class="news-card bg-bg-surface rounded-xl p-5 border border-border-subtle flex flex-col gap-4">
<!-- Card header: avatar + meta -->
<div class="flex items-center gap-3">
<img
src="{{ $post->einundzwanzigPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
alt="{{ $post->einundzwanzigPleb->profile?->name ?? 'Anonym' }}"
class="w-10 h-10 rounded-full bg-bg-elevated object-cover shrink-0"
/>
<div class="flex-1 min-w-0 flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold text-text-primary">{{ $post->einundzwanzigPleb?->profile?->name ?? str($post->einundzwanzigPleb?->npub)->limit(32) }}</span>
<span class="text-xs text-text-tertiary">{{ $post->created_at->format('d.m.Y') }}</span>
</div>
<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>
<button
wire:click="filterByCategory({{ $post->category->value }})"
class="news-category-badge news-category-badge--{{ $post->category->color() }} inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] cursor-pointer"
>
{{ $post->category->emoji() }} {{ $post->category->label() }}
</button>
</div>
</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 }}
</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">
<flux:button
xs
target="_blank"
:href="url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
icon="cloud-arrow-down">
Öffnen
</flux:button>
@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>
<!-- Card body -->
<div class="flex flex-col gap-2">
<h3 class="text-base font-semibold text-text-primary">{{ $post->name }}</h3>
@if($post->description)
<p class="text-[13px] leading-relaxed text-text-secondary">{{ $post->description }}</p>
@endif
</div>
<!-- Card footer -->
<div class="flex items-center">
@if($post->getFirstMedia('pdf'))
<a
href="{{ url()->temporarySignedRoute('media.signed', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')]) }}"
target="_blank"
class="inline-flex items-center gap-2 rounded-lg border border-border-default px-4 py-2 text-[13px] font-medium text-text-secondary hover:text-text-primary transition-colors"
>
<svg class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 13H6"/><path d="M14 13h-1"/></svg>
PDF öffnen
</a>
@endif
@if($canEdit)
<div class="ml-auto">
<flux:modal.trigger name="delete-news-{{ $post->id }}">
<button
wire:click="confirmDelete({{ $post->id }})"
class="inline-flex items-center gap-1.5 rounded-lg bg-red-500/20 px-4 py-2 text-[13px] font-medium text-red-500 hover:bg-red-500/30 transition-colors cursor-pointer"
>
<svg class="w-3.5 h-3.5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
Löschen
</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>
</div>
@endif
</div>
</div>
@empty
<div class="bg-bg-surface rounded-xl p-5 border border-border-subtle">
<div class="py-6 text-center">
@if($selectedCategory !== null)
<flux:icon name="funnel" class="mx-auto mb-3 text-text-disabled" />
<h3 class="text-base font-semibold text-text-primary">Keine News in dieser Kategorie</h3>
<p class="mt-1 text-sm text-text-secondary">Versuche eine andere Kategorie oder zeige alle an.</p>
<button wire:click="clearFilter" class="mt-4 rounded-lg border border-border-default px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors cursor-pointer">
Alle anzeigen
</button>
@else
<flux:icon name="newspaper" class="mx-auto mb-3 text-text-disabled" />
<h3 class="text-base font-semibold text-text-primary">Noch keine News vorhanden</h3>
<p class="mt-1 text-sm text-text-secondary">Hier werden zukünftige Neuigkeiten angezeigt.</p>
@endif
</div>
</div>
@endforelse
</div>
</div>
<!-- Sidebar: create form (board members only) -->
@if($canEdit)
<div class="w-full lg:w-[360px] shrink-0">
<div class="lg:sticky lg:top-16 flex flex-col gap-6">
<div class="flex flex-col gap-6">
<h2 class="text-lg font-semibold text-text-primary">News anlegen</h2>
<!-- Upload section -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">PDF hochladen</label>
<flux:file-upload wire:model="file">
<flux:file-upload.dropzone heading="Datei hier ablegen oder klicken" text="PDF bis 10MB" class="!border-orange-primary !border-2 !bg-orange-primary/10 !rounded-xl" />
</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
</div>
<!-- Kategorie -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">Kategorie</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" />
</div>
<!-- Titel -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-text-primary">Titel</label>
<flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name" />
</div>
<!-- Beschreibung -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-text-primary">Beschreibung</label>
<span class="text-xs text-text-tertiary">optional</span>
</div>
<flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
<flux:error name="form.description" />
</div>
<!-- Submit -->
<button
wire:click="save"
class="w-full rounded-lg bg-orange-primary py-3 px-6 text-sm font-semibold text-white hover:bg-orange-light transition-colors cursor-pointer"
>
Hinzufügen
</button>
<!-- User badge -->
@if(NostrAuth::check())
@php
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first();
@endphp
@if($currentPleb)
<div class="flex items-center gap-2.5 rounded-xl bg-bg-surface border border-border-subtle px-4 py-2.5">
<img
src="{{ $currentPleb->profile?->picture ?? asset('einundzwanzig-alpha.jpg') }}"
alt="{{ $currentPleb->profile?->name ?? 'Anonym' }}"
class="w-8 h-8 rounded-full bg-bg-elevated object-cover shrink-0"
/>
<span class="text-[13px] font-medium text-text-primary">{{ $currentPleb->profile?->name ?? str($currentPleb->npub)->limit(32) }}</span>
</div>
@endif
@endif
</div>
</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

@@ -574,6 +574,12 @@ new class extends Component {
public function loadEvents(): void
{
$relayUrl = config('services.relay');
if (! $relayUrl) {
$this->events = [];
return;
}
$subscription = new Subscription;
$subscriptionId = $subscription->setId();
@@ -585,7 +591,7 @@ new class extends Component {
$requestMessage = new RequestMessage($subscriptionId, $filters);
$relays = [
new Relay(config('services.relay')),
new Relay($relayUrl),
];
$relaySet = new RelaySet;
$relaySet->setRelays($relays);
@@ -593,7 +599,7 @@ new class extends Component {
$request = new Request($relaySet, $requestMessage);
$response = $request->send();
$this->events = collect($response[config('services.relay')])
$this->events = collect($response[$relayUrl] ?? [])
->map(function ($event) {
if (!isset($event->event)) {
return false;

View File

@@ -2,6 +2,8 @@
use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@@ -34,15 +36,15 @@ class extends Component
public function mount(): void
{
if (NostrAuth::check()) {
$currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
$nostrUser = NostrAuth::user();
if ($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists()) {
$this->isAllowed = true;
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('create', ProjectProposal::class)) {
$this->isAllowed = true;
}
if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) {
if ($nostrUser) {
$pleb = $nostrUser->getPleb();
if ($pleb && in_array($pleb->npub, config('einundzwanzig.config.current_board'), true)) {
$this->isAdmin = true;
}
}
@@ -58,6 +60,18 @@ class extends Component
public function save(): void
{
Gate::forUser(NostrAuth::user())->authorize('create', ProjectProposal::class);
$executed = RateLimiter::attempt(
'project-proposal-create:'.request()->ip(),
5,
function () {},
);
if (! $executed) {
abort(429, 'Too many requests.');
}
$this->validate([
'form.name' => 'required|string|max:255',
'form.description' => 'required|string',
@@ -66,15 +80,15 @@ class extends Component
'file' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|mimetypes:image/jpeg,image/png,image/gif,image/webp|max:10240',
]);
$projectProposal = ProjectProposal::query()->create([
'name' => $this->form['name'],
'description' => $this->form['description'],
'support_in_sats' => (int) $this->form['support_in_sats'],
'website' => $this->form['website'],
'accepted' => $this->form['accepted'],
'sats_paid' => $this->form['sats_paid'],
'einundzwanzig_pleb_id' => \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first()->id,
]);
$projectProposal = new ProjectProposal;
$projectProposal->name = $this->form['name'];
$projectProposal->description = $this->form['description'];
$projectProposal->support_in_sats = (int) $this->form['support_in_sats'];
$projectProposal->website = $this->form['website'];
$projectProposal->accepted = $this->isAdmin ? $this->form['accepted'] : false;
$projectProposal->sats_paid = $this->isAdmin ? $this->form['sats_paid'] : 0;
$projectProposal->einundzwanzig_pleb_id = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first()->id;
$projectProposal->save();
if ($this->file) {
$projectProposal->addMedia($this->file)->toMediaCollection('main');

View File

@@ -2,6 +2,8 @@
use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
@@ -39,36 +41,29 @@ class extends Component
{
$this->project = $projectProposal;
if (NostrAuth::check()) {
$currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
$nostrUser = NostrAuth::user();
if (
(
$currentPleb
&& $currentPleb->id === $this->project->einundzwanzig_pleb_id
)
|| in_array($currentPleb->npub, config('einundzwanzig.config.current_board'))
) {
$this->isAllowed = true;
$this->form = [
'name' => $this->project->name,
'description' => $this->project->description,
'support_in_sats' => (string) $this->project->support_in_sats,
'website' => $this->project->website ?? '',
'accepted' => (bool) $this->project->accepted,
'sats_paid' => $this->project->sats_paid,
];
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('update', $this->project)) {
$this->isAllowed = true;
$this->form = [
'name' => $this->project->name,
'description' => $this->project->description,
'support_in_sats' => (string) $this->project->support_in_sats,
'website' => $this->project->website ?? '',
'accepted' => (bool) $this->project->accepted,
'sats_paid' => $this->project->sats_paid,
];
}
if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) {
$this->isAdmin = true;
}
if ($nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project)) {
$this->isAdmin = true;
}
}
public function deleteMainImage(): void
{
Gate::forUser(NostrAuth::user())->authorize('update', $this->project);
if ($this->project->getFirstMedia('main')) {
$this->project->getFirstMedia('main')->delete();
}
@@ -84,6 +79,18 @@ class extends Component
public function update(): void
{
Gate::forUser(NostrAuth::user())->authorize('update', $this->project);
$executed = RateLimiter::attempt(
'project-proposal-update:'.request()->ip(),
5,
function () {},
);
if (! $executed) {
abort(429, 'Too many requests.');
}
$this->validate([
'form.name' => 'required|string|max:255',
'form.description' => 'required|string',
@@ -92,13 +99,16 @@ class extends Component
'file' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|mimetypes:image/jpeg,image/png,image/gif,image/webp|max:10240',
]);
$nostrUser = NostrAuth::user();
$canAccept = $nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project);
$this->project->update([
'name' => $this->form['name'],
'description' => $this->form['description'],
'support_in_sats' => (int) $this->form['support_in_sats'],
'website' => $this->form['website'],
'accepted' => $this->isAdmin ? (bool) $this->form['accepted'] : $this->project->accepted,
'sats_paid' => $this->isAdmin ? $this->form['sats_paid'] : $this->project->sats_paid,
'accepted' => $canAccept ? (bool) $this->form['accepted'] : $this->project->accepted,
'sats_paid' => $canAccept ? $this->form['sats_paid'] : $this->project->sats_paid,
]);
if ($this->file) {

View File

@@ -6,6 +6,7 @@ use App\Models\ProjectProposal;
use App\Support\NostrAuth;
use Flux\Flux;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -31,8 +32,6 @@ new class extends Component {
public ?ProjectProposal $projectToDelete = null;
protected $listeners = [
'nostrLoggedIn' => 'handleNostrLoggedIn',
'nostrLoggedOut' => 'handleNostrLoggedOut',
'confirmDeleteProject' => 'confirmDeleteProject',
];
@@ -79,6 +78,8 @@ new class extends Component {
public function delete(): void
{
if ($this->projectToDelete) {
Gate::forUser(NostrAuth::user())->authorize('delete', $this->projectToDelete);
$this->projectToDelete->delete();
Flux::toast('Projektunterstützung gelöscht.');
$this->loadProjects();
@@ -112,7 +113,7 @@ new class extends Component {
</form>
<!-- Add meetup button -->
@if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists())
@if(Gate::forUser(NostrAuth::user())->allows('create', App\Models\ProjectProposal::class))
<flux:button :href="route('association.projectSupport.create')" icon="plus" variant="primary">
Projekt einreichen
</flux:button>

View File

@@ -4,6 +4,8 @@ use App\Livewire\Traits\WithNostrAuth;
use App\Models\ProjectProposal;
use App\Models\Vote;
use App\Support\NostrAuth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -60,10 +62,24 @@ new class extends Component {
public function handleApprove(): void
{
if (! $this->currentPleb) {
$nostrUser = NostrAuth::user();
if (! $nostrUser || ! $nostrUser->getPleb()) {
return;
}
Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
Vote::query()->updateOrCreate([
'project_proposal_id' => $this->projectProposal->id,
'einundzwanzig_pleb_id' => $this->currentPleb->id,
@@ -75,10 +91,24 @@ new class extends Component {
public function handleNotApprove(): void
{
if (! $this->currentPleb) {
$nostrUser = NostrAuth::user();
if (! $nostrUser || ! $nostrUser->getPleb()) {
return;
}
Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]);
$executed = RateLimiter::attempt(
'voting:'.request()->ip(),
10,
function () {},
);
if (! $executed) {
abort(429, 'Too many voting attempts.');
}
Vote::query()->updateOrCreate([
'project_proposal_id' => $this->projectProposal->id,
'einundzwanzig_pleb_id' => $this->currentPleb->id,
@@ -138,7 +168,7 @@ new class extends Component {
</div>
<figure class="mb-6">
<img class="rounded-sm h-48" src="{{ $projectProposal->getSignedMediaUrl('main') }}"
<img class="rounded-sm h-48" src="{{ $projectProposal->getSignedMediaUrl('main', 60, 'preview') }}"
alt="Picture">
</figure>