🎉 **Introduce meetup activity management**

- Added `is_active` and `last_event_at` fields to meetups with migration.
- Enhanced UI: Display `Aktiv`/`Inaktiv` badges and last event dates across dashboard, tables, and maps.
- Introduced `/meetups:update-activity` command to manage activity flags and timestamps.
- Validated latitude/longitude to prevent `0,0` inputs in city creation and updates.
- Updated factories and tests to include meetup activity states (`active`, `inactive`).
This commit is contained in:
HolgerHatGarKeineNode
2026-05-17 17:57:16 +02:00
parent bf9654de87
commit 71a4898303
16 changed files with 343 additions and 11 deletions
@@ -1,7 +1,20 @@
@props(['meetup', 'url', 'eventUrl' => null])
<div class="w-72">
<flux:heading size="lg" class="mb-3">{{ $meetup->name }}</flux:heading>
<div class="flex items-center justify-between mb-3 gap-2">
<flux:heading size="lg">{{ $meetup->name }}</flux:heading>
@if($meetup->is_active)
<flux:badge color="green" size="sm">{{ __('Aktiv') }}</flux:badge>
@else
<flux:badge color="zinc" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endif
</div>
@if($meetup->last_event_at)
<flux:text class="text-xs text-zinc-500 mb-2">
{{ __('Letztes Event') }}: {{ $meetup->last_event_at->asDate() }}
</flux:text>
@endif
@if($meetup->intro)
<flux:text class="text-sm text-zinc-600 dark:text-zinc-400 mb-3">
@@ -36,8 +36,17 @@ class extends Component {
'longitude' => ['required', 'numeric', 'between:-180,180'],
'population' => ['nullable', 'integer', 'min:0'],
'population_date' => ['nullable', 'string', 'max:255'],
], [], [
'latitude' => __('Breitengrad'),
'longitude' => __('Längengrad'),
]);
if ((float) $validated['latitude'] === 0.0 && (float) $validated['longitude'] === 0.0) {
$this->addError('latitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.'));
return;
}
$validated['slug'] = str($validated['name'])->slug();
$validated['created_by'] = auth()->id();
@@ -39,8 +39,17 @@ class extends Component {
'longitude' => ['required', 'numeric', 'between:-180,180'],
'population' => ['nullable', 'integer', 'min:0'],
'population_date' => ['nullable', 'string', 'max:255'],
], [], [
'latitude' => __('Breitengrad'),
'longitude' => __('Längengrad'),
]);
if ((float) $validated['latitude'] === 0.0 && (float) $validated['longitude'] === 0.0) {
$this->addError('latitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.'));
return;
}
$validated['slug'] = str($validated['name'])->slug();
$this->city->update($validated);
+5 -1
View File
@@ -152,11 +152,12 @@ class extends Component {
<flux:separator class="my-4"/>
<div class="space-y-3">
@foreach($myMeetups as $meetup)
<div class="flex flex-col sm:flex-row items-start justify-between gap-3">
<div class="flex flex-col sm:flex-row items-start justify-between gap-3 {{ $meetup->is_active ? '' : 'opacity-60' }}">
<div class="flex items-center gap-3 flex-1">
<flux:avatar
:href="route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country])"
size="sm"
class="{{ $meetup->is_active ? '' : 'grayscale' }}"
src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<a href="{{ route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country]) }}">
<div>
@@ -167,6 +168,9 @@ class extends Component {
src="{{ asset('vendor/blade-flags/country-'.strtolower($meetup->city->country->code).'.svg') }}"
width="24" height="12"
/>
@unless($meetup->is_active)
<flux:badge color="zinc" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endunless
</div>
<div class="text-xs text-zinc-500">
{{ $meetup->city->name }}
@@ -64,14 +64,18 @@ class extends Component {
@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">
class="block p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors {{ $meetup->is_active ? '' : 'opacity-60' }}">
<div class="flex items-start gap-3">
<flux:avatar
size="sm"
class="{{ $meetup->is_active ? '' : 'grayscale' }}"
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>
@unless($meetup->is_active)
<flux:badge color="zinc" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endunless
<img
alt="{{ strtolower($meetup->city->country->code) }}"
src="{{ asset('vendor/blade-flags/country-'.strtolower($meetup->city->country->code).'.svg') }}"
@@ -87,13 +87,19 @@ class extends Component {
<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">
class="flex items-center justify-between gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors block {{ $meetup->is_active ? '' : 'opacity-60' }}">
<div class="flex items-center gap-3 flex-1">
<flux:avatar
size="sm"
class="{{ $meetup->is_active ? '' : 'grayscale' }}"
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="font-medium flex items-center gap-2">
<span>{{ $meetup->name }}</span>
@unless($meetup->is_active)
<flux:badge color="zinc" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endunless
</div>
<div class="flex items-center space-x-2">
<div class="text-xs text-zinc-500">{{ $meetup->users_count }} {{ __('User') }}</div>
<img
@@ -47,10 +47,19 @@ class extends Component {
$validated = $this->validate([
'newCityName' => ['required', 'string', 'max:255', 'unique:cities,name'],
'newCityCountryId' => ['required', 'exists:countries,id'],
'newCityLatitude' => ['required', 'numeric'],
'newCityLongitude' => ['required', 'numeric'],
'newCityLatitude' => ['required', 'numeric', 'between:-90,90'],
'newCityLongitude' => ['required', 'numeric', 'between:-180,180'],
], [], [
'newCityLatitude' => __('Breitengrad'),
'newCityLongitude' => __('Längengrad'),
]);
if ((float) $validated['newCityLatitude'] === 0.0 && (float) $validated['newCityLongitude'] === 0.0) {
$this->addError('newCityLatitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.'));
return;
}
$city = City::create([
'name' => $validated['newCityName'],
'country_id' => $validated['newCityCountryId'],
@@ -73,6 +73,7 @@ class extends Component {
<flux:table.columns>
<flux:table.column>{{ __('Name') }}
</flux:table.column>
<flux:table.column>{{ __('Aktivität') }}</flux:table.column>
<flux:table.column>{{ __('Nächster Termin') }}</flux:table.column>
<flux:table.column>{{ __('Links') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
@@ -80,10 +81,10 @@ class extends Component {
<flux:table.rows>
@foreach ($meetups as $meetup)
<flux:table.row :key="$meetup->id">
<flux:table.row :key="$meetup->id" :class="$meetup->is_active ? '' : 'opacity-60'">
<flux:table.cell variant="strong" class="flex items-center gap-3">
<flux:avatar
class="[:where(&)]:size-24 [:where(&)]:text-base" size="xl"
class="[:where(&)]:size-24 [:where(&)]:text-base {{ $meetup->is_active ? '' : 'grayscale' }}" size="xl"
:href="route('meetups.landingpage', ['meetup' => $meetup, 'country' => $country])"
src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div>
@@ -102,6 +103,23 @@ class extends Component {
</div>
</flux:table.cell>
<flux:table.cell>
<div class="flex flex-col gap-1">
@if($meetup->is_active)
<flux:badge color="green" size="sm">{{ __('Aktiv') }}</flux:badge>
@else
<flux:badge color="zinc" size="sm">{{ __('Inaktiv') }}</flux:badge>
@endif
@if($meetup->last_event_at)
<span class="text-xs text-zinc-500">
{{ __('Letztes Event') }}: {{ $meetup->last_event_at->asDate() }}
</span>
@else
<span class="text-xs text-zinc-500">{{ __('Noch kein Event') }}</span>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
@if($meetup->nextEvent && $meetup->nextEvent['start']->isFuture())
<a href="{{ route('meetups.landingpage-event', ['meetup' => $meetup, 'event' => $meetup->nextEvent['id'], 'country' => $country]) }}">
+26 -2
View File
@@ -49,6 +49,8 @@ class extends Component {
'meetups.nostr',
'meetups.simplex',
'meetups.signal',
'meetups.is_active',
'meetups.last_event_at',
])
->with(['city:id,country_id,longitude,latitude', 'city.country'])
->when(
@@ -81,6 +83,7 @@ class extends Component {
'name' => $meetup->name,
'slug' => $meetup->slug,
'city' => $meetup->city,
'is_active' => (bool) $meetup->is_active,
'popupHtml' => view('components.meetup-popup', [
'meetup' => $meetup,
'url' => route('meetups.landingpage', [
@@ -105,6 +108,18 @@ class extends Component {
#map:focus {
outline: none;
}
.meetup-marker-inactive {
background: transparent;
border: none;
}
.meetup-marker-inactive img {
width: 100%;
height: 100%;
filter: grayscale(100%);
opacity: 0.55;
}
</style>
@php
$attribution = '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
@@ -127,7 +142,7 @@ class extends Component {
attribution: '{{ $attribution }}'
}).addTo(map);
// Custom BTC icon
// Custom BTC icon (active meetups)
const btcIcon = L.icon({
iconUrl: '/img/btc_marker.png',
iconSize: [32, 32], // Full size of the image
@@ -136,9 +151,18 @@ class extends Component {
shadowUrl: null // No shadow for simplicity
});
// Inactive meetups: smaller, grayscaled, semi-transparent
const btcIconInactive = L.divIcon({
className: 'meetup-marker-inactive',
html: `<img src='/img/btc_marker.png' alt='' />`,
iconSize: [20, 20],
iconAnchor: [10, 20],
popupAnchor: [0, -20],
});
this.markers.forEach(marker => {
L.marker([marker.city.latitude, marker.city.longitude], {
icon: btcIcon
icon: marker.is_active ? btcIcon : btcIconInactive
})
.bindPopup(marker.popupHtml)
.addTo(map);