🚀 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

@@ -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>