mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-03-23 19:08:41 +00:00
Merge remote-tracking branch 'origin/master'
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user