🚀 Add courses and lecturers management functionality

This commit is contained in:
HolgerHatGarKeineNode
2025-11-21 14:23:59 +01:00
parent 976844487a
commit e96413d1a0
18 changed files with 1740 additions and 17 deletions

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Image\Manipulations; use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
@@ -49,10 +49,10 @@ class Course extends Model implements HasMedia
{ {
$this $this
->addMediaConversion('preview') ->addMediaConversion('preview')
->fit(Manipulations::FIT_CROP, 300, 300) ->fit(Fit::Crop, 300, 300)
->nonQueued(); ->nonQueued();
$this->addMediaConversion('thumb') $this->addMediaConversion('thumb')
->fit(Manipulations::FIT_CROP, 130, 130) ->fit(Fit::Crop, 130, 130)
->width(130) ->width(130)
->height(130); ->height(130);
} }

View File

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Support\Facades\Cookie; use Illuminate\Support\Facades\Cookie;
use Spatie\Image\Manipulations; use Spatie\Image\Enums\Fit;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media; use Spatie\MediaLibrary\MediaCollections\Models\Media;
@@ -49,10 +49,10 @@ class Lecturer extends Model implements HasMedia
{ {
$this $this
->addMediaConversion('preview') ->addMediaConversion('preview')
->fit(Manipulations::FIT_CROP, 300, 300) ->fit(Fit::Crop, 300, 300)
->nonQueued(); ->nonQueued();
$this->addMediaConversion('thumb') $this->addMediaConversion('thumb')
->fit(Manipulations::FIT_CROP, 130, 130) ->fit(Fit::Crop, 130, 130)
->width(130) ->width(130)
->height(130); ->height(130);
} }

View File

@@ -25,6 +25,7 @@
"spatie/laravel-permission": "^6.20", "spatie/laravel-permission": "^6.20",
"spatie/laravel-sluggable": "^3.7", "spatie/laravel-sluggable": "^3.7",
"spatie/laravel-tags": "^4.10", "spatie/laravel-tags": "^4.10",
"staudenmeir/eloquent-has-many-deep": "^1.21",
"woodsandwalker/laravel-countries": "^1.5" "woodsandwalker/laravel-countries": "^1.5"
}, },
"require-dev": { "require-dev": {

110
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9b98697732c4305356365667814db4be", "content-hash": "e2d34fa17a2f68c2fc315cab9b402b42",
"packages": [ "packages": [
{ {
"name": "akuechler/laravel-geoly", "name": "akuechler/laravel-geoly",
@@ -5329,6 +5329,114 @@
], ],
"time": "2025-01-13T13:04:43+00:00" "time": "2025-01-13T13:04:43+00:00"
}, },
{
"name": "staudenmeir/eloquent-has-many-deep",
"version": "v1.21.2",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-has-many-deep.git",
"reference": "b0a3041c44237ebcd0d1005e475a4c736cba482c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep/zipball/b0a3041c44237ebcd0d1005e475a4c736cba482c",
"reference": "b0a3041c44237ebcd0d1005e475a4c736cba482c",
"shasum": ""
},
"require": {
"illuminate/database": "^12.0",
"php": "^8.2",
"staudenmeir/eloquent-has-many-deep-contracts": "^1.3"
},
"require-dev": {
"awobaz/compoships": "^2.3",
"barryvdh/laravel-ide-helper": "^3.0",
"korridor/laravel-has-many-merged": "^1.2",
"larastan/larastan": "^3.0",
"laravel/framework": "^12.0",
"mockery/mockery": "^1.6",
"orchestra/testbench-core": "^10.0",
"phpunit/phpunit": "^11.0",
"staudenmeir/eloquent-json-relations": "^1.14",
"staudenmeir/laravel-adjacency-list": "^1.24"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Staudenmeir\\EloquentHasManyDeep\\IdeHelperServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentHasManyDeep\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Laravel Eloquent HasManyThrough relationships with unlimited levels",
"support": {
"issues": "https://github.com/staudenmeir/eloquent-has-many-deep/issues",
"source": "https://github.com/staudenmeir/eloquent-has-many-deep/tree/v1.21.2"
},
"funding": [
{
"url": "https://paypal.me/JonasStaudenmeir",
"type": "custom"
}
],
"time": "2025-11-08T08:44:24+00:00"
},
{
"name": "staudenmeir/eloquent-has-many-deep-contracts",
"version": "v1.3",
"source": {
"type": "git",
"url": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts.git",
"reference": "37ce351e4db919b3af606bc8ca0e62e2e4939cde"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/staudenmeir/eloquent-has-many-deep-contracts/zipball/37ce351e4db919b3af606bc8ca0e62e2e4939cde",
"reference": "37ce351e4db919b3af606bc8ca0e62e2e4939cde",
"shasum": ""
},
"require": {
"illuminate/database": "^12.0",
"php": "^8.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Staudenmeir\\EloquentHasManyDeepContracts\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonas Staudenmeir",
"email": "mail@jonas-staudenmeir.de"
}
],
"description": "Contracts for staudenmeir/eloquent-has-many-deep",
"support": {
"issues": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/issues",
"source": "https://github.com/staudenmeir/eloquent-has-many-deep-contracts/tree/v1.3"
},
"time": "2025-02-15T17:11:01+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v7.3.0", "version": "v7.3.0",

View File

@@ -32,6 +32,21 @@
:current="request()->routeIs('meetups.map')" :current="request()->routeIs('meetups.map')"
wire:navigate>{{ __('Karte') }}</flux:navlist.item> wire:navigate>{{ __('Karte') }}</flux:navlist.item>
</flux:navlist.group> </flux:navlist.group>
<flux:navlist.group :heading="__('Kurse')" class="grid">
<flux:navlist.item icon="academic-cap" :href="route_with_country('courses.index')"
:current="request()->routeIs('courses.index')"
wire:navigate
badge="{{ \App\Models\Course::query()->count() }}">
{{ __('Kurse') }}
</flux:navlist.item>
<flux:navlist.item icon="user" :href="route_with_country('lecturers.index')"
:current="request()->routeIs('lecturers.index')"
wire:navigate
badge="{{ \App\Models\Lecturer::query()->count() }}">
{{ __('Dozenten') }}
</flux:navlist.item>
</flux:navlist.group>
{{--<flux:navlist.group :heading="__('Wallpaper')" class="grid"> {{--<flux:navlist.group :heading="__('Wallpaper')" class="grid">
</flux:navlist.group>--}} </flux:navlist.group>--}}

View File

@@ -0,0 +1,126 @@
<?php
use App\Models\Course;
use App\Models\Lecturer;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:10240')] // 10MB Max
public $logo;
public string $name = '';
public ?int $lecturer_id = null;
public ?string $description = null;
public function createCourse(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'lecturer_id' => ['required', 'exists:lecturers,id'],
'description' => ['nullable', 'string'],
]);
$course = Course::create($validated);
if ($this->logo) {
$course
->addMedia($this->logo->getRealPath())
->usingName($course->name)
->toMediaCollection('logo');
}
session()->flash('status', __('Kurs erfolgreich erstellt!'));
$this->redirect(route_with_country('courses.edit', ['course' => $course]), navigate: true);
}
public function with(): array
{
return [
'lecturers' => Lecturer::query()->orderBy('name')->get(),
];
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Neuen Kurs erstellen') }}</flux:heading>
<form wire:submit="createCourse" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="logo">
<!-- Custom logo uploader -->
<div class="
relative flex items-center justify-center size-20 rounded transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
<!-- Show the uploaded file if it exists -->
@if($logo)
<img src="{{ $logo?->temporaryUrl() }}" alt="Logo"
class="size-full object-cover rounded"/>
@else
<!-- Show the default icon if no file is uploaded -->
<flux:icon name="academic-cap" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<!-- Corner upload icon -->
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Der Anzeigename für diesen Kurs') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Dozent') }} <span class="text-red-500">*</span></flux:label>
<flux:select variant="listbox" searchable wire:model="lecturer_id"
placeholder="{{ __('Dozent auswählen') }}">
<x-slot name="search">
<flux:select.search class="px-4" placeholder="{{ __('Suche passenden Dozenten...') }}"/>
</x-slot>
@foreach($lecturers as $lecturer)
<flux:select.option value="{{ $lecturer->id }}">{{ $lecturer->name }}
</flux:select.option>
@endforeach
</flux:select>
<flux:description>{{ __('Der Dozent, der diesen Kurs leitet') }}</flux:description>
<flux:error name="lecturer_id"/>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="6"/>
<flux:description>{{ __('Ausführliche Beschreibung des Kurses') }}</flux:description>
<flux:error name="description"/>
</flux:field>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Kurs erstellen') }}
</flux:button>
</div>
</form>
</div>

View File

@@ -0,0 +1,195 @@
<?php
use App\Models\Course;
use App\Models\Lecturer;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:10240')] // 10MB Max
public $logo;
public Course $course;
// Basic Information
public string $name = '';
public ?int $lecturer_id = null;
public ?string $description = null;
// System fields (read-only)
public ?int $created_by = null;
public ?string $created_at = null;
public ?string $updated_at = null;
public function mount(): void
{
$this->course->load('media');
// Basic Information
$this->name = $this->course->name ?? '';
$this->lecturer_id = $this->course->lecturer_id;
$this->description = $this->course->description;
// System fields
$this->created_by = $this->course->created_by;
$this->created_at = $this->course->created_at?->format('Y-m-d H:i:s');
$this->updated_at = $this->course->updated_at?->format('Y-m-d H:i:s');
}
public function updateCourse(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'lecturer_id' => ['required', 'exists:lecturers,id'],
'description' => ['nullable', 'string'],
]);
$this->course->update($validated);
if ($this->logo) {
$this->course->clearMediaCollection('logo');
$this->course
->addMedia($this->logo->getRealPath())
->usingName($this->course->name)
->toMediaCollection('logo');
$this->logo = null;
$this->course->load('media');
}
$this->dispatch('course-updated', name: $this->course->name);
session()->flash('status', __('Kurs erfolgreich aktualisiert!'));
}
public function with(): array
{
return [
'lecturers' => Lecturer::query()->orderBy('name')->get(),
];
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Kurs bearbeiten') }}: {{ $course->name }}</flux:heading>
<form wire:submit="updateCourse" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="logo">
<!-- Custom logo uploader -->
<div class="
relative flex items-center justify-center size-20 rounded-full transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
<!-- Show the uploaded file if it exists -->
@if (!$logo && $course->getFirstMedia('logo'))
<img src="{{ $course->getFirstMediaUrl('logo') }}" alt="Logo"
class="size-full object-cover rounded"/>
@elseif($logo)
<img src="{{ $logo?->temporaryUrl() }}" alt="Logo"
class="size-full object-cover rounded"/>
@else
<!-- Show the default icon if no file is uploaded -->
<flux:icon name="academic-cap" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<!-- Corner upload icon -->
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('ID') }}</flux:label>
<flux:input value="{{ $course->id }}" disabled/>
<flux:description>{{ __('System-generierte ID (nur lesbar)') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Der Anzeigename für diesen Kurs') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Dozent') }} <span class="text-red-500">*</span></flux:label>
<flux:select variant="listbox" searchable wire:model="lecturer_id"
placeholder="{{ __('Dozent auswählen') }}">
<x-slot name="search">
<flux:select.search class="px-4" placeholder="{{ __('Suche passenden Dozenten...') }}"/>
</x-slot>
@foreach($lecturers as $lecturer)
<flux:select.option value="{{ $lecturer->id }}">{{ $lecturer->name }}
</flux:select.option>
@endforeach
</flux:select>
<flux:description>{{ __('Der Dozent, der diesen Kurs leitet') }}</flux:description>
<flux:error name="lecturer_id"/>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="6"/>
<flux:description>{{ __('Ausführliche Beschreibung des Kurses') }}</flux:description>
<flux:error name="description"/>
</flux:field>
</flux:fieldset>
<!-- System Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Systeminformationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<flux:field>
<flux:label>{{ __('Erstellt von') }}</flux:label>
<flux:input value="{{ $course->createdBy?->name ?? __('Unbekannt') }}" disabled/>
<flux:description>{{ __('Ersteller des Kurses') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Erstellt am') }}</flux:label>
<flux:input value="{{ $created_at }}" disabled/>
<flux:description>{{ __('Wann dieser Kurs erstellt wurde') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Aktualisiert am') }}</flux:label>
<flux:input value="{{ $updated_at }}" disabled/>
<flux:description>{{ __('Letzte Änderungszeit') }}</flux:description>
</flux:field>
</div>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<div class="flex items-center gap-4">
@if (session('status'))
<flux:text class="text-green-600 dark:text-green-400 font-medium">
{{ session('status') }}
</flux:text>
@endif
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Kurs aktualisieren') }}
</flux:button>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,127 @@
<?php
use App\Models\Course;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public $country = 'de';
public $search = '';
public function mount(): void
{
$this->country = request()->route('country');
}
public function with(): array
{
return [
'courses' => Course::with(['lecturer', 'createdBy'])
->withExists([
'courseEvents as has_future_events' => fn($query) => $query->where('from', '>=', now())
])
->when($this->search, fn($query)
=> $query
->where('name', 'ilike', '%'.$this->search.'%')
->orWhere('description', 'ilike', '%'.$this->search.'%'),
)
->orderByDesc('has_future_events')
->paginate(15),
];
}
}; ?>
<div>
<div class="flex items-center justify-between">
<flux:heading size="xl">{{ __('Kurse') }}</flux:heading>
<div class="flex items-center gap-4">
<div>
<flux:input
wire:model.live="search"
:placeholder="__('Suche nach Kursen...')"
clearable
/>
</div>
<flux:button variant="primary" icon="plus-circle" :href="route_with_country('courses.create')"
wire:navigate>{{ __('Neuer Kurs') }}</flux:button>
</div>
</div>
<flux:table :paginate="$courses" class="mt-6">
<flux:table.columns>
<flux:table.column>
{{ __('Name') }}
</flux:table.column>
<flux:table.column>
{{ __('Dozent') }}
</flux:table.column>
<flux:table.column>{{ __('Nächster Termin') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($courses as $course)
<flux:table.row :key="$course->id">
<flux:table.cell variant="strong" class="flex items-center gap-3">
<flux:avatar :href="route('courses.landingpage', ['course' => $course, 'country' => $country])"
src="{{ $course->getFirstMedia('logo') ? $course->getFirstMediaUrl('logo', 'thumb') : asset('android-chrome-512x512.png') }}"/>
<div>
<a href="{{ route('courses.landingpage', ['course' => $course, 'country' => $country]) }}">
<span>{{ $course->name }}</span>
@if($course->description)
<div class="text-xs text-zinc-500">
{{ Str::limit($course->description, 60) }}
</div>
@endif
</a>
</div>
</flux:table.cell>
<flux:table.cell>
@if($course->lecturer)
<div class="flex items-center gap-2">
<flux:avatar size="xs"
src="{{ $course->lecturer->getFirstMedia('avatar') ? $course->lecturer->getFirstMediaUrl('avatar', 'thumb') : asset('img/einundzwanzig.png') }}"/>
<span>{{ $course->lecturer->name }}</span>
</div>
@endif
</flux:table.cell>
<flux:table.cell>
@php
$nextEvent = $course->courseEvents()
->where('from', '>=', now())
->orderBy('from', 'asc')
->first();
@endphp
@if($nextEvent)
<flux:badge color="green" size="sm">
{{ $nextEvent->from->format('d.m.Y H:i') }}
</flux:badge>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:button
:disabled="$course->created_by !== auth()->id()"
:href="$course->created_by === auth()->id() ? route_with_country('courses.edit', ['course' => $course]) : null"
size="xs"
variant="filled"
icon="pencil">
{{ __('Bearbeiten') }}
</flux:button>
<flux:button
:href="route_with_country('courses.events.create', ['course' => $course])"
size="xs"
variant="filled"
icon="calendar">
{{ __('Neues Event erstellen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>

View File

@@ -0,0 +1,164 @@
<?php
use App\Models\Course;
use App\Models\CourseEvent;
use Livewire\Volt\Component;
new class extends Component {
public Course $course;
public $country = 'de';
public function mount(): void
{
$this->country = request()->route('country');
}
public function with(): array
{
return [
'course' => $this->course->load('lecturer'),
'events' => $this->course
->courseEvents()
->where('from', '>=', now())
->orderBy('from', 'asc')
->get(),
];
}
}; ?>
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: Course Details -->
<div class="space-y-6">
<div class="flex items-center space-x-4">
<flux:avatar class="[:where(&)]:size-32 [:where(&)]:text-base" size="xl"
src="{{ $course->getFirstMedia('logo') ? $course->getFirstMediaUrl('logo') : asset('android-chrome-512x512.png') }}"/>
<div class="space-y-2">
<flux:heading size="xl" class="mb-4">{{ $course->name }}</flux:heading>
@if($course->lecturer)
<flux:subheading class="text-gray-600 dark:text-gray-400 flex items-center gap-2">
<flux:avatar size="xs" src="{{ $course->lecturer->getFirstMedia('avatar') ? $course->lecturer->getFirstMediaUrl('avatar', 'thumb') : asset('img/einundzwanzig.png') }}"/>
{{ $course->lecturer->name }}
</flux:subheading>
@endif
</div>
</div>
@if($course->description)
<div>
<flux:heading size="lg" class="mb-2">{{ __('Über den Kurs') }}</flux:heading>
<x-markdown class="prose whitespace-pre-wrap">{!! $course->description !!}</x-markdown>
</div>
@endif
@if($course->lecturer)
<div class="space-y-4">
<flux:heading size="lg">{{ __('Über den Dozenten') }}</flux:heading>
<div class="flex items-start gap-4 p-4 bg-zinc-50 dark:bg-zinc-900 rounded-lg">
<flux:avatar size="lg" src="{{ $course->lecturer->getFirstMedia('avatar') ? $course->lecturer->getFirstMediaUrl('avatar', 'preview') : asset('img/einundzwanzig.png') }}"/>
<div class="flex-1">
<flux:heading size="md" class="mb-1">{{ $course->lecturer->name }}</flux:heading>
@if($course->lecturer->subtitle)
<flux:text class="text-sm text-zinc-600 dark:text-zinc-400 mb-2">{{ $course->lecturer->subtitle }}</flux:text>
@endif
@if($course->lecturer->intro)
<x-markdown class="prose prose-sm whitespace-pre-wrap">{!! $course->lecturer->intro !!}</x-markdown>
@endif
<!-- Lecturer Social Links -->
<div class="mt-4 flex flex-wrap gap-2">
@if($course->lecturer->website)
<flux:button href="{{ $course->lecturer->website }}" target="_blank" variant="ghost" size="xs">
<flux:icon.globe-alt class="w-4 h-4 mr-1"/>
Website
</flux:button>
@endif
@if($course->lecturer->twitter_username)
<flux:button href="https://twitter.com/{{ $course->lecturer->twitter_username }}" target="_blank" variant="ghost" size="xs">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
Twitter
</flux:button>
@endif
@if($course->lecturer->nostr)
<flux:button href="https://njump.me/{{ $course->lecturer->nostr }}" target="_blank" variant="ghost" size="xs">
<flux:icon.bolt class="w-4 h-4 mr-1"/>
Nostr
</flux:button>
@endif
</div>
</div>
</div>
</div>
@endif
</div>
<!-- Right Column: Lecturer Avatar/Info -->
<div>
@if($course->lecturer && $course->lecturer->getFirstMedia('avatar'))
<div class="sticky top-8">
<flux:heading size="lg" class="mb-4">{{ __('Dozent') }}</flux:heading>
<img src="{{ $course->lecturer->getFirstMediaUrl('avatar') }}"
alt="{{ $course->lecturer->name }}"
class="w-full rounded-lg shadow-lg"/>
</div>
@endif
</div>
</div>
<!-- Events Section -->
@if($events->isNotEmpty())
<div class="mt-16">
<flux:heading size="xl" class="mb-6">{{ __('Kommende Veranstaltungen') }}</flux:heading>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach($events as $event)
<flux:card size="sm" class="h-full flex flex-col">
<flux:heading class="flex items-center gap-2">
{{ $event->from->format('d.m.Y') }}
</flux:heading>
<flux:text class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
<flux:icon.clock class="inline w-4 h-4"/>
{{ $event->from->format('H:i') }} - {{ $event->to->format('H:i') }} Uhr
</flux:text>
@if($event->venue)
<flux:text class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
<flux:icon.map-pin class="inline w-4 h-4"/>
{{ $event->venue->name }}
</flux:text>
@endif
<div class="mt-auto pt-4 flex gap-2">
<flux:button
target="_blank"
:href="$event->link"
size="xs"
variant="primary"
class="flex-1"
>
{{ __('Details/Anmelden') }}
</flux:button>
@if($course->created_by === auth()->id())
<flux:button
:href="route_with_country('courses.events.edit', ['course' => $course, 'event' => $event])"
size="xs"
variant="ghost"
icon="pencil"
>
{{ __('Bearbeiten') }}
</flux:button>
@endif
</div>
</flux:card>
@endforeach
</div>
</div>
@endif
</div>

View File

@@ -0,0 +1,202 @@
<?php
use App\Models\Lecturer;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:10240')] // 10MB Max
public $avatar;
public string $name = '';
public ?string $subtitle = null;
public ?string $intro = null;
public ?string $description = null;
public bool $active = true;
// Social & Payment Links
public ?string $website = null;
public ?string $twitter_username = null;
public ?string $nostr = null;
public ?string $lightning_address = null;
public ?string $lnurl = null;
public ?string $node_id = null;
public ?string $paynym = null;
public function createLecturer(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255', 'unique:lecturers,name'],
'subtitle' => ['nullable', 'string'],
'intro' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'active' => ['boolean'],
'website' => ['nullable', 'url', 'max:255'],
'twitter_username' => ['nullable', 'string', 'max:255'],
'nostr' => ['nullable', 'string', 'max:255'],
'lightning_address' => ['nullable', 'string'],
'lnurl' => ['nullable', 'string'],
'node_id' => ['nullable', 'string', 'max:255'],
'paynym' => ['nullable', 'string'],
]);
$lecturer = Lecturer::create($validated);
if ($this->avatar) {
$lecturer
->addMedia($this->avatar->getRealPath())
->usingName($lecturer->name)
->toMediaCollection('avatar');
}
session()->flash('status', __('Dozent erfolgreich erstellt!'));
$this->redirect(route_with_country('lecturers.edit', ['lecturer' => $lecturer]), navigate: true);
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Neuen Dozenten erstellen') }}</flux:heading>
<form wire:submit="createLecturer" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="avatar">
<!-- Custom avatar uploader -->
<div class="
relative flex items-center justify-center size-20 rounded-full transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
@if($avatar)
<img src="{{ $avatar?->temporaryUrl() }}" alt="Avatar"
class="size-full object-cover rounded-full"/>
@else
<flux:icon name="user" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded-full">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Vollständiger Name des Dozenten') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Untertitel') }}</flux:label>
<flux:input wire:model="subtitle"/>
<flux:description>{{ __('Kurze Berufsbezeichnung oder Rolle') }}</flux:description>
<flux:error name="subtitle"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Status') }}</flux:label>
<flux:switch wire:model="active"/>
<flux:description>{{ __('Ist dieser Dozent aktiv?') }}</flux:description>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Einführung') }}</flux:label>
<flux:textarea wire:model="intro" rows="3"/>
<flux:description>{{ __('Kurze Vorstellung (wird auf Kurs-Seiten angezeigt)') }}</flux:description>
<flux:error name="intro"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="6"/>
<flux:description>{{ __('Ausführliche Beschreibung und Biografie') }}</flux:description>
<flux:error name="description"/>
</flux:field>
</flux:fieldset>
<!-- Social Links -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Links & Soziale Medien') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Webseite') }}</flux:label>
<flux:input wire:model="website" type="url" placeholder="https://example.com"/>
<flux:description>{{ __('Persönliche Webseite oder Portfolio') }}</flux:description>
<flux:error name="website"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Twitter Benutzername') }}</flux:label>
<flux:input wire:model="twitter_username" placeholder="benutzername"/>
<flux:description>{{ __('Twitter-Handle ohne @ Symbol') }}</flux:description>
<flux:error name="twitter_username"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Nostr') }}</flux:label>
<flux:input wire:model="nostr" placeholder="npub..."/>
<flux:description>{{ __('Nostr öffentlicher Schlüssel') }}</flux:description>
<flux:error name="nostr"/>
</flux:field>
</div>
</flux:fieldset>
<!-- Payment Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Zahlungsinformationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Lightning Adresse') }}</flux:label>
<flux:input wire:model="lightning_address" placeholder="name@getalby.com"/>
<flux:description>{{ __('Lightning-Adresse für Zahlungen') }}</flux:description>
<flux:error name="lightning_address"/>
</flux:field>
<flux:field>
<flux:label>{{ __('LNURL') }}</flux:label>
<flux:input wire:model="lnurl"/>
<flux:description>{{ __('LNURL für Lightning-Zahlungen') }}</flux:description>
<flux:error name="lnurl"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Node ID') }}</flux:label>
<flux:input wire:model="node_id"/>
<flux:description>{{ __('Lightning Node ID') }}</flux:description>
<flux:error name="node_id"/>
</flux:field>
<flux:field>
<flux:label>{{ __('PayNym') }}</flux:label>
<flux:input wire:model="paynym"/>
<flux:description>{{ __('PayNym für Bitcoin-Zahlungen') }}</flux:description>
<flux:error name="paynym"/>
</flux:field>
</div>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Dozenten erstellen') }}
</flux:button>
</div>
</form>
</div>

View File

@@ -0,0 +1,278 @@
<?php
use App\Models\Lecturer;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:10240')] // 10MB Max
public $avatar;
public Lecturer $lecturer;
public string $name = '';
public ?string $subtitle = null;
public ?string $intro = null;
public ?string $description = null;
public bool $active = true;
// Social & Payment Links
public ?string $website = null;
public ?string $twitter_username = null;
public ?string $nostr = null;
public ?string $lightning_address = null;
public ?string $lnurl = null;
public ?string $node_id = null;
public ?string $paynym = null;
// System fields (read-only)
public ?int $created_by = null;
public ?string $created_at = null;
public ?string $updated_at = null;
public function mount(): void
{
$this->lecturer->load('media');
$this->name = $this->lecturer->name ?? '';
$this->subtitle = $this->lecturer->subtitle;
$this->intro = $this->lecturer->intro;
$this->description = $this->lecturer->description;
$this->active = (bool) $this->lecturer->active;
$this->website = $this->lecturer->website;
$this->twitter_username = $this->lecturer->twitter_username;
$this->nostr = $this->lecturer->nostr;
$this->lightning_address = $this->lecturer->lightning_address;
$this->lnurl = $this->lecturer->lnurl;
$this->node_id = $this->lecturer->node_id;
$this->paynym = $this->lecturer->paynym;
$this->created_by = $this->lecturer->created_by;
$this->created_at = $this->lecturer->created_at?->format('Y-m-d H:i:s');
$this->updated_at = $this->lecturer->updated_at?->format('Y-m-d H:i:s');
}
public function updateLecturer(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255', Rule::unique('lecturers')->ignore($this->lecturer->id)],
'subtitle' => ['nullable', 'string'],
'intro' => ['nullable', 'string'],
'description' => ['nullable', 'string'],
'active' => ['boolean'],
'website' => ['nullable', 'url', 'max:255'],
'twitter_username' => ['nullable', 'string', 'max:255'],
'nostr' => ['nullable', 'string', 'max:255'],
'lightning_address' => ['nullable', 'string'],
'lnurl' => ['nullable', 'string'],
'node_id' => ['nullable', 'string', 'max:255'],
'paynym' => ['nullable', 'string'],
]);
$this->lecturer->update($validated);
if ($this->avatar) {
$this->lecturer->clearMediaCollection('avatar');
$this->lecturer
->addMedia($this->avatar->getRealPath())
->usingName($this->lecturer->name)
->toMediaCollection('avatar');
$this->avatar = null;
$this->lecturer->load('media');
}
$this->dispatch('lecturer-updated', name: $this->lecturer->name);
session()->flash('status', __('Dozent erfolgreich aktualisiert!'));
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Dozent bearbeiten') }}: {{ $lecturer->name }}</flux:heading>
<form wire:submit="updateLecturer" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="avatar">
<!-- Custom avatar uploader -->
<div class="
relative flex items-center justify-center size-20 rounded-full transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
@if (!$avatar && $lecturer->getFirstMedia('avatar'))
<img src="{{ $lecturer->getFirstMediaUrl('avatar') }}" alt="Avatar"
class="size-full object-cover rounded-full"/>
@elseif($avatar)
<img src="{{ $avatar?->temporaryUrl() }}" alt="Avatar"
class="size-full object-cover rounded-full"/>
@else
<flux:icon name="user" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded-full">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('ID') }}</flux:label>
<flux:input value="{{ $lecturer->id }}" disabled/>
<flux:description>{{ __('System-generierte ID (nur lesbar)') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Vollständiger Name des Dozenten') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Untertitel') }}</flux:label>
<flux:input wire:model="subtitle"/>
<flux:description>{{ __('Kurze Berufsbezeichnung oder Rolle') }}</flux:description>
<flux:error name="subtitle"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Status') }}</flux:label>
<flux:switch wire:model="active"/>
<flux:description>{{ __('Ist dieser Dozent aktiv?') }}</flux:description>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Einführung') }}</flux:label>
<flux:textarea wire:model="intro" rows="3"/>
<flux:description>{{ __('Kurze Vorstellung (wird auf Kurs-Seiten angezeigt)') }}</flux:description>
<flux:error name="intro"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Beschreibung') }}</flux:label>
<flux:textarea wire:model="description" rows="6"/>
<flux:description>{{ __('Ausführliche Beschreibung und Biografie') }}</flux:description>
<flux:error name="description"/>
</flux:field>
</flux:fieldset>
<!-- Social Links -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Links & Soziale Medien') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Webseite') }}</flux:label>
<flux:input wire:model="website" type="url" placeholder="https://example.com"/>
<flux:description>{{ __('Persönliche Webseite oder Portfolio') }}</flux:description>
<flux:error name="website"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Twitter Benutzername') }}</flux:label>
<flux:input wire:model="twitter_username" placeholder="benutzername"/>
<flux:description>{{ __('Twitter-Handle ohne @ Symbol') }}</flux:description>
<flux:error name="twitter_username"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Nostr') }}</flux:label>
<flux:input wire:model="nostr" placeholder="npub..."/>
<flux:description>{{ __('Nostr öffentlicher Schlüssel') }}</flux:description>
<flux:error name="nostr"/>
</flux:field>
</div>
</flux:fieldset>
<!-- Payment Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Zahlungsinformationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Lightning Adresse') }}</flux:label>
<flux:input wire:model="lightning_address" placeholder="name@getalby.com"/>
<flux:description>{{ __('Lightning-Adresse für Zahlungen') }}</flux:description>
<flux:error name="lightning_address"/>
</flux:field>
<flux:field>
<flux:label>{{ __('LNURL') }}</flux:label>
<flux:input wire:model="lnurl"/>
<flux:description>{{ __('LNURL für Lightning-Zahlungen') }}</flux:description>
<flux:error name="lnurl"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Node ID') }}</flux:label>
<flux:input wire:model="node_id"/>
<flux:description>{{ __('Lightning Node ID') }}</flux:description>
<flux:error name="node_id"/>
</flux:field>
<flux:field>
<flux:label>{{ __('PayNym') }}</flux:label>
<flux:input wire:model="paynym"/>
<flux:description>{{ __('PayNym für Bitcoin-Zahlungen') }}</flux:description>
<flux:error name="paynym"/>
</flux:field>
</div>
</flux:fieldset>
<!-- System Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Systeminformationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<flux:field>
<flux:label>{{ __('Erstellt von') }}</flux:label>
<flux:input value="{{ $lecturer->createdBy?->name ?? __('Unbekannt') }}" disabled/>
<flux:description>{{ __('Ersteller des Dozenten') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Erstellt am') }}</flux:label>
<flux:input value="{{ $created_at }}" disabled/>
<flux:description>{{ __('Wann dieser Dozent erstellt wurde') }}</flux:description>
</flux:field>
<flux:field>
<flux:label>{{ __('Aktualisiert am') }}</flux:label>
<flux:input value="{{ $updated_at }}" disabled/>
<flux:description>{{ __('Letzte Änderungszeit') }}</flux:description>
</flux:field>
</div>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<div class="flex items-center gap-4">
@if (session('status'))
<flux:text class="text-green-600 dark:text-green-400 font-medium">
{{ session('status') }}
</flux:text>
@endif
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Dozent aktualisieren') }}
</flux:button>
</div>
</div>
</form>
</div>

View File

@@ -0,0 +1,136 @@
<?php
use App\Models\Lecturer;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public $country = 'de';
public $sortBy = 'name';
public $sortDirection = 'asc';
public $search = '';
public function mount(): void
{
$this->country = request()->route('country');
}
public function sort($column)
{
if ($this->sortBy === $column) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDirection = 'asc';
}
}
public function with(): array
{
return [
'lecturers' => Lecturer::with(['createdBy'])
->when($this->search, fn($query)
=> $query->where('name', 'ilike', '%'.$this->search.'%')
->orWhere('description', 'ilike', '%'.$this->search.'%')
->orWhere('subtitle', 'ilike', '%'.$this->search.'%'),
)
->orderBy($this->sortBy, $this->sortDirection)
->paginate(15),
];
}
}; ?>
<div>
<div class="flex items-center justify-between mb-6">
<flux:heading size="xl">{{ __('Dozenten') }}</flux:heading>
<div class="flex items-center gap-4">
<flux:input
wire:model.live="search"
:placeholder="__('Suche nach Dozenten...')"
clearable
/>
@auth
<flux:button class="cursor-pointer" :href="route_with_country('lecturers.create')" icon="plus" variant="primary">
{{ __('Dozenten anlegen') }}
</flux:button>
@endauth
</div>
</div>
<flux:table :paginate="$lecturers" class="mt-6">
<flux:table.columns>
<flux:table.column sortable :sorted="$sortBy === 'name'" :direction="$sortDirection"
wire:click="sort('name')">{{ __('Name') }}
</flux:table.column>
<flux:table.column>{{ __('Untertitel') }}</flux:table.column>
<flux:table.column>{{ __('Kurse') }}</flux:table.column>
<flux:table.column>{{ __('Links') }}</flux:table.column>
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@foreach ($lecturers as $lecturer)
<flux:table.row :key="$lecturer->id">
<flux:table.cell variant="strong" class="flex items-center gap-3">
<flux:avatar size="lg" src="{{ $lecturer->getFirstMedia('avatar') ? $lecturer->getFirstMediaUrl('avatar', 'thumb') : asset('img/einundzwanzig.png') }}"/>
<div>
<div class="font-semibold">{{ $lecturer->name }}</div>
@if($lecturer->active)
<flux:badge size="sm" color="green">{{ __('Aktiv') }}</flux:badge>
@else
<flux:badge size="sm" color="zinc">{{ __('Inaktiv') }}</flux:badge>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
@if($lecturer->subtitle)
<div class="text-sm text-zinc-600 dark:text-zinc-400">
{{ Str::limit($lecturer->subtitle, 50) }}
</div>
@endif
</flux:table.cell>
<flux:table.cell>
<flux:badge size="sm">{{ $lecturer->courses()->count() }} {{ __('Kurse') }}</flux:badge>
</flux:table.cell>
<flux:table.cell>
<div class="flex gap-2">
@if($lecturer->website)
<flux:link :href="$lecturer->website" external variant="subtle" title="{{ __('Website') }}">
<flux:icon.globe-alt variant="mini"/>
</flux:link>
@endif
@if($lecturer->twitter_username)
<flux:link :href="'https://twitter.com/' . $lecturer->twitter_username" external
variant="subtle" title="{{ __('Twitter') }}">
<flux:icon.x-mark variant="mini"/>
</flux:link>
@endif
@if($lecturer->nostr)
<flux:link :href="'https://njump.me/'.$lecturer->nostr" external variant="subtle"
title="{{ __('Nostr') }}">
<flux:icon.bolt variant="mini"/>
</flux:link>
@endif
</div>
</flux:table.cell>
<flux:table.cell>
<flux:button
:disabled="$lecturer->created_by !== auth()->id()"
:href="$lecturer->created_by === auth()->id() ? route_with_country('lecturers.edit', ['lecturer' => $lecturer]) : null"
size="xs"
variant="filled"
icon="pencil">
{{ __('Bearbeiten') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@endforeach
</flux:table.rows>
</flux:table>
</div>

View File

@@ -64,7 +64,7 @@ new class extends Component {
if ($this->event) { if ($this->event) {
$this->event->delete(); $this->event->delete();
session()->flash('status', __('Event erfolgreich gelöscht!')); session()->flash('status', __('Event erfolgreich gelöscht!'));
$this->redirect(route_with_country('meetups.edit', ['meetup' => $this->meetup]), navigate: true); $this->redirect(route('meetups.landingpage', ['meetup' => $this->meetup, 'country' => $this->country]), navigate: true);
} }
} }
}; ?> }; ?>

View File

@@ -0,0 +1,337 @@
<?php
use App\Models\City;
use App\Models\Country;
use App\Models\Meetup;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:10240')] // 10MB Max
public $logo;
// Basic Information
public string $name = '';
public ?int $city_id = null;
public ?string $intro = null;
// Links and Social Media
public ?string $telegram_link = null;
public ?string $webpage = null;
public ?string $twitter_username = null;
public ?string $matrix_group = null;
public ?string $nostr = null;
public ?string $simplex = null;
public ?string $signal = null;
// Additional Information
public ?string $community = null;
public bool $visible_on_map = true;
// New City Modal
public string $newCityName = '';
public ?int $newCityCountryId = null;
public float $newCityLatitude = 0;
public float $newCityLongitude = 0;
public function createCity(): void
{
$validated = $this->validate([
'newCityName' => ['required', 'string', 'max:255', 'unique:cities,name'],
'newCityCountryId' => ['required', 'exists:countries,id'],
'newCityLatitude' => ['required', 'numeric'],
'newCityLongitude' => ['required', 'numeric'],
]);
$city = City::create([
'name' => $validated['newCityName'],
'country_id' => $validated['newCityCountryId'],
'latitude' => $validated['newCityLatitude'],
'longitude' => $validated['newCityLongitude'],
'slug' => str($validated['newCityName'])->slug(),
'created_by' => auth()->id(),
]);
$this->city_id = $city->id;
$this->reset(['newCityName', 'newCityCountryId', 'newCityLatitude', 'newCityLongitude']);
\Flux\Flux::modal('add-city')->close();
}
public function createMeetup(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255', 'unique:meetups,name'],
'city_id' => ['required', 'exists:cities,id'],
'intro' => ['nullable', 'string'],
'telegram_link' => ['nullable', 'url', 'max:255'],
'webpage' => ['nullable', 'url', 'max:255'],
'twitter_username' => ['nullable', 'string', 'max:255'],
'matrix_group' => ['nullable', 'string', 'max:255'],
'nostr' => ['nullable', 'string', 'max:255'],
'simplex' => ['nullable', 'string', 'max:255'],
'signal' => ['nullable', 'string', 'max:510'],
'community' => ['nullable', 'string', 'max:255'],
'visible_on_map' => ['boolean'],
]);
$meetup = Meetup::create($validated);
if ($this->logo) {
$meetup
->addMedia($this->logo->getRealPath())
->usingName($meetup->name)
->toMediaCollection('logo');
}
session()->flash('status', __('Meetup erfolgreich erstellt!'));
$this->redirect(route_with_country('meetups.edit', ['meetup' => $meetup]), navigate: true);
}
public function with(): array
{
return [
'cities' => City::query()->orderBy('name')->get(),
'countries' => Country::query()->orderBy('countries.name')->get(),
];
}
}; ?>
<div class="max-w-4xl mx-auto p-6">
<flux:heading size="xl" class="mb-8">{{ __('Neues Meetup erstellen') }}</flux:heading>
<form wire:submit="createMeetup" class="space-y-10">
<!-- Basic Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Grundlegende Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:file-upload wire:model="logo">
<!-- Custom logo uploader -->
<div class="
relative flex items-center justify-center size-20 rounded-full transition-colors cursor-pointer
border border-zinc-200 dark:border-white/10 hover:border-zinc-300 dark:hover:border-white/10
bg-zinc-100 hover:bg-zinc-200 dark:bg-white/10 hover:dark:bg-white/15 in-data-dragging:dark:bg-white/15
">
@if($logo)
<img src="{{ $logo?->temporaryUrl() }}" alt="Logo"
class="size-full object-cover rounded-full"/>
@else
<!-- Show the default icon if no file is uploaded -->
<flux:icon name="user-group" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
@endif
<!-- Corner upload icon -->
<div class="absolute bottom-0 right-0 bg-white dark:bg-zinc-800 rounded-full">
<flux:icon name="arrow-up-circle" variant="solid" class="text-zinc-500 dark:text-zinc-400"/>
</div>
</div>
</flux:file-upload>
<flux:field>
<flux:label>{{ __('Name') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="name" required/>
<flux:description>{{ __('Der Anzeigename für dieses Meetup') }}</flux:description>
<flux:error name="name"/>
</flux:field>
<flux:field>
<div class="flex items-center justify-between mb-2">
<flux:label>{{ __('Stadt') }} <span class="text-red-500">*</span></flux:label>
<flux:modal.trigger name="add-city">
<flux:button class="cursor-pointer" size="xs" variant="ghost" icon="plus">
{{ __('Stadt hinzufügen') }}
</flux:button>
</flux:modal.trigger>
</div>
<flux:select variant="listbox" searchable wire:model="city_id"
placeholder="{{ __('Stadt auswählen') }}" required>
<x-slot name="search">
<flux:select.search class="px-4" placeholder="{{ __('Suche passende Stadt...') }}"/>
</x-slot>
@foreach($cities as $city)
<flux:select.option value="{{ $city->id }}">{{ $city->name }} ({{ $city->country->name }})
</flux:select.option>
@endforeach
</flux:select>
<flux:description>{{ __('Die nächstgrößte Stadt oder Ort') }}</flux:description>
<flux:error name="city_id"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Auf Karte sichtbar') }}</flux:label>
<flux:switch wire:model="visible_on_map"/>
<flux:description>{{ __('Soll dieses Meetup auf der Karte angezeigt werden?') }}</flux:description>
</flux:field>
</div>
<flux:field>
<flux:label>{{ __('Einführung') }}</flux:label>
<flux:textarea wire:model="intro" rows="4"/>
<flux:description>{{ __('Kurze Beschreibung des Meetups') }}</flux:description>
<flux:error name="intro"/>
</flux:field>
</flux:fieldset>
<!-- Links and Social Media -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Links & Soziale Medien') }}</flux:legend>
<!-- Primary Links -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Webseite') }}</flux:label>
<flux:input wire:model="webpage" type="url" placeholder="https://example.com"/>
<flux:description>{{ __('Offizielle Webseite oder Landingpage') }}</flux:description>
<flux:error name="webpage"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Telegram Link') }}</flux:label>
<flux:input wire:model="telegram_link" type="url" placeholder="https://t.me/gruppenname"/>
<flux:description>{{ __('Link zur Telegram-Gruppe oder zum Kanal') }}</flux:description>
<flux:error name="telegram_link"/>
</flux:field>
</div>
<!-- Social Media -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Twitter Benutzername') }}</flux:label>
<flux:input wire:model="twitter_username" placeholder="benutzername"/>
<flux:description>{{ __('Twitter-Handle ohne @ Symbol') }}</flux:description>
<flux:error name="twitter_username"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Matrix Gruppe') }}</flux:label>
<flux:input wire:model="matrix_group" placeholder="#gruppe:matrix.org"/>
<flux:description>{{ __('Matrix-Raum Bezeichner oder Link') }}</flux:description>
<flux:error name="matrix_group"/>
</flux:field>
</div>
<!-- Decentralized Platforms -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Nostr') }}</flux:label>
<flux:input wire:model="nostr" placeholder="npub..."/>
<flux:description>{{ __('Nostr öffentlicher Schlüssel oder Bezeichner') }}</flux:description>
<flux:error name="nostr"/>
</flux:field>
</div>
<!-- Messaging Apps -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('SimpleX') }}</flux:label>
<flux:input wire:model="simplex"/>
<flux:description>{{ __('SimpleX Chat Kontaktinformationen') }}</flux:description>
<flux:error name="simplex"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Signal') }}</flux:label>
<flux:input wire:model="signal"/>
<flux:description>{{ __('Signal Kontakt- oder Gruppeninformationen') }}</flux:description>
<flux:error name="signal"/>
</flux:field>
</div>
</flux:fieldset>
<!-- Additional Information -->
<flux:fieldset class="space-y-6">
<flux:legend>{{ __('Zusätzliche Informationen') }}</flux:legend>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<flux:field>
<flux:label>{{ __('Gemeinschaft') }}</flux:label>
<flux:select wire:model="community">
<flux:select.option value="">{{ __('Keine') }}</flux:select.option>
<flux:select.option value="einundzwanzig">einundzwanzig</flux:select.option>
<flux:select.option value="bitcoin">bitcoin</flux:select.option>
</flux:select>
<flux:description>{{ __('Gemeinschafts- oder Organisationsname') }}</flux:description>
<flux:error name="community"/>
</flux:field>
</div>
</flux:fieldset>
<!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }}
</flux:button>
<flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Meetup erstellen') }}
</flux:button>
</div>
</form>
<!-- Add City Modal -->
<flux:modal name="add-city" variant="flyout" wire:key="add-city-modal">
<form wire:submit="createCity" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Stadt hinzufügen') }}</flux:heading>
<flux:text class="mt-2">{{ __('Füge eine neue Stadt zur Datenbank hinzu.') }}</flux:text>
</div>
<flux:field>
<flux:label>{{ __('Stadtname') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="newCityName" placeholder="{{ __('z.B. Berlin') }}" required/>
<flux:error name="newCityName"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Land') }} <span class="text-red-500">*</span></flux:label>
<flux:select variant="listbox" searchable wire:model="newCityCountryId"
placeholder="{{ __('Land auswählen') }}">
@foreach($countries as $country)
<flux:select.option value="{{ $country->id }}">
<div class="flex items-center space-x-2">
<img alt="{{ str($country->code)->lower() }}"
src="{{ asset('vendor/blade-flags/country-'.str($country->code)->lower().'.svg') }}"
width="24" height="12"/>
<span>{{ $country->name }}</span>
</div>
</flux:select.option>
@endforeach
</flux:select>
<flux:error name="newCityCountryId"/>
</flux:field>
<div class="grid grid-cols-2 gap-4">
<flux:field>
<flux:label>{{ __('Breitengrad') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="newCityLatitude" type="number" step="0.000001" placeholder="52.520008"
required/>
<flux:error name="newCityLatitude"/>
</flux:field>
<flux:field>
<flux:label>{{ __('Längengrad') }} <span class="text-red-500">*</span></flux:label>
<flux:input wire:model="newCityLongitude" type="number" step="0.000001" placeholder="13.404954"
required/>
<flux:error name="newCityLongitude"/>
</flux:field>
</div>
<div class="flex gap-2">
<flux:spacer/>
<flux:modal.close>
<flux:button class="cursor-pointer" type="button" variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close>
<flux:button class="cursor-pointer" type="submit" variant="primary">{{ __('Stadt erstellen') }}</flux:button>
</div>
</form>
</flux:modal>
</div>

View File

@@ -212,7 +212,7 @@ new class extends Component {
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<flux:label>{{ __('Stadt') }}</flux:label> <flux:label>{{ __('Stadt') }}</flux:label>
<flux:modal.trigger name="add-city"> <flux:modal.trigger name="add-city">
<flux:button size="xs" variant="ghost" icon="plus"> <flux:button class="cursor-pointer" size="xs" variant="ghost" icon="plus">
{{ __('Stadt hinzufügen') }} {{ __('Stadt hinzufügen') }}
</flux:button> </flux:button>
</flux:modal.trigger> </flux:modal.trigger>
@@ -350,7 +350,7 @@ new class extends Component {
<!-- Form Actions --> <!-- Form Actions -->
<div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700"> <div class="flex items-center justify-between pt-8 border-t border-gray-200 dark:border-gray-700">
<flux:button variant="ghost" type="button" onclick="history.back()"> <flux:button class="cursor-pointer" variant="ghost" type="button" onclick="history.back()">
{{ __('Abbrechen') }} {{ __('Abbrechen') }}
</flux:button> </flux:button>
@@ -361,7 +361,7 @@ new class extends Component {
</flux:text> </flux:text>
@endif @endif
<flux:button variant="primary" type="submit"> <flux:button class="cursor-pointer" variant="primary" type="submit">
{{ __('Meetup aktualisieren') }} {{ __('Meetup aktualisieren') }}
</flux:button> </flux:button>
</div> </div>
@@ -420,10 +420,10 @@ new class extends Component {
<flux:spacer/> <flux:spacer/>
<flux:modal.close> <flux:modal.close>
<flux:button type="button" variant="ghost">{{ __('Abbrechen') }}</flux:button> <flux:button class="cursor-pointer" type="button" variant="ghost">{{ __('Abbrechen') }}</flux:button>
</flux:modal.close> </flux:modal.close>
<flux:button type="submit" variant="primary">{{ __('Stadt erstellen') }}</flux:button> <flux:button class="cursor-pointer" type="submit" variant="primary">{{ __('Stadt erstellen') }}</flux:button>
</div> </div>
</form> </form>
</flux:modal> </flux:modal>

View File

@@ -48,15 +48,20 @@ new class extends Component {
}; ?> }; ?>
<div> <div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between mb-6">
<flux:heading size="xl">{{ __('Meetups') }}</flux:heading> <flux:heading size="xl">{{ __('Meetups') }}</flux:heading>
<flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics') }}'" icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button> <div class="flex items-center gap-4">
<div class="mt-4"> <flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics') }}'" icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button>
<flux:input <flux:input
wire:model.live="search" wire:model.live="search"
:placeholder="__('Suche nach Meetups...')" :placeholder="__('Suche nach Meetups...')"
clearable clearable
/> />
@auth
<flux:button class="cursor-pointer" :href="route_with_country('meetups.create')" icon="plus" variant="primary">
{{ __('Meetup erstellen') }}
</flux:button>
@endauth
</div> </div>
</div> </div>

View File

@@ -193,6 +193,11 @@ new class extends Component {
<div class="mt-16"> <div class="mt-16">
<div class="flex items-center space-x-4 mb-6"> <div class="flex items-center space-x-4 mb-6">
<flux:heading size="xl">{{ __('Kommende Veranstaltungen') }}</flux:heading> <flux:heading size="xl">{{ __('Kommende Veranstaltungen') }}</flux:heading>
@if(auth()->user()->meetups()->find($meetup->id)?->exists)
<flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])" variant="primary" icon="calendar">
{{ __('Neues Event erstellen') }}
</flux:button>
@endif
<flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics', ['meetup' => $meetup]) }}'" icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button> <flux:button class="cursor-pointer" x-copy-to-clipboard="'{{ route('ics', ['meetup' => $meetup]) }}'" icon="calendar-date-range">{{ __('Kalender-Stream-URL kopieren') }}</flux:button>
</div> </div>
@@ -251,5 +256,15 @@ new class extends Component {
@endforeach @endforeach
</div> </div>
</div> </div>
@else
<div class="mt-16">
<div class="flex items-center space-x-4 mb-6">
@if(auth()->user()->meetups()->find($meetup->id)?->exists)
<flux:button :href="route_with_country('meetups.events.create', ['meetup' => $meetup])" variant="primary" icon="calendar">
{{ __('Neues Event erstellen') }}
</flux:button>
@endif
</div>
</div>
@endif @endif
</div> </div>

View File

@@ -13,20 +13,34 @@ Route::get('stream-calendar', \App\Http\Controllers\DownloadMeetupCalendar::clas
Route::middleware([]) Route::middleware([])
->prefix('/{country:code}') ->prefix('/{country:code}')
->group(function () { ->group(function () {
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');
Volt::route('meetup/{meetup:slug}/event/{event}', 'meetups.landingpage-event')->name('meetups.landingpage-event'); Volt::route('meetup/{meetup:slug}/event/{event}', 'meetups.landingpage-event')->name('meetups.landingpage-event');
Volt::route('courses', 'courses.index')->name('courses.index');
Volt::route('course/{course}', 'courses.landingpage')->name('courses.landingpage');
Volt::route('course/{course}/event/{event}', 'courses.landingpage-event')->name('courses.landingpage-event');
Volt::route('lecturers', 'lecturers.index')->name('lecturers.index');
}); });
Route::middleware(['auth']) Route::middleware(['auth'])
->prefix('/{country:code}') ->prefix('/{country:code}')
->group(function () { ->group(function () {
Volt::route('dashboard', 'dashboard')->name('dashboard'); Volt::route('dashboard', 'dashboard')->name('dashboard');
Volt::route('meetup-create', 'meetups.create')->name('meetups.create');
Volt::route('meetup-edit/{meetup}', 'meetups.edit')->name('meetups.edit'); Volt::route('meetup-edit/{meetup}', 'meetups.edit')->name('meetups.edit');
Volt::route('meetup/{meetup}/events/create', 'meetups.create-edit-events')->name('meetups.events.create'); Volt::route('meetup/{meetup}/events/create', 'meetups.create-edit-events')->name('meetups.events.create');
Volt::route('meetup/{meetup}/events/{event}/edit', 'meetups.create-edit-events')->name('meetups.events.edit'); Volt::route('meetup/{meetup}/events/{event}/edit', 'meetups.create-edit-events')->name('meetups.events.edit');
Volt::route('course-create', 'courses.create')->name('courses.create');
Volt::route('course-edit/{course}', 'courses.edit')->name('courses.edit');
Volt::route('course/{course}/events/create', 'courses.create-edit-events')->name('courses.events.create');
Volt::route('course/{course}/events/{event}/edit', 'courses.create-edit-events')->name('courses.events.edit');
Volt::route('lecturer-create', 'lecturers.create')->name('lecturers.create');
Volt::route('lecturer-edit/{lecturer}', 'lecturers.edit')->name('lecturers.edit');
}); });
Route::middleware(['auth']) Route::middleware(['auth'])