mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2025-12-14 12:06:46 +00:00
- 🛠️ Replaced inline dashboard layout with Livewire component for better reusability and management.
- 🔒 Introduced Nostr-based login functionality with `nostr-tools` integration. - 🖼️ Added user profile photo handling (upload, delete, and URL retrieval) in the `User` model. - 💻 Updated views to use `flux:avatar` for consistent user avatars. - ✂️ Removed unused routes and adjusted dashboard routing logic. - 📦 Updated dependencies in `package.json` and `yarn.lock`.
This commit is contained in:
@@ -63,7 +63,8 @@ class Meetup extends Model implements HasMedia
|
|||||||
->addMediaConversion('preview')
|
->addMediaConversion('preview')
|
||||||
->fit(Fit::Crop, 300, 300)
|
->fit(Fit::Crop, 300, 300)
|
||||||
->nonQueued();
|
->nonQueued();
|
||||||
$this->addMediaConversion('thumb')
|
$this
|
||||||
|
->addMediaConversion('thumb')
|
||||||
->fit(Fit::Crop, 130, 130)
|
->fit(Fit::Crop, 130, 130)
|
||||||
->width(130)
|
->width(130)
|
||||||
->height(130);
|
->height(130);
|
||||||
@@ -71,7 +72,8 @@ class Meetup extends Model implements HasMedia
|
|||||||
|
|
||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('logo')
|
$this
|
||||||
|
->addMediaCollection('logo')
|
||||||
->singleFile()
|
->singleFile()
|
||||||
->useFallbackUrl(asset('img/einundzwanzig.png'));
|
->useFallbackUrl(asset('img/einundzwanzig.png'));
|
||||||
}
|
}
|
||||||
@@ -101,7 +103,8 @@ class Meetup extends Model implements HasMedia
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn() => url()->route('img',
|
get: fn()
|
||||||
|
=> url()->route('img',
|
||||||
[
|
[
|
||||||
'path' => $path,
|
'path' => $path,
|
||||||
'w' => 900,
|
'w' => 900,
|
||||||
@@ -117,9 +120,11 @@ class Meetup extends Model implements HasMedia
|
|||||||
$nextEvent = $this->meetupEvents()->where('start', '>=', now())->orderBy('start')->first();
|
$nextEvent = $this->meetupEvents()->where('start', '>=', now())->orderBy('start')->first();
|
||||||
|
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn() => $nextEvent ? [
|
get: fn()
|
||||||
|
=> $nextEvent ? [
|
||||||
'start' => $nextEvent->start,
|
'start' => $nextEvent->start,
|
||||||
'portalLink' => url()->route('meetups.landingpage-event', ['country' => $this->city->country, 'meetup' => $this, 'event' => $nextEvent]),
|
'portalLink' => url()->route('meetups.landingpage-event',
|
||||||
|
['country' => $this->city->country, 'meetup' => $this, 'event' => $nextEvent]),
|
||||||
'location' => $nextEvent->location,
|
'location' => $nextEvent->location,
|
||||||
'description' => $nextEvent->description,
|
'description' => $nextEvent->description,
|
||||||
'link' => $nextEvent->link,
|
'link' => $nextEvent->link,
|
||||||
@@ -130,6 +135,13 @@ class Meetup extends Model implements HasMedia
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function belongsToMe(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function meetupEvents(): HasMany
|
public function meetupEvents(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(MeetupEvent::class);
|
return $this->hasMany(MeetupEvent::class);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ namespace App\Models;
|
|||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use ParagonIE\CipherSweet\BlindIndex;
|
use ParagonIE\CipherSweet\BlindIndex;
|
||||||
use ParagonIE\CipherSweet\EncryptedRow;
|
use ParagonIE\CipherSweet\EncryptedRow;
|
||||||
@@ -14,7 +16,7 @@ use Spatie\LaravelCipherSweet\Concerns\UsesCipherSweet;
|
|||||||
use Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted;
|
use Spatie\LaravelCipherSweet\Contracts\CipherSweetEncrypted;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail, CipherSweetEncrypted
|
class User extends Authenticatable implements CipherSweetEncrypted
|
||||||
{
|
{
|
||||||
use UsesCipherSweet;
|
use UsesCipherSweet;
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
@@ -101,4 +103,73 @@ class User extends Authenticatable implements MustVerifyEmail, CipherSweetEncryp
|
|||||||
{
|
{
|
||||||
return $this->belongsToMany(LibraryItem::class, 'library_item_user', 'user_id', 'library_item_id');
|
return $this->belongsToMany(LibraryItem::class, 'library_item_user', 'user_id', 'library_item_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updateProfilePhoto(UploadedFile $photo)
|
||||||
|
{
|
||||||
|
tap($this->profile_photo_path, function ($previous) use ($photo) {
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => $photo->storePublicly(
|
||||||
|
'profile-photos', ['disk' => $this->profilePhotoDisk()]
|
||||||
|
),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
if ($previous) {
|
||||||
|
Storage::disk($this->profilePhotoDisk())->delete($previous);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's profile photo.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function deleteProfilePhoto()
|
||||||
|
{
|
||||||
|
if (is_null($this->profile_photo_path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Storage::disk($this->profilePhotoDisk())->delete($this->profile_photo_path);
|
||||||
|
|
||||||
|
$this->forceFill([
|
||||||
|
'profile_photo_path' => null,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the URL to the user's profile photo.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getProfilePhotoUrlAttribute()
|
||||||
|
{
|
||||||
|
return $this->profile_photo_path
|
||||||
|
? Storage::disk($this->profilePhotoDisk())->url($this->profile_photo_path)
|
||||||
|
: $this->defaultProfilePhotoUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default profile photo URL if no profile photo has been uploaded.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function defaultProfilePhotoUrl()
|
||||||
|
{
|
||||||
|
$name = trim(collect(explode(' ', $this->name))->map(function ($segment) {
|
||||||
|
return mb_substr($segment, 0, 1);
|
||||||
|
})->join(' '));
|
||||||
|
|
||||||
|
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the disk that profile photos should be stored on.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function profilePhotoDisk()
|
||||||
|
{
|
||||||
|
return isset($_ENV['VAPOR_ARTIFACT_NAME']) ? 's3' : config('jetstream.profile_photo_disk', 'public');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"axios": "^1.7.4",
|
"axios": "^1.7.4",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^1.0",
|
"laravel-vite-plugin": "^1.0",
|
||||||
|
"nostr-tools": "^2.17.4",
|
||||||
"shiki": "^3.15.0",
|
"shiki": "^3.15.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^6.0"
|
"vite": "^6.0"
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import nostrLogin from "./nostrLogin.js";
|
||||||
|
|
||||||
|
Alpine.data('nostrLogin', nostrLogin);
|
||||||
|
|||||||
17
resources/js/nostrLogin.js
Normal file
17
resources/js/nostrLogin.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {npubEncode} from "nostr-tools/nip19";
|
||||||
|
|
||||||
|
export default () => ({
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
async openNostrLogin() {
|
||||||
|
const pubkey = await window.nostr.getPublicKey();
|
||||||
|
const npub = npubEncode(pubkey);
|
||||||
|
console.log(pubkey);
|
||||||
|
console.log(npub);
|
||||||
|
this.$dispatch('nostrLoggedIn', {pubkey: npub});
|
||||||
|
},
|
||||||
|
|
||||||
|
});
|
||||||
@@ -155,5 +155,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/window.nostr.js/dist/window.nostr.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
<flux:dropdown class="hidden lg:block" position="bottom" align="start">
|
<flux:dropdown class="hidden lg:block" position="bottom" align="start">
|
||||||
<flux:profile
|
<flux:profile
|
||||||
:name="auth()->user()->name"
|
:name="auth()->user()->name"
|
||||||
|
:avatar="auth()->user()->profile_photo_url"
|
||||||
:initials="auth()->user()->initials()"
|
:initials="auth()->user()->initials()"
|
||||||
icon:trailing="chevrons-up-down"
|
icon:trailing="chevrons-up-down"
|
||||||
/>
|
/>
|
||||||
@@ -66,13 +67,7 @@
|
|||||||
<flux:menu.radio.group>
|
<flux:menu.radio.group>
|
||||||
<div class="p-0 text-sm font-normal">
|
<div class="p-0 text-sm font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
<flux:avatar :src="auth()->user()->profile_photo_url" size="sm" class="shrink-0" />
|
||||||
<span
|
|
||||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
|
||||||
>
|
|
||||||
{{ auth()->user()->initials() }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||||
@@ -124,6 +119,7 @@
|
|||||||
|
|
||||||
<flux:dropdown position="top" align="end">
|
<flux:dropdown position="top" align="end">
|
||||||
<flux:profile
|
<flux:profile
|
||||||
|
:avatar="auth()->user()->profile_photo_url"
|
||||||
:initials="auth()->user()->initials()"
|
:initials="auth()->user()->initials()"
|
||||||
icon-trailing="chevron-down"
|
icon-trailing="chevron-down"
|
||||||
/>
|
/>
|
||||||
@@ -132,13 +128,7 @@
|
|||||||
<flux:menu.radio.group>
|
<flux:menu.radio.group>
|
||||||
<div class="p-0 text-sm font-normal">
|
<div class="p-0 text-sm font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||||
<span class="relative flex h-8 w-8 shrink-0 overflow-hidden rounded-lg">
|
<flux:avatar :src="auth()->user()->profile_photo_url" size="sm" class="shrink-0" />
|
||||||
<span
|
|
||||||
class="flex h-full w-full items-center justify-center rounded-lg bg-neutral-200 text-black dark:bg-neutral-700 dark:text-white"
|
|
||||||
>
|
|
||||||
{{ auth()->user()->initials() }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
<span class="truncate font-semibold">{{ auth()->user()->name }}</span>
|
||||||
@@ -202,5 +192,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/window.nostr.js/dist/window.nostr.min.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<x-layouts.app :title="__('Dashboard')">
|
|
||||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
|
||||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
|
||||||
<div class="p-12">
|
|
||||||
Meine nächsten Meetup Termine
|
|
||||||
</div>
|
|
||||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
|
||||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700"><div class="p-12">
|
|
||||||
Nächstes Bitcoin Event
|
|
||||||
</div>
|
|
||||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
|
||||||
<div class="relative aspect-video overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700"><div class="p-12">
|
|
||||||
Meine Einundzwanzig Meetups
|
|
||||||
</div>
|
|
||||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{--<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
|
||||||
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
|
||||||
</div>--}}
|
|
||||||
</div>
|
|
||||||
</x-layouts.app>
|
|
||||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Session;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Livewire\Attributes\Layout;
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Flux\Flux;
|
use Flux\Flux;
|
||||||
@@ -22,18 +23,16 @@ class extends Component {
|
|||||||
|
|
||||||
public bool $remember = false;
|
public bool $remember = false;
|
||||||
|
|
||||||
/**
|
#[On('nostrLoggedIn')]
|
||||||
* Handle an incoming authentication request.
|
public function loginListener($pubkey): void
|
||||||
*/
|
|
||||||
public function login(): void
|
|
||||||
{
|
{
|
||||||
if (app()->environment('production')) {
|
$user = \App\Models\User::query()->where('nostr', $pubkey)->first();
|
||||||
Flux::toast(text: 'Login work in progress', variant: 'danger');
|
if ($user) {
|
||||||
|
Auth::loginUsingId($user->id);
|
||||||
|
Session::regenerate();
|
||||||
|
$this->redirectIntended(default: route_with_country('dashboard', absolute: false), navigate: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Auth::loginUsingId(1, true);
|
|
||||||
Session::regenerate();
|
|
||||||
$this->redirectIntended(default: route_with_country('dashboard', absolute: false), navigate: true);
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
$this->validate();
|
$this->validate();
|
||||||
@@ -84,14 +83,15 @@ class extends Component {
|
|||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="flex min-h-screen">
|
<div class="flex min-h-screen" x-data="nostrLogin">
|
||||||
<div class="flex-1 flex justify-center items-center">
|
<div class="flex-1 flex justify-center items-center">
|
||||||
<div class="w-80 max-w-80 space-y-6">
|
<div class="w-80 max-w-80 space-y-6">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<a href="/" class="group flex items-center gap-3">
|
<a href="/" class="group flex items-center gap-3">
|
||||||
<div>
|
<div>
|
||||||
<flux:avatar class="[:where(&)]:size-32 [:where(&)]:text-base" size="xl" src="{{ asset('img/einundzwanzig-square.svg') }}" />
|
<flux:avatar class="[:where(&)]:size-32 [:where(&)]:text-base" size="xl"
|
||||||
|
src="{{ asset('img/einundzwanzig-square.svg') }}"/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,10 +134,10 @@ class extends Component {
|
|||||||
{{--<flux:separator text="or" />--}}
|
{{--<flux:separator text="or" />--}}
|
||||||
|
|
||||||
<!-- Session Status -->
|
<!-- Session Status -->
|
||||||
<x-auth-session-status class="text-center" :status="session('status')" />
|
<x-auth-session-status class="text-center" :status="session('status')"/>
|
||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form wire:submit="login" class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<!-- Email Input -->
|
<!-- Email Input -->
|
||||||
{{--<flux:input
|
{{--<flux:input
|
||||||
wire:model="email"
|
wire:model="email"
|
||||||
@@ -172,8 +172,8 @@ class extends Component {
|
|||||||
{{--<flux:checkbox wire:model="remember" label="Remember me for 30 days" />--}}
|
{{--<flux:checkbox wire:model="remember" label="Remember me for 30 days" />--}}
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<flux:button variant="primary" type="submit" class="w-full">Log in</flux:button>
|
<flux:button class="cursor-pointer" variant="primary" @click="openNostrLogin" class="w-full">{{ __('Log in mit Nostr') }}</flux:button>
|
||||||
</form>
|
</div>
|
||||||
|
|
||||||
<!-- Sign up Link -->
|
<!-- Sign up Link -->
|
||||||
{{--@if (Route::has('register'))
|
{{--@if (Route::has('register'))
|
||||||
@@ -186,7 +186,8 @@ class extends Component {
|
|||||||
|
|
||||||
<!-- Right Side Panel -->
|
<!-- Right Side Panel -->
|
||||||
<div class="flex-1 p-4 max-lg:hidden">
|
<div class="flex-1 p-4 max-lg:hidden">
|
||||||
<div class="text-white relative rounded-lg h-full w-full bg-zinc-900 flex flex-col items-start justify-end p-16" style="background-image: url('https://dergigi.com/assets/images/bitcoin-is-time.jpg'); background-size: cover">
|
<div class="text-white relative rounded-lg h-full w-full bg-zinc-900 flex flex-col items-start justify-end p-16"
|
||||||
|
style="background-image: url('https://dergigi.com/assets/images/bitcoin-is-time.jpg'); background-size: cover">
|
||||||
|
|
||||||
<!-- Testimonial -->
|
<!-- Testimonial -->
|
||||||
<div class="mb-6 italic font-base text-3xl xl:text-4xl">
|
<div class="mb-6 italic font-base text-3xl xl:text-4xl">
|
||||||
@@ -195,7 +196,7 @@ class extends Component {
|
|||||||
|
|
||||||
<!-- Author Info -->
|
<!-- Author Info -->
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<flux:avatar src="https://dergigi.com/assets/images/avatar.jpg" size="xl" />
|
<flux:avatar src="https://dergigi.com/assets/images/avatar.jpg" size="xl"/>
|
||||||
<div class="flex flex-col justify-center font-medium">
|
<div class="flex flex-col justify-center font-medium">
|
||||||
<div class="text-lg">Gigi</div>
|
<div class="text-lg">Gigi</div>
|
||||||
<div class="text-zinc-300">bitcoiner and software engineer</div>
|
<div class="text-zinc-300">bitcoiner and software engineer</div>
|
||||||
|
|||||||
174
resources/views/livewire/dashboard.blade.php
Normal file
174
resources/views/livewire/dashboard.blade.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Meetup;
|
||||||
|
use App\Models\MeetupEvent;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Flux\Flux;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public $selectedMeetupId = null;
|
||||||
|
|
||||||
|
public function addMeetup()
|
||||||
|
{
|
||||||
|
if ($this->selectedMeetupId) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// Prüfen ob bereits zugeordnet
|
||||||
|
if (!$user->meetups()->where('meetup_id', $this->selectedMeetupId)->exists()) {
|
||||||
|
$user->meetups()->attach($this->selectedMeetupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->selectedMeetupId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMeetup($meetupId)
|
||||||
|
{
|
||||||
|
auth()->user()->meetups()->detach($meetupId);
|
||||||
|
Flux::modals()->close();
|
||||||
|
$this->reset('selectedMeetupId');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function with(): array
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
// Meine Meetups
|
||||||
|
$myMeetups = $user->meetups()
|
||||||
|
->with(['city.country'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Alle verfügbaren Meetups (außer die bereits zugeordneten)
|
||||||
|
$availableMeetups = Meetup::with(['city.country'])
|
||||||
|
->whereNotIn('id', $myMeetups->pluck('id'))
|
||||||
|
->orderBy('name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Meine nächsten Meetup Termine
|
||||||
|
$myUpcomingEvents = MeetupEvent::whereHas('meetup', function($query) use ($user) {
|
||||||
|
$query->whereHas('users', function($q) use ($user) {
|
||||||
|
$q->where('users.id', $user->id);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->where('start', '>=', now())
|
||||||
|
->with(['meetup.city.country'])
|
||||||
|
->orderBy('start')
|
||||||
|
->limit(5)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'myMeetups' => $myMeetups,
|
||||||
|
'availableMeetups' => $availableMeetups,
|
||||||
|
'myUpcomingEvents' => $myUpcomingEvents,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}; ?>
|
||||||
|
|
||||||
|
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||||
|
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||||
|
<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">{{ __('Meine nächsten Meetup Termine') }}</flux:heading>
|
||||||
|
@if($myUpcomingEvents->count() > 0)
|
||||||
|
<flux:separator class="my-4"/>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach($myUpcomingEvents as $event)
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium">{{ $event->meetup->name }}</div>
|
||||||
|
<div class="text-sm text-zinc-500">
|
||||||
|
{{ $event->meetup->city->name }}, {{ $event->meetup->city->country->name }}
|
||||||
|
</div>
|
||||||
|
<flux:badge color="green" size="sm" class="mt-1">
|
||||||
|
{{ $event->start->format('d.m.Y H:i') }}
|
||||||
|
</flux:badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-sm text-zinc-500">{{ __('Keine bevorstehenden Termine') }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">{{ __('Meine Meetups') }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:select variant="listbox" searchable placeholder="{{ __('Meetup hinzufügen...') }}" wire:model="selectedMeetupId" wire:change="addMeetup">
|
||||||
|
<x-slot name="search">
|
||||||
|
<flux:select.search class="px-4" placeholder="{{ __('Meetup suchen...') }}"/>
|
||||||
|
</x-slot>
|
||||||
|
@foreach($availableMeetups as $meetup)
|
||||||
|
<flux:select.option value="{{ $meetup->id }}">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<img alt="{{ $meetup->name }}" src="{{ $meetup->getFirstMedia('logo') ? $meetup->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}" width="24" height="24" class="rounded"/>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">{{ $meetup->name }}</span>
|
||||||
|
@if($meetup->city)
|
||||||
|
<span class="text-xs text-zinc-500">- {{ $meetup->city->name }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
|
@if($myMeetups->count() > 0)
|
||||||
|
<flux:separator class="my-4"/>
|
||||||
|
<div class="space-y-3">
|
||||||
|
@foreach($myMeetups as $meetup)
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<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>
|
||||||
|
<div class="font-medium">{{ $meetup->name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">
|
||||||
|
{{ $meetup->city->name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<flux:button :href="route_with_country('meetups.edit', ['meetup' => $meetup])" size="xs" variant="ghost" icon="pencil">
|
||||||
|
{{ __('Bearbeiten') }}
|
||||||
|
</flux:button>
|
||||||
|
<flux:modal.trigger :name="'remove-meetup-' . $meetup->id">
|
||||||
|
<flux:button size="xs" variant="danger" icon="trash"></flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:modal wire:key="remove-meetup-{{ $meetup->id }}" :name="'remove-meetup-' . $meetup->id" class="min-w-[22rem]">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">{{ __('Meetup entfernen?') }}</flux:heading>
|
||||||
|
|
||||||
|
<flux:text class="mt-2">
|
||||||
|
{{ __('Möchtest du') }} "{{ $meetup->name }}" {{ __('aus deinen Meetups entfernen?') }}<br>
|
||||||
|
{{ __('Du kannst es jederzeit wieder hinzufügen.') }}
|
||||||
|
</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="removeMeetup({{ $meetup->id }})" variant="danger">{{ __('Entfernen') }}</flux:button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="text-sm text-zinc-500 mt-4">{{ __('Keine Meetups zugeordnet') }}</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{--<div class="relative h-full flex-1 overflow-hidden rounded-xl border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<x-placeholder-pattern class="absolute inset-0 size-full stroke-gray-900/20 dark:stroke-neutral-100/20" />
|
||||||
|
</div>--}}
|
||||||
|
</div>
|
||||||
@@ -146,7 +146,9 @@ new class extends Component {
|
|||||||
</flux:table.cell>
|
</flux:table.cell>
|
||||||
|
|
||||||
<flux:table.cell>
|
<flux:table.cell>
|
||||||
<flux:button :href="route_with_country('meetups.edit', ['meetup' => $meetup])" size="xs"
|
<flux:button
|
||||||
|
:disabled="!$meetup->belongsToMe"
|
||||||
|
:href="$meetup->belongsToMe ? route_with_country('meetups.edit', ['meetup' => $meetup]) : null" size="xs"
|
||||||
variant="filled" icon="pencil">
|
variant="filled" icon="pencil">
|
||||||
{{ __('Bearbeiten') }}
|
{{ __('Bearbeiten') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ Volt::route('welcome', 'welcome')->name('welcome');
|
|||||||
Route::middleware([])
|
Route::middleware([])
|
||||||
->prefix('/{country:code}')
|
->prefix('/{country:code}')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
Route::view('dashboard', 'dashboard')
|
|
||||||
->middleware(['auth', 'verified'])
|
|
||||||
->name('dashboard');
|
|
||||||
|
|
||||||
Volt::route('meetups', 'meetups.index')->name('meetups.index');
|
Volt::route('meetups', 'meetups.index')->name('meetups.index');
|
||||||
Volt::route('map', 'meetups.map')->name('meetups.map');
|
Volt::route('map', 'meetups.map')->name('meetups.map');
|
||||||
Volt::route('meetup/{meetup:slug}', 'meetups.landingpage')->name('meetups.landingpage');
|
Volt::route('meetup/{meetup:slug}', 'meetups.landingpage')->name('meetups.landingpage');
|
||||||
@@ -23,6 +19,7 @@ Route::middleware([])
|
|||||||
Route::middleware(['auth'])
|
Route::middleware(['auth'])
|
||||||
->prefix('/{country:code}')
|
->prefix('/{country:code}')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
|
Volt::route('dashboard', 'dashboard')->name('dashboard');
|
||||||
Volt::route('meetup-edit/{meetup}', 'meetups.edit')->name('meetups.edit');
|
Volt::route('meetup-edit/{meetup}', 'meetups.edit')->name('meetups.edit');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
79
yarn.lock
79
yarn.lock
@@ -197,6 +197,40 @@
|
|||||||
"@emnapi/runtime" "^1.5.0"
|
"@emnapi/runtime" "^1.5.0"
|
||||||
"@tybys/wasm-util" "^0.10.1"
|
"@tybys/wasm-util" "^0.10.1"
|
||||||
|
|
||||||
|
"@noble/ciphers@^0.5.1":
|
||||||
|
version "0.5.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.5.3.tgz#48b536311587125e0d0c1535f73ec8375cd76b23"
|
||||||
|
integrity sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==
|
||||||
|
|
||||||
|
"@noble/curves@1.2.0":
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35"
|
||||||
|
integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==
|
||||||
|
dependencies:
|
||||||
|
"@noble/hashes" "1.3.2"
|
||||||
|
|
||||||
|
"@noble/curves@~1.1.0":
|
||||||
|
version "1.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d"
|
||||||
|
integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==
|
||||||
|
dependencies:
|
||||||
|
"@noble/hashes" "1.3.1"
|
||||||
|
|
||||||
|
"@noble/hashes@1.3.1":
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9"
|
||||||
|
integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==
|
||||||
|
|
||||||
|
"@noble/hashes@1.3.2":
|
||||||
|
version "1.3.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39"
|
||||||
|
integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==
|
||||||
|
|
||||||
|
"@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1":
|
||||||
|
version "1.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699"
|
||||||
|
integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi@4.53.3":
|
"@rollup/rollup-android-arm-eabi@4.53.3":
|
||||||
version "4.53.3"
|
version "4.53.3"
|
||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz#7e478b66180c5330429dd161bf84dad66b59c8eb"
|
||||||
@@ -312,6 +346,33 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe"
|
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz#38ae84f4c04226c1d56a3b17296ef1e0460ecdfe"
|
||||||
integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==
|
integrity sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==
|
||||||
|
|
||||||
|
"@scure/base@1.1.1":
|
||||||
|
version "1.1.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938"
|
||||||
|
integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==
|
||||||
|
|
||||||
|
"@scure/base@~1.1.0":
|
||||||
|
version "1.1.9"
|
||||||
|
resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.9.tgz#e5e142fbbfe251091f9c5f1dd4c834ac04c3dbd1"
|
||||||
|
integrity sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==
|
||||||
|
|
||||||
|
"@scure/bip32@1.3.1":
|
||||||
|
version "1.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10"
|
||||||
|
integrity sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==
|
||||||
|
dependencies:
|
||||||
|
"@noble/curves" "~1.1.0"
|
||||||
|
"@noble/hashes" "~1.3.1"
|
||||||
|
"@scure/base" "~1.1.0"
|
||||||
|
|
||||||
|
"@scure/bip39@1.2.1":
|
||||||
|
version "1.2.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a"
|
||||||
|
integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==
|
||||||
|
dependencies:
|
||||||
|
"@noble/hashes" "~1.3.0"
|
||||||
|
"@scure/base" "~1.1.0"
|
||||||
|
|
||||||
"@shikijs/core@3.15.0":
|
"@shikijs/core@3.15.0":
|
||||||
version "3.15.0"
|
version "3.15.0"
|
||||||
resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.15.0.tgz#eee251070b4e39b59e108266cbcd50c85d738d54"
|
resolved "https://registry.yarnpkg.com/@shikijs/core/-/core-3.15.0.tgz#eee251070b4e39b59e108266cbcd50c85d738d54"
|
||||||
@@ -1063,6 +1124,24 @@ normalize-range@^0.1.2:
|
|||||||
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
|
||||||
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==
|
||||||
|
|
||||||
|
nostr-tools@^2.17.4:
|
||||||
|
version "2.17.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.17.4.tgz#c4d70e0df6f7374a001444a627313645f3c965d7"
|
||||||
|
integrity sha512-LGqpKufnmR93tOjFi4JZv1BTTVIAVfZAaAa+1gMqVfI0wNz2DnCB6UDXmjVTRrjQHMw2ykbk0EZLPzV5UeCIJw==
|
||||||
|
dependencies:
|
||||||
|
"@noble/ciphers" "^0.5.1"
|
||||||
|
"@noble/curves" "1.2.0"
|
||||||
|
"@noble/hashes" "1.3.1"
|
||||||
|
"@scure/base" "1.1.1"
|
||||||
|
"@scure/bip32" "1.3.1"
|
||||||
|
"@scure/bip39" "1.2.1"
|
||||||
|
nostr-wasm "0.1.0"
|
||||||
|
|
||||||
|
nostr-wasm@0.1.0:
|
||||||
|
version "0.1.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94"
|
||||||
|
integrity sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==
|
||||||
|
|
||||||
oniguruma-parser@^0.12.1:
|
oniguruma-parser@^0.12.1:
|
||||||
version "0.12.1"
|
version "0.12.1"
|
||||||
resolved "https://registry.yarnpkg.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz#82ba2208d7a2b69ee344b7efe0ae930c627dcc4a"
|
resolved "https://registry.yarnpkg.com/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz#82ba2208d7a2b69ee344b7efe0ae930c627dcc4a"
|
||||||
|
|||||||
Reference in New Issue
Block a user