🌍 Add Top-Countries, Top-Meetups, and Activities dashboard components

- Introduced three new Livewire components for the dashboard:
  - Top Countries: Displays countries with the most users.
  - Top Meetups: Highlights meetups with the largest user counts.
  - Activities: Showcases recent meetups and events.
- Updated `dashboard.blade.php` to lazy-load these components.
- Expanded multilingual support for relevant labels across all languages.
This commit is contained in:
HolgerHatGarKeineNode
2025-11-24 16:49:53 +01:00
parent 4a101c22a5
commit 54f79309dc
10 changed files with 423 additions and 6 deletions

View File

@@ -460,5 +460,16 @@
"Über den Kurs": "",
"Über uns": "",
"Alle Meetups": "",
"Welt-Karte": ""
"Welt-Karte": "",
"Aktivitäten": "",
"Neue Meetups und Termine": "",
"Neues Meetup": "",
"Neuer Termin": "",
"Keine Aktivitäten": "",
"Top Länder": "",
"Länder mit den meisten Usern": "",
"User": "",
"Keine Daten verfügbar": "",
"Top Meetups": "",
"Meetups mit den meisten Usern": ""
}

View File

@@ -460,5 +460,16 @@
"Über den Kurs": "About the course",
"Über uns": "About us",
"Alle Meetups": "All Meetups",
"Welt-Karte": "World Map"
"Welt-Karte": "World Map",
"Aktivitäten": "Activities",
"Neue Meetups und Termine": "New Meetups and Events",
"Neues Meetup": "New Meetup",
"Neuer Termin": "New Event",
"Keine Aktivitäten": "No Activities",
"Top Länder": "Top Countries",
"Länder mit den meisten Usern": "Countries with Most Users",
"User": "Users",
"Keine Daten verfügbar": "No Data Available",
"Top Meetups": "Top Meetups",
"Meetups mit den meisten Usern": "Meetups with Most Users"
}

View File

@@ -459,5 +459,16 @@
"Über den Kurs": "Sobre el curso",
"Über uns": "Sobre nosotros",
"Alle Meetups": "Todos los encuentros",
"Welt-Karte": "Mapa mundial"
"Welt-Karte": "Mapa mundial",
"Aktivitäten": "Actividades",
"Neue Meetups und Termine": "Nuevos encuentros y eventos",
"Neues Meetup": "Nuevo encuentro",
"Neuer Termin": "Nuevo evento",
"Keine Aktivitäten": "Sin actividades",
"Top Länder": "Países principales",
"Länder mit den meisten Usern": "Países con más usuarios",
"User": "Usuarios",
"Keine Daten verfügbar": "No hay datos disponibles",
"Top Meetups": "Encuentros principales",
"Meetups mit den meisten Usern": "Encuentros con más usuarios"
}

View File

@@ -454,5 +454,16 @@
"Über den Kurs": "A kurzusról",
"Über uns": "Rólunk",
"Alle Meetups": "Minden meetup",
"Welt-Karte": "Világtérkép"
"Welt-Karte": "Világtérkép",
"Aktivitäten": "Tevékenységek",
"Neue Meetups und Termine": "Új meetupok és időpontok",
"Neues Meetup": "Új meetup",
"Neuer Termin": "Új időpont",
"Keine Aktivitäten": "Nincsenek tevékenységek",
"Top Länder": "Top országok",
"Länder mit den meisten Usern": "Legtöbb felhasználóval rendelkező országok",
"User": "Felhasználó",
"Keine Daten verfügbar": "Nincsenek elérhető adatok",
"Top Meetups": "Top meetupok",
"Meetups mit den meisten Usern": "Legtöbb felhasználóval rendelkező meetupok"
}

View File

@@ -456,5 +456,16 @@
"Über den Kurs": "Over de cursus",
"Über uns": "Over ons",
"Alle Meetups": "Alle Meetups",
"Welt-Karte": "Wereldkaart"
"Welt-Karte": "Wereldkaart",
"Aktivitäten": "Activiteiten",
"Neue Meetups und Termine": "Nieuwe Meetups en afspraken",
"Neues Meetup": "Nieuwe Meetup",
"Neuer Termin": "Nieuwe afspraak",
"Keine Aktivitäten": "Geen activiteiten",
"Top Länder": "Top landen",
"Länder mit den meisten Usern": "Landen met de meeste gebruikers",
"User": "Gebruikers",
"Keine Daten verfügbar": "Geen gegevens beschikbaar",
"Top Meetups": "Top Meetups",
"Meetups mit den meisten Usern": "Meetups met de meeste gebruikers"
}

View File

@@ -454,5 +454,16 @@
"Über den Kurs": "Sobre o curso",
"Über uns": "Sobre nós",
"Alle Meetups": "Todos os Meetups",
"Welt-Karte": "Mapa Mundial"
"Welt-Karte": "Mapa Mundial",
"Aktivitäten": "Atividades",
"Neue Meetups und Termine": "Novos Meetups e eventos",
"Neues Meetup": "Novo Meetup",
"Neuer Termin": "Novo evento",
"Keine Aktivitäten": "Nenhuma atividade",
"Top Länder": "Principais países",
"Länder mit den meisten Usern": "Países com mais usuários",
"User": "Usuário",
"Keine Daten verfügbar": "Nenhum dado disponível",
"Top Meetups": "Principais Meetups",
"Meetups mit den meisten Usern": "Meetups com mais usuários"
}

View File

@@ -204,4 +204,11 @@ class extends Component {
</div>
</div>
</div>
{{-- Neue Statistiken und Activities (Lazy loaded) --}}
<div class="grid auto-rows-min gap-4 grid-cols-1 md:grid-cols-2 2xl:grid-cols-3">
<livewire:dashboard.top-countries lazy/>
<livewire:dashboard.top-meetups lazy/>
<livewire:dashboard.activities lazy/>
</div>
</div>

View File

@@ -0,0 +1,114 @@
<?php
use App\Models\Meetup;
use App\Models\MeetupEvent;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
new
#[Lazy]
class extends Component {
public function with(): array
{
// Recent Activities - Neue Meetups und Events
$recentMeetups = Meetup::with(['city.country'])
->orderBy('created_at', 'desc')
->limit(5)
->get();
$recentEvents = MeetupEvent::with(['meetup.city.country'])
->orderBy('created_at', 'desc')
->limit(5)
->get();
// Kombiniere und sortiere Activities
$activities = collect($recentMeetups->map(fn($m) => ['type' => 'meetup', 'data' => $m, 'created_at' => $m->created_at]))
->merge($recentEvents->map(fn($e) => ['type' => 'event', 'data' => $e, 'created_at' => $e->created_at]))
->sortByDesc('created_at')
->take(10);
return [
'activities' => $activities,
];
}
public function placeholder(): string
{
return <<<'HTML'
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Aktivitäten') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Neue Meetups und Termine') }}</flux:text>
<flux:separator class="my-4"/>
<div class="flex items-center justify-center py-8">
<flux:icon.arrow-path class="animate-spin size-6 text-zinc-400" />
</div>
</div>
</div>
HTML;
}
}; ?>
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Aktivitäten') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Neue Meetups und Termine') }}</flux:text>
@if($activities->count() > 0)
<flux:separator class="my-4"/>
<div class="space-y-3">
@foreach($activities as $activity)
@if($activity['type'] === 'meetup')
@php $meetup = $activity['data']; @endphp
<a href="{{ route('meetups.landingpage', ['meetup' => $meetup, 'country' => $meetup->city->country->code]) }}"
class="block p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors">
<div class="flex items-start gap-3">
<flux:avatar
size="sm"
src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<flux:badge color="green" size="sm">{{ __('Neues Meetup') }}</flux:badge>
<span class="text-2xl">{{ $meetup->city->country->emoji }}</span>
</div>
<div class="font-medium mt-1">{{ $meetup->name }}</div>
<div class="text-xs text-zinc-500">
{{ $meetup->city->name }}, {{ $meetup->city->country->name }}
</div>
<div class="text-xs text-zinc-400 mt-1">
{{ $activity['created_at']->diffForHumans() }}
</div>
</div>
</div>
</a>
@else
@php $event = $activity['data']; @endphp
<a href="{{ route('meetups.landingpage-event', ['meetup' => $event->meetup->slug, 'event' => $event->id, 'country' => $event->meetup->city->country->code]) }}"
class="block p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors">
<div class="flex items-start gap-3">
<flux:avatar
size="sm"
src="{{ $event->meetup->getFirstMedia('logo') ? $event->meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<flux:badge color="blue" size="sm">{{ __('Neuer Termin') }}</flux:badge>
<span class="text-2xl">{{ $event->meetup->city->country->emoji }}</span>
</div>
<div class="font-medium mt-1">{{ $event->meetup->name }}</div>
<div class="text-xs text-zinc-500">
{{ $event->start->asDateTime() }}
</div>
<div class="text-xs text-zinc-400 mt-1">
{{ $activity['created_at']->diffForHumans() }}
</div>
</div>
</div>
</a>
@endif
@endforeach
</div>
@else
<div class="text-sm text-zinc-500">{{ __('Keine Aktivitäten') }}</div>
@endif
</div>
</div>

View File

@@ -0,0 +1,118 @@
<?php
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
new
#[Lazy]
class extends Component {
public function with(): array
{
// Top Countries - Länder mit den meisten Usern
$topCountries = \App\Models\Country::select('countries.*')
->join('cities', 'cities.country_id', '=', 'countries.id')
->join('meetups', 'meetups.city_id', '=', 'cities.id')
->join('meetup_user', 'meetup_user.meetup_id', '=', 'meetups.id')
->groupBy('countries.id')
->selectRaw('COUNT(DISTINCT meetup_user.user_id) as user_count')
->orderBy('user_count', 'desc')
->limit(10)
->get()
->map(function ($country) {
// Optimierte Query: Hole alle User-Erstellungsdaten für dieses Land auf einmal
$userCreationDates = \DB::table('users')
->join('meetup_user', 'users.id', '=', 'meetup_user.user_id')
->join('meetups', 'meetup_user.meetup_id', '=', 'meetups.id')
->join('cities', 'meetups.city_id', '=', 'cities.id')
->where('cities.country_id', $country->id)
->whereNotNull('users.created_at')
->orderBy('users.created_at')
->pluck('users.created_at')
->unique()
->values();
if ($userCreationDates->isEmpty()) {
$country->sparkline = [0];
return $country;
}
// Berechne monatliche Buckets für kumulative Zählung
$startDate = \Carbon\Carbon::parse($userCreationDates->first())->startOfMonth();
$endDate = now()->endOfMonth();
$monthsDiff = max(1, $startDate->diffInMonths($endDate));
$interval = max(1, ceil($monthsDiff / 12));
// Generiere 12 Zeitpunkte
$sparklineData = [];
$currentDate = $startDate->copy();
for ($i = 0; $i < 12 && $currentDate <= $endDate; $i++) {
// Zähle kumulative User bis zu diesem Zeitpunkt
$count = $userCreationDates->filter(function ($date) use ($currentDate) {
return \Carbon\Carbon::parse($date) <= $currentDate;
})->count();
$sparklineData[] = $count;
$currentDate->addMonths($interval);
}
$country->sparkline = $sparklineData;
return $country;
});
return [
'topCountries' => $topCountries,
];
}
public function placeholder(): string
{
return <<<'HTML'
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Top Länder') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Länder mit den meisten Usern') }}</flux:text>
<flux:separator class="my-4"/>
<div class="flex items-center justify-center py-8">
<flux:icon.arrow-path class="animate-spin size-6 text-zinc-400" />
</div>
</div>
</div>
HTML;
}
}; ?>
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Top Länder') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Länder mit den meisten Usern') }}</flux:text>
@if($topCountries->count() > 0)
<flux:separator class="my-4"/>
<div class="space-y-3">
@foreach($topCountries as $country)
<div class="flex items-center justify-between gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors">
<a href="{{ route('meetups.map', ['country' => $country->code]) }}">
<div class="flex items-center gap-3 flex-1">
<img alt="{{ $country->code }}"
src="{{ asset('vendor/blade-flags/country-'.$country->code.'.svg') }}"
width="24" height="12"/>
<div class="flex-1">
<div class="font-medium">{{ $country->name }}</div>
<div class="text-xs text-zinc-500">{{ $country->user_count }} {{ __('User') }}</div>
</div>
</div>
</a>
<flux:chart :value="$country->sparkline" class="w-[5rem] aspect-[3/1]">
<flux:chart.svg gutter="0">
<flux:chart.line class="text-green-500 dark:text-green-400" />
</flux:chart.svg>
</flux:chart>
</div>
@endforeach
</div>
@else
<div class="text-sm text-zinc-500">{{ __('Keine Daten verfügbar') }}</div>
@endif
</div>
</div>

View File

@@ -0,0 +1,112 @@
<?php
use App\Models\Meetup;
use Livewire\Attributes\Lazy;
use Livewire\Volt\Component;
new
#[Lazy]
class extends Component {
public function with(): array
{
// Top Meetups - Meetups mit den meisten Usern
$topMeetups = Meetup::withCount('users')
->with(['city.country'])
->orderBy('users_count', 'desc')
->limit(10)
->get()
->map(function ($meetup) {
// Optimierte Query: Hole alle User-Erstellungsdaten für dieses Meetup auf einmal
$userCreationDates = \DB::table('users')
->join('meetup_user', 'users.id', '=', 'meetup_user.user_id')
->where('meetup_user.meetup_id', $meetup->id)
->whereNotNull('users.created_at')
->orderBy('users.created_at')
->pluck('users.created_at')
->unique()
->values();
if ($userCreationDates->isEmpty()) {
$meetup->sparkline = [0];
return $meetup;
}
// Berechne monatliche Buckets für kumulative Zählung
$startDate = \Carbon\Carbon::parse($userCreationDates->first())->startOfMonth();
$endDate = now()->endOfMonth();
$monthsDiff = max(1, $startDate->diffInMonths($endDate));
$interval = max(1, ceil($monthsDiff / 12));
// Generiere 12 Zeitpunkte
$sparklineData = [];
$currentDate = $startDate->copy();
for ($i = 0; $i < 12 && $currentDate <= $endDate; $i++) {
// Zähle kumulative User bis zu diesem Zeitpunkt
$count = $userCreationDates->filter(function ($date) use ($currentDate) {
return \Carbon\Carbon::parse($date) <= $currentDate;
})->count();
$sparklineData[] = $count;
$currentDate->addMonths($interval);
}
$meetup->sparkline = $sparklineData;
return $meetup;
});
return [
'topMeetups' => $topMeetups,
];
}
public function placeholder(): string
{
return <<<'HTML'
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Top Meetups') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Meetups mit den meisten Usern') }}</flux:text>
<flux:separator class="my-4"/>
<div class="flex items-center justify-center py-8">
<flux:icon.arrow-path class="animate-spin size-6 text-zinc-400" />
</div>
</div>
</div>
HTML;
}
}; ?>
<div class="relative overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
<div class="p-6">
<flux:heading size="lg" class="mb-4">{{ __('Top Meetups') }}</flux:heading>
<flux:text class="text-sm text-zinc-500 mb-4">{{ __('Meetups mit den meisten Usern') }}</flux:text>
@if($topMeetups->count() > 0)
<flux:separator class="my-4"/>
<div class="space-y-3">
@foreach($topMeetups as $meetup)
<a href="{{ route('meetups.landingpage', ['meetup' => $meetup, 'country' => $meetup->city->country->code]) }}"
class="flex items-center justify-between gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors block">
<div class="flex items-center gap-3 flex-1">
<flux:avatar
size="sm"
src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div class="flex-1">
<div class="font-medium">{{ $meetup->name }}</div>
<div class="text-xs text-zinc-500">{{ $meetup->users_count }} {{ __('User') }}</div>
</div>
</div>
<flux:chart :value="$meetup->sparkline" class="w-[5rem] aspect-[3/1]">
<flux:chart.svg gutter="0">
<flux:chart.line class="text-green-500 dark:text-green-400" />
</flux:chart.svg>
</flux:chart>
</a>
@endforeach
</div>
@else
<div class="text-sm text-zinc-500">{{ __('Keine Daten verfügbar') }}</div>
@endif
</div>
</div>