Add radio embed widget, SSE real-time, song history, moderation panel, and Auto DJ

- Embed widget: standalone iframe player with dark/light/transparent themes, copy-paste embed code admin page
- Real-time SSE: streaming now-playing/listeners/dj events, replaces polling in radio-player and embed
- Song history: auto-records song changes to radio_song_plays table, Filament resource to view
- DJ moderation: unified panel for shouts approval, song request queue, DJ applications
- Auto DJ: playlist management with round-robin playback when no DJ is live
- Refactored radio-player Alpine component to use EventSource API with auto-reconnect
This commit is contained in:
root
2026-05-24 14:07:32 +02:00
parent 5476dce882
commit 0c6c558a59
32 changed files with 2236 additions and 29 deletions
@@ -0,0 +1,26 @@
<?php
?>
<x-filament-panels::page>
@if($newKey = $this->getNewKey())
<div class="bg-success-50 dark:bg-success-950 border border-success-300 dark:border-success-700 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<x-filament::icon name="heroicon-o-key" class="w-5 h-5 text-success-600 dark:text-success-400" />
<strong class="text-success-700 dark:text-success-300">Nieuwe API Sleutel Aangemaakt!</strong>
</div>
<p class="text-sm text-success-600 dark:text-success-400 mb-2">Kopieer deze sleutel nu. Deze wordt niet meer getoond:</p>
<div class="flex gap-2">
<input type="text" value="{{ $newKey }}" readonly
class="flex-1 bg-white dark:bg-gray-900 border border-success-300 dark:border-success-700 rounded-lg px-3 py-2 text-sm font-mono"
id="newApiKey" />
<button onclick="navigator.clipboard.writeText('{{ $newKey }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
class="px-4 py-2 bg-success-600 text-white rounded-lg text-sm hover:bg-success-700 transition">
Kopieer
</button>
</div>
</div>
@endif
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,31 @@
<?php
?>
<x-filament-panels::page>
@php $autoDj = \Illuminate\Support\Facades\Cache::get('radio_auto_dj_active'); @endphp
@if($autoDj)
<div class="bg-success-50 dark:bg-success-950 border border-success-300 dark:border-success-700 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2">
<x-filament::icon name="heroicon-o-play-circle" class="w-5 h-5 text-success-600 dark:text-success-400" />
<div>
<strong class="text-success-700 dark:text-success-300">Auto DJ is actief</strong>
<p class="text-sm text-success-600 dark:text-success-400">Speelt: {{ $autoDj['title'] ?? 'Onbekend' }}{{ $autoDj['artist'] ? ' - ' . $autoDj['artist'] : '' }}</p>
</div>
</div>
</div>
@else
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2">
<x-filament::icon name="heroicon-o-pause-circle" class="w-5 h-5 text-gray-400" />
<div>
<strong class="text-gray-700 dark:text-gray-300">Auto DJ is niet actief</strong>
<p class="text-sm text-gray-500 dark:text-gray-400">Auto DJ wordt geactiveerd wanneer er geen DJ live is en er tracks in de playlist staan.</p>
</div>
</div>
</div>
@endif
{{ $this->table }}
</x-filament-panels::page>
@@ -0,0 +1,151 @@
<?php
use App\Models\RadioApplication;
use App\Models\RadioShout;
use App\Models\RadioSongRequest;
?>
<x-filament-panels::page>
<x-filament::tabs>
{{-- Shouts Tab --}}
<x-filament::tabs.tab
icon="heroicon-o-chat-bubble-oval-left"
label="Shouts ({{ RadioShout::where('approved', false)->count() }} wachtend, {{ RadioShout::where('reported', true)->count() }} gerapporteerd)"
>
<div class="space-y-4">
@php $pendingShouts = $this->getPendingShouts(); @endphp
@if($pendingShouts->count() > 0)
<h3 class="text-lg font-semibold">Wachtend op goedkeuring</h3>
<div class="space-y-2">
@foreach($pendingShouts as $shout)
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-medium text-sm">{{ $shout->user?->username ?? 'Onbekend' }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ $shout->message }}</p>
<p class="text-xs text-gray-400 mt-1">{{ $shout->created_at->diffForHumans() }}</p>
</div>
<div class="flex gap-2 shrink-0">
<x-filament::button wire:click="approveShout({{ $shout->id }})" color="success" size="xs" icon="heroicon-o-check">
Goedkeuren
</x-filament::button>
<x-filament::button wire:click="deleteShout({{ $shout->id }})" color="danger" size="xs" icon="heroicon-o-trash" onclick="return confirm('Zeker weten?')">
Verwijderen
</x-filament::button>
</div>
</div>
@endforeach
{{ $pendingShouts->links() }}
</div>
@endif
@php $reportedShouts = $this->getReportedShouts(); @endphp
@if($reportedShouts->count() > 0)
<h3 class="text-lg font-semibold mt-6">Gerapporteerde shouts</h3>
<div class="space-y-2">
@foreach($reportedShouts as $shout)
<div class="bg-red-50 dark:bg-red-950 rounded-lg p-4 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-medium text-sm">{{ $shout->user?->username ?? 'Onbekend' }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400 truncate">{{ $shout->message }}</p>
<p class="text-xs text-gray-400 mt-1">{{ $shout->created_at->diffForHumans() }}</p>
</div>
<div class="flex gap-2 shrink-0">
<x-filament::button wire:click="dismissShoutReport({{ $shout->id }})" color="info" size="xs" icon="heroicon-o-flag">
Negeren
</x-filament::button>
<x-filament::button wire:click="deleteShout({{ $shout->id }})" color="danger" size="xs" icon="heroicon-o-trash" onclick="return confirm('Zeker weten?')">
Verwijderen
</x-filament::button>
</div>
</div>
@endforeach
{{ $reportedShouts->links() }}
</div>
@endif
@if($pendingShouts->count() === 0 && $reportedShouts->count() === 0)
<p class="text-gray-500 text-center py-8">Geen shouts wachtend op moderatie</p>
@endif
</div>
</x-filament::tabs.tab>
{{-- Song Requests Tab --}}
<x-filament::tabs.tab
icon="heroicon-o-musical-note"
label="Verzoeken ({{ RadioSongRequest::where('is_approved', false)->where('is_played', false)->count() }} wachtend)"
>
<div class="space-y-4">
@php $requests = $this->getPendingRequests(); @endphp
@if($requests->count() > 0)
<div class="space-y-2">
@foreach($requests as $request)
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-medium text-sm">{{ $request->user?->username ?? 'Onbekend' }}</p>
<p class="text-sm font-semibold">{{ $request->song_title }}</p>
<p class="text-sm text-gray-500" x-show="{{ $request->song_artist }}">{{ $request->song_artist }}</p>
<div class="flex items-center gap-3 mt-1">
<span class="text-xs text-gray-400">{{ $request->submitted_at->diffForHumans() }}</span>
<span class="text-xs text-amber-500">{{ $request->votes }} stemmen</span>
</div>
</div>
<div class="flex gap-2 shrink-0">
<x-filament::button wire:click="approveRequest({{ $request->id }})" color="success" size="xs" icon="heroicon-o-check">
Goedkeuren
</x-filament::button>
<x-filament::button wire:click="rejectRequest({{ $request->id }})" color="danger" size="xs" icon="heroicon-o-x-mark" onclick="return confirm('Zeker weten?')">
Afwijzen
</x-filament::button>
</div>
</div>
@endforeach
{{ $requests->links() }}
</div>
@else
<p class="text-gray-500 text-center py-8">Geen verzoeken wachtend op goedkeuring</p>
@endif
</div>
</x-filament::tabs.tab>
{{-- Applications Tab --}}
<x-filament::tabs.tab
icon="heroicon-o-user-group"
label="Aanmeldingen ({{ RadioApplication::where('status', 'pending')->count() }} wachtend)"
>
<div class="space-y-4">
@php $applications = $this->getPendingApplications(); @endphp
@if($applications->count() > 0)
<div class="space-y-2">
@foreach($applications as $app)
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<p class="font-medium">{{ $app->user?->username ?? 'Onbekend' }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ $app->rank?->name ?? 'Geen functie' }} &middot; {{ $app->age }} jaar
</p>
<p class="text-sm mt-2">{{ $app->motivation }}</p>
@if($app->experience)
<p class="text-xs text-gray-400 mt-1">Ervaring: {{ $app->experience }}</p>
@endif
<p class="text-xs text-gray-400 mt-1">{{ $app->created_at->diffForHumans() }}</p>
</div>
<div class="flex gap-2 shrink-0">
<x-filament::button wire:click="approveApplication({{ $app->id }})" color="success" size="xs" icon="heroicon-o-check" onclick="return confirm('Aanmelding goedkeuren?')">
Goedkeuren
</x-filament::button>
<x-filament::button wire:click="rejectApplication({{ $app->id }})" color="danger" size="xs" icon="heroicon-o-x-mark" onclick="return confirm('Aanmelding afwijzen?')">
Afwijzen
</x-filament::button>
</div>
</div>
@endforeach
{{ $applications->links() }}
</div>
@else
<p class="text-gray-500 text-center py-8">Geen aanmeldingen wachtend</p>
@endif
</div>
</x-filament::tabs.tab>
</x-filament::tabs>
</x-filament-panels::page>
@@ -0,0 +1,61 @@
<?php
?>
<x-filament-panels::page>
{{ $this->form }}
<div class="space-y-6 mt-6">
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Directe URL</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Gebruik deze URL om direct naar de embed player te navigeren:</p>
<div class="flex gap-2">
<input type="text" value="{{ $this->getDirectUrl() }}" readonly
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
id="directUrl" />
<button onclick="navigator.clipboard.writeText('{{ $this->getDirectUrl() }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
Kopieer
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Iframe Embed</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Voeg deze code toe aan je website om de radio player als iframe te tonen:</p>
<div class="flex gap-2">
<input type="text" value="{{ $this->getIframeCode() }}" readonly
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
id="iframeCode" />
<button onclick="navigator.clipboard.writeText('{{ $this->getIframeCode() }}').then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer', 2000); })"
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
Kopieer
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">JavaScript Embed</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Voeg deze code toe aan je website voor een dynamische embed (geeft meer controle):</p>
<div class="flex gap-2">
<textarea readonly rows="6"
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm font-mono"
id="jsCode">{{ $this->getJsSnippet() }}</textarea>
</div>
<div class="mt-3">
<button onclick="navigator.clipboard.writeText(document.getElementById('jsCode').value).then(() => { this.textContent = 'Gekopieerd!'; setTimeout(() => this.textContent = 'Kopieer JavaScript', 2000); })"
class="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-700 transition">
Kopieer JavaScript
</button>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Voorbeeld</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3">Live voorbeeld van de embed player:</p>
<div class="border border-gray-300 dark:border-gray-600 rounded-xl overflow-hidden">
<iframe src="{{ $this->getDirectUrl() }}" width="100%" height="400" frameborder="0" allow="autoplay"></iframe>
</div>
</div>
</div>
</x-filament-panels::page>