feat(ui): dashboard MVP — feature-folders, lazy skeletons, wire-elements modals, brand logos
Major refactor + UX polish for authenticated dashboard MVP.
Architecture:
- Feature-based folder structure: app/Livewire/<Feature>/{Index, Modals/*}
Dashboard, Subjects, Tasks, PhotoHelp, Rewards, Progress, Notifications, Settings
- Matching view structure: resources/views/livewire/<feature>/{index, modals/*}.blade.php
- Removed legacy Pages/ and Modals/ top-level folders
Modals (wire-elements/modal v3):
- 7 modal components extending LivewireUI\Modal\ModalComponent
- Vendor view override: z-50 (above sidebar z-20), items-center justify-center,
bg-bg-soft surface, role=dialog + aria-modal, design-token-driven backdrop
- Server-side dispatch uses ->to('livewire-ui-modal') for cross-component routing
URL state + skeleton:
- Tasks + Notifications + Settings use #[Url(as, keep:true)] with allowlist
validation in mount()/setSection()/setFilter() to block injection
- Tasks startTask + repeatTask actions dispatch openModal targeted at modal
- All 8 main pages declare #[Lazy] + placeholder() returning a page-level
skeleton (animate-pulse, bg-bg-soft container, bg-line bars) so first paint
is structured instead of blank
Skeleton/no-jump:
- Skeleton container uses bg-bg-soft (matches table card surface) — no color
jump between skeleton and resolved table
- Settings sub-nav renders active state server-side (no Alpine :class flicker)
with @if-server-render instead of x-show
- Font flicker eliminated: font-display: block + <link rel="preload"> on both
layouts for Plus Jakarta Sans + JetBrains Mono
Logos / PWA:
- public/logo/, public/icons/, public/manifest.webmanifest deployed per
Logos/DEPLOY.md
- Brand component (default / wordmark / icon-only variants) with href prop
- Favicon SVG/ICO + apple-touch-icon + manifest tags in dashboard + guest layouts
- Sidebar brand wires to route('dashboard')
Layout / a11y:
- Sidebar fixed left (top-0, h-screen, w-60) with header+footer shrink-0 and
scrollable content
- Topbar sticky with z-10
- Sidebar uses x-layout.brand :href="route('dashboard')"
- Logout: POST /logout route + sidebar-user widget with @csrf form button
Bug fixes:
- Tasks rows are passive divs (not buttons) with explicit info-icon + Start
button — eliminates nested-button invalid HTML
- Tasks Start/Wiederholen buttons wire:click to startTask/repeatTask actions
(previously did nothing)
- Modals centered + above sidebar z-index
- Settings section sub-nav no longer flickers on hydration
- public/hot cleanup: Vite runs as docker compose service with auto-restart
Test infrastructure:
- Pest beforeEach calls Livewire::withoutLazyLoading() so #[Lazy] components
hydrate fully under test
- 157 tests passing, 0 skipped, 0 failed (411 assertions)
- New test files:
AssetReachabilityTest, BrandAssetsTest, FontLoadingTest, LazySkeletonTest,
LogoutTest, ModalLayoutTest, ModalsTest, NoNestedButtonsTest,
SettingsNavTest, SidebarPagesTest, TasksStartButtonTest, UrlStateTest
Config:
- config/sidebar.php as single source of truth for sidebar nav items
- config/reverb.php allowed_origins set
- Test DB isolated: phpunit.xml DB_DATABASE=lernschiff_test +
tests/TestCase.php refreshApplication override
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main
|
|
@ -0,0 +1,8 @@
|
||||||
|
APP_ENV=testing
|
||||||
|
APP_KEY=base64:olpsnUi1o5DBLbA0lK2LCJiCnhMPldp7Pl3kuj1Iz7A=
|
||||||
|
DB_CONNECTION=mysql
|
||||||
|
DB_HOST=mysql
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=lernschiff_test
|
||||||
|
DB_USERNAME=lernschiff
|
||||||
|
DB_PASSWORD=secret
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Dashboard;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'dashboard'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public int $totalPoints = 2480;
|
||||||
|
public int $streakDays = 7;
|
||||||
|
public int $weekMinutes = 86;
|
||||||
|
public string $testRatio = '18 / 24';
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
$firstName = $user ? explode(' ', $user->name)[0] : 'Lina';
|
||||||
|
|
||||||
|
return view('livewire.dashboard.index', [
|
||||||
|
'firstName' => $firstName,
|
||||||
|
'today' => Carbon::now()->locale('de')->isoFormat('dddd · D. MMMM'),
|
||||||
|
'subjects' => $this->subjects(),
|
||||||
|
'tasks' => $this->tasks(),
|
||||||
|
'rewards' => $this->rewards(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function subjects(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['name' => 'Mathematik', 'icon' => '∑', 'color' => 'primary', 'lessons' => '18 / 24', 'progress' => 75, 'next' => 'Schriftliches Multiplizieren'],
|
||||||
|
['name' => 'Deutsch', 'icon' => 'A', 'color' => 'rose', 'lessons' => '13 / 20', 'progress' => 65, 'next' => 'Wortarten erkennen'],
|
||||||
|
['name' => 'Englisch', 'icon' => 'E', 'color' => 'violet', 'lessons' => '8 / 16', 'progress' => 50, 'next' => 'Past Simple üben'],
|
||||||
|
['name' => 'Sachkunde', 'icon' => '🌱', 'color' => 'green', 'lessons' => '14 / 18', 'progress' => 78, 'next' => 'Wasserkreislauf'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tasks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['title' => 'Multiplikation bis 100', 'meta' => 'Mathematik · 12 Aufgaben · 15 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 120],
|
||||||
|
['title' => 'Wortarten: Nomen & Verben', 'meta' => 'Deutsch · Lese-Aufgabe · 10 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 80],
|
||||||
|
['title' => 'Vocab Review: Animals', 'meta' => 'Englisch · Karteikarten · 8 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 60],
|
||||||
|
['title' => 'Kreislauf des Wassers', 'meta' => 'Sachkunde · Video + Quiz · 12 Min.', 'icon' => '🌱', 'color' => 'green', 'points' => 100],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rewards(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['title' => 'Avatar-Brille (gold)', 'emoji' => '🎨', 'status' => 'Verfügbar', 'price' => 800, 'available' => true],
|
||||||
|
['title' => 'Einhorn-Begleiter', 'emoji' => '🦄', 'status' => 'Verfügbar', 'price' => 1500, 'available' => true],
|
||||||
|
['title' => 'Raketen-Theme', 'emoji' => '🚀', 'status' => 'Noch 720 Pkt. nötig', 'price' => 3200, 'available' => false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Notifications;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'notifications'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[Url(as: 'filter', keep: true)]
|
||||||
|
public string $filter = 'all';
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
protected array $allowedFilters = ['all', 'unread'];
|
||||||
|
|
||||||
|
public array $readKeys = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! in_array($this->filter, $this->allowedFilters, true)) {
|
||||||
|
$this->filter = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFilter(string $filter): void
|
||||||
|
{
|
||||||
|
if (in_array($filter, $this->allowedFilters, true)) {
|
||||||
|
$this->filter = $filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRead(string $key): void
|
||||||
|
{
|
||||||
|
if (! in_array($key, $this->readKeys, true)) {
|
||||||
|
$this->readKeys[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markAllRead(): void
|
||||||
|
{
|
||||||
|
$this->readKeys = array_column($this->notifications(), 'key');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function notifications(): array
|
||||||
|
{
|
||||||
|
$items = [
|
||||||
|
['key' => 'n1', 'title' => 'Heutige Aufgaben warten', 'body' => 'Du hast noch 4 offene Aufgaben für heute. Streak nicht brechen!', 'time' => 'vor 10 Min.', 'icon' => 'clock', 'color' => 'primary', 'baseRead' => false],
|
||||||
|
['key' => 'n2', 'title' => 'Neuer Rekord erreicht!', 'body' => 'Du hast 7 Tage am Stück gelernt — neuer persönlicher Rekord.', 'time' => 'vor 2 Std.', 'icon' => 'trophy', 'color' => 'amber', 'baseRead' => false],
|
||||||
|
['key' => 'n3', 'title' => 'Nachricht von Frau Müller', 'body' => 'Hallo Lina, super Arbeit gestern! Schau dir die Zusatzaufgabe an.', 'time' => 'vor 4 Std.', 'icon' => 'chat-bubble-left-right', 'color' => 'rose', 'baseRead' => false],
|
||||||
|
['key' => 'n4', 'title' => 'Neues Foto-Hilfe-Feature', 'body' => 'Du kannst jetzt Aufgaben fotografieren und die KI erklärt den Weg.', 'time' => 'gestern', 'icon' => 'sparkles', 'color' => 'violet', 'baseRead' => true],
|
||||||
|
['key' => 'n5', 'title' => 'Mathematik: 75% erreicht', 'body' => 'Du hast 18 von 24 Lektionen geschafft. Noch 6 bis zum Abschluss.', 'time' => '2 Tage', 'icon' => 'check-badge', 'color' => 'green', 'baseRead' => true],
|
||||||
|
['key' => 'n6', 'title' => 'Wochenende Übung', 'body' => 'Auch am Wochenende lernen hilft beim Streak. Heute 10 Minuten.', 'time' => '3 Tage', 'icon' => 'calendar-days', 'color' => 'primary', 'baseRead' => true],
|
||||||
|
];
|
||||||
|
|
||||||
|
return array_map(function ($n) {
|
||||||
|
$n['read'] = $n['baseRead'] || in_array($n['key'], $this->readKeys, true);
|
||||||
|
return $n;
|
||||||
|
}, $items);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function filteredNotifications(): array
|
||||||
|
{
|
||||||
|
if ($this->filter === 'unread') {
|
||||||
|
return array_values(array_filter($this->notifications, fn($n) => ! $n['read']));
|
||||||
|
}
|
||||||
|
return $this->notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function counts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'all' => count($this->notifications),
|
||||||
|
'unread' => count(array_filter($this->notifications, fn($n) => ! $n['read'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.notifications.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Notifications\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Detail extends ModalComponent
|
||||||
|
{
|
||||||
|
public array $notification = [];
|
||||||
|
|
||||||
|
public function mount(array $notification = []): void
|
||||||
|
{
|
||||||
|
$this->notification = $notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.notifications.modals.detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\PhotoHelp;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'photo'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.photo-help.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\PhotoHelp\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Result extends ModalComponent
|
||||||
|
{
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return '2xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.photo-help.modals.result');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Progress;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'progress'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.progress.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Rewards;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'rewards'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.rewards.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Rewards\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Purchase extends ModalComponent
|
||||||
|
{
|
||||||
|
public array $item = [];
|
||||||
|
public int $balance = 0;
|
||||||
|
public bool $confirming = false;
|
||||||
|
public bool $purchased = false;
|
||||||
|
|
||||||
|
public function mount(array $item = [], int $balance = 0): void
|
||||||
|
{
|
||||||
|
$this->item = $item;
|
||||||
|
$this->balance = $balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirm(): void
|
||||||
|
{
|
||||||
|
$this->confirming = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchase(): void
|
||||||
|
{
|
||||||
|
if (($this->item['price'] ?? 0) > $this->balance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->balance -= (int) $this->item['price'];
|
||||||
|
$this->purchased = true;
|
||||||
|
$this->confirming = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.rewards.modals.purchase');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'settings'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[Url(as: 'section', keep: true)]
|
||||||
|
public string $section = 'profile';
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
protected array $allowedSections = ['profile', 'language', 'security', 'notifications', 'danger'];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! in_array($this->section, $this->allowedSections, true)) {
|
||||||
|
$this->section = 'profile';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSection(string $section): void
|
||||||
|
{
|
||||||
|
if (in_array($section, $this->allowedSections, true)) {
|
||||||
|
$this->section = $section;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.settings.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Settings\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Delete extends ModalComponent
|
||||||
|
{
|
||||||
|
public string $confirmText = '';
|
||||||
|
|
||||||
|
public function delete(): void
|
||||||
|
{
|
||||||
|
if ($this->confirmText !== 'LÖSCHEN') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.settings.modals.delete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Settings\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class LogoutAll extends ModalComponent
|
||||||
|
{
|
||||||
|
public function logoutAll(): void
|
||||||
|
{
|
||||||
|
// Real implementation needs current password to invalidate other sessions.
|
||||||
|
// Placeholder: close modal — actual session-cleanup wired in future MVP.
|
||||||
|
$this->closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.settings.modals.logout-all');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Subjects;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'subjects'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 4]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.subjects.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Subjects\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Detail extends ModalComponent
|
||||||
|
{
|
||||||
|
public array $subject = [];
|
||||||
|
|
||||||
|
public function mount(array $subject = []): void
|
||||||
|
{
|
||||||
|
$this->subject = $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.subjects.modals.detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Tasks;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Attributes\Url;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('layouts.dashboard', ['active' => 'tasks'])]
|
||||||
|
#[Lazy]
|
||||||
|
class Index extends Component
|
||||||
|
{
|
||||||
|
public static function placeholder(array $params = []): View
|
||||||
|
{
|
||||||
|
return view('components.skeleton.page', ['rows' => 5]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[Url(as: 'filter', keep: true)]
|
||||||
|
public string $filter = 'open';
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
protected array $allowedFilters = ['open', 'done', 'all'];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! in_array($this->filter, $this->allowedFilters, true)) {
|
||||||
|
$this->filter = 'open';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFilter(string $filter): void
|
||||||
|
{
|
||||||
|
if (in_array($filter, $this->allowedFilters, true)) {
|
||||||
|
$this->filter = $filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var array<int,string> */
|
||||||
|
public array $startedKeys = [];
|
||||||
|
|
||||||
|
public function startTask(string $key): void
|
||||||
|
{
|
||||||
|
$task = collect($this->tasks)->firstWhere('key', $key);
|
||||||
|
if ($task === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($key, $this->startedKeys, true)) {
|
||||||
|
$this->startedKeys[] = $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future: redirect to a real lesson runner. For now open detail modal.
|
||||||
|
// Must target wire-elements modal component explicitly — Livewire 4
|
||||||
|
// server-side dispatch does not auto-broadcast to other components.
|
||||||
|
$this->dispatch('openModal', component: 'tasks.modals.detail', arguments: ['task' => $task])
|
||||||
|
->to('livewire-ui-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function repeatTask(string $key): void
|
||||||
|
{
|
||||||
|
$this->startTask($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function tasks(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['key' => 't1', 'title' => 'Multiplikation bis 100', 'meta' => 'Mathematik · 12 Aufgaben · 15 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 120, 'status' => 'open', 'due' => 'Heute'],
|
||||||
|
['key' => 't2', 'title' => 'Wortarten: Nomen & Verben', 'meta' => 'Deutsch · Lese-Aufgabe · 10 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 80, 'status' => 'open', 'due' => 'Heute'],
|
||||||
|
['key' => 't3', 'title' => 'Vocab Review: Animals', 'meta' => 'Englisch · Karteikarten · 8 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 60, 'status' => 'open', 'due' => 'Heute'],
|
||||||
|
['key' => 't4', 'title' => 'Kreislauf des Wassers', 'meta' => 'Sachkunde · Video + Quiz · 12 Min.', 'icon' => '🌱', 'color' => 'green', 'points' => 100, 'status' => 'open', 'due' => 'Heute'],
|
||||||
|
['key' => 't5', 'title' => 'Geometrie: Flächen', 'meta' => 'Mathematik · Übung · 20 Min.', 'icon' => '∑', 'color' => 'primary', 'points' => 150, 'status' => 'open', 'due' => 'Morgen'],
|
||||||
|
['key' => 't6', 'title' => 'Gedicht lernen', 'meta' => 'Deutsch · Lese-Aufgabe · 15 Min.', 'icon' => 'A', 'color' => 'rose', 'points' => 90, 'status' => 'done', 'due' => 'Gestern'],
|
||||||
|
['key' => 't7', 'title' => 'Past Simple Übung', 'meta' => 'Englisch · Übung · 10 Min.', 'icon' => 'E', 'color' => 'violet', 'points' => 70, 'status' => 'done', 'due' => 'Gestern'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function filteredTasks(): array
|
||||||
|
{
|
||||||
|
if ($this->filter === 'all') {
|
||||||
|
return $this->tasks;
|
||||||
|
}
|
||||||
|
return array_values(array_filter($this->tasks, fn($t) => $t['status'] === $this->filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function counts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'open' => count(array_filter($this->tasks, fn($t) => $t['status'] === 'open')),
|
||||||
|
'done' => count(array_filter($this->tasks, fn($t) => $t['status'] === 'done')),
|
||||||
|
'all' => count($this->tasks),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.tasks.index');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Tasks\Modals;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use LivewireUI\Modal\ModalComponent;
|
||||||
|
|
||||||
|
class Detail extends ModalComponent
|
||||||
|
{
|
||||||
|
public array $task = [];
|
||||||
|
|
||||||
|
public function mount(array $task = []): void
|
||||||
|
{
|
||||||
|
$this->task = $task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function modalMaxWidth(): string
|
||||||
|
{
|
||||||
|
return 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.tasks.modals.detail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,16 @@ class User extends Authenticatable
|
||||||
return $this->role->contains('name', $role);
|
return $this->role->contains('name', $role);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function hasAnyRole(array $roles): bool
|
||||||
|
{
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
if ($this->hasRole($role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public function tenant()
|
public function tenant()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Tenant::class);
|
return $this->belongsTo(Tenant::class);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"laravel/reverb": "^1.10",
|
"laravel/reverb": "^1.10",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"livewire/livewire": "^4.0",
|
"livewire/livewire": "^4.0",
|
||||||
"livewire/volt": "^1.7.0"
|
"livewire/volt": "^1.7.0",
|
||||||
|
"wire-elements/modal": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|
|
||||||
|
|
@ -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": "bdd78351d73171465c3922dafb001ba4",
|
"content-hash": "eac2e89f7c819b5d6fbfe22b5c260f04",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "blade-ui-kit/blade-heroicons",
|
"name": "blade-ui-kit/blade-heroicons",
|
||||||
|
|
@ -4503,6 +4503,67 @@
|
||||||
],
|
],
|
||||||
"time": "2024-06-11T12:45:25+00:00"
|
"time": "2024-06-11T12:45:25+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "spatie/laravel-package-tools",
|
||||||
|
"version": "1.93.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/spatie/laravel-package-tools.git",
|
||||||
|
"reference": "d5552849801f2642aea710557463234b59ef65eb"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
|
||||||
|
"reference": "d5552849801f2642aea710557463234b59ef65eb",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.5",
|
||||||
|
"orchestra/testbench": "^8.0|^9.2|^10.0|^11.0",
|
||||||
|
"pestphp/pest": "^2.1|^3.1|^4.0",
|
||||||
|
"phpunit/php-code-coverage": "^10.0|^11.0|^12.0",
|
||||||
|
"phpunit/phpunit": "^10.5|^11.5|^12.5",
|
||||||
|
"spatie/pest-plugin-test-time": "^2.2|^3.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Spatie\\LaravelPackageTools\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Freek Van der Herten",
|
||||||
|
"email": "freek@spatie.be",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Tools for creating Laravel packages",
|
||||||
|
"homepage": "https://github.com/spatie/laravel-package-tools",
|
||||||
|
"keywords": [
|
||||||
|
"laravel-package-tools",
|
||||||
|
"spatie"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/spatie/laravel-package-tools/issues",
|
||||||
|
"source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/spatie",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-19T14:06:37+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
|
|
@ -7177,6 +7238,70 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-04-26T05:33:54+00:00"
|
"time": "2026-04-26T05:33:54+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "wire-elements/modal",
|
||||||
|
"version": "3.0.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/wire-elements/modal.git",
|
||||||
|
"reference": "3c17ed4e5d1506773db37dbbfdcacff12b037317"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/wire-elements/modal/zipball/3c17ed4e5d1506773db37dbbfdcacff12b037317",
|
||||||
|
"reference": "3c17ed4e5d1506773db37dbbfdcacff12b037317",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"livewire/livewire": "^3.2.3|^4.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"spatie/laravel-package-tools": "^1.9"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"orchestra/testbench": "^8.5",
|
||||||
|
"phpunit/phpunit": "^9.5"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"LivewireUI\\Modal\\LivewireModalServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"LivewireUI\\Modal\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Philo Hermans",
|
||||||
|
"email": "me@philohermans.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Livewire modal component",
|
||||||
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"livewire",
|
||||||
|
"modal"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/wire-elements/modal/issues",
|
||||||
|
"source": "https://github.com/wire-elements/modal/tree/3.0.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://github.com/PhiloNL",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-23T11:08:48+00:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ return [
|
||||||
'scheme' => env('REVERB_SCHEME', 'https'),
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
],
|
],
|
||||||
'allowed_origins' => ['https://app.dev.lernschiff.com'],
|
'allowed_origins' => ['app.dev.lernschiff.com'],
|
||||||
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar Navigation Konfiguration — Single Source of Truth.
|
||||||
|
*
|
||||||
|
* Struktur:
|
||||||
|
* groups[]
|
||||||
|
* label — Sektionstitel
|
||||||
|
* items[]
|
||||||
|
* key — Active-State-Kennung (matched via Sidebar :active="...")
|
||||||
|
* route — Route-Name (Laravel route())
|
||||||
|
* label — Display-Text
|
||||||
|
* icon — Heroicon-Name (ohne `heroicon-o-` Prefix)
|
||||||
|
* badge — optional, numerisch oder string, in Pill rendert
|
||||||
|
* roles — optional, Array; wenn gesetzt nur für User mit einer dieser Rollen
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
'groups' => [
|
||||||
|
[
|
||||||
|
'label' => 'Hauptmenü',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'key' => 'dashboard',
|
||||||
|
'route' => 'dashboard',
|
||||||
|
'label' => 'Startseite',
|
||||||
|
'icon' => 'home',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'subjects',
|
||||||
|
'route' => 'subjects',
|
||||||
|
'label' => 'Meine Fächer',
|
||||||
|
'icon' => 'book-open',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'tasks',
|
||||||
|
'route' => 'tasks',
|
||||||
|
'label' => 'Aufgaben',
|
||||||
|
'icon' => 'clipboard-document-check',
|
||||||
|
'badge' => 4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'photo',
|
||||||
|
'route' => 'photo',
|
||||||
|
'label' => 'Foto-Hilfe',
|
||||||
|
'icon' => 'camera',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'rewards',
|
||||||
|
'route' => 'rewards',
|
||||||
|
'label' => 'Belohnungen',
|
||||||
|
'icon' => 'trophy',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'progress',
|
||||||
|
'route' => 'progress',
|
||||||
|
'label' => 'Fortschritt',
|
||||||
|
'icon' => 'chart-bar',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'Mehr',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'key' => 'notifications',
|
||||||
|
'route' => 'notifications',
|
||||||
|
'label' => 'Mitteilungen',
|
||||||
|
'icon' => 'bell',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'settings',
|
||||||
|
'route' => 'settings',
|
||||||
|
'label' => 'Einstellungen',
|
||||||
|
'icon' => 'cog-6-tooth',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -26,8 +26,9 @@ class MusterschuleSeeder extends Seeder
|
||||||
$users = [
|
$users = [
|
||||||
['email' => 'schuladmin@musterschule.at', 'name' => 'Schuladmin Muster', 'role' => 'school-admin'],
|
['email' => 'schuladmin@musterschule.at', 'name' => 'Schuladmin Muster', 'role' => 'school-admin'],
|
||||||
['email' => 'lehrer@musterschule.at', 'name' => 'Lehrerin Muster', 'role' => 'teacher'],
|
['email' => 'lehrer@musterschule.at', 'name' => 'Lehrerin Muster', 'role' => 'teacher'],
|
||||||
['email' => 'kind1@musterschule.at', 'name' => 'Kind 1', 'role' => 'child'],
|
['email' => 'eltern@musterschule.at', 'name' => 'Elternteil Muster', 'role' => 'parent'],
|
||||||
['email' => 'kind2@musterschule.at', 'name' => 'Kind 2', 'role' => 'child'],
|
['email' => 'leon@musterschule.at', 'name' => 'Leon Muster', 'role' => 'child'],
|
||||||
|
['email' => 'mia@musterschule.at', 'name' => 'Mia Muster', 'role' => 'child'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($users as $data) {
|
foreach ($users as $data) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,21 @@ services:
|
||||||
depends_on: [app]
|
depends_on: [app]
|
||||||
networks: [lernschiff_net]
|
networks: [lernschiff_net]
|
||||||
|
|
||||||
|
vite:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/app/Dockerfile
|
||||||
|
args:
|
||||||
|
UID: 1000
|
||||||
|
GID: 1000
|
||||||
|
working_dir: /var/www/html
|
||||||
|
command: sh -c "[ -d node_modules ] || npm install --silent; npm run dev -- --host 0.0.0.0"
|
||||||
|
volumes:
|
||||||
|
- .:/var/www/html
|
||||||
|
env_file: .env
|
||||||
|
networks: [lernschiff_net]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
queue:
|
queue:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|
@ -77,9 +92,9 @@ services:
|
||||||
args:
|
args:
|
||||||
UID: 1000
|
UID: 1000
|
||||||
GID: 1000
|
GID: 1000
|
||||||
command: php artisan reverb:start --host=0.0.0.0 --port=8081
|
command: php artisan reverb:start --host=0.0.0.0 --port=8080
|
||||||
ports:
|
ports:
|
||||||
- "8080:8081"
|
- "8080:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html
|
- .:/var/www/html
|
||||||
- app_storage:/var/www/html/storage/app
|
- app_storage:/var/www/html/storage/app
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,35 @@ server {
|
||||||
root /var/www/html/public;
|
root /var/www/html/public;
|
||||||
index index.php;
|
index index.php;
|
||||||
|
|
||||||
|
# Vite HMR WebSocket
|
||||||
|
location /vite-hmr {
|
||||||
|
proxy_pass http://vite:5173/vite-hmr;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vite dev asset paths — prefix locations with ^~ to skip regex eval
|
||||||
|
location ^~ /resources/ {
|
||||||
|
proxy_pass http://vite:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /node_modules/ {
|
||||||
|
proxy_pass http://vite:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/(@vite|@id|@fs|@react-refresh)/ {
|
||||||
|
proxy_pass http://vite:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.php?$query_string;
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
# Button Spinner Overlay — Design
|
||||||
|
|
||||||
|
**Datum:** 2026-05-22
|
||||||
|
**Scope:** Auth-Page Buttons (Login, Confirm-Password, Forgot-Password, Reset-Password, Verify-Email)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Aktuelles Loading-Pattern der Auth-Buttons:
|
||||||
|
- Text + Icon werden via `wire:loading.remove` ausgeblendet
|
||||||
|
- Spinner + "Wird geladen…"-Text werden via `wire:loading` eingeblendet
|
||||||
|
- Button-Größe ist stabil, aber UX wirkt unruhig (Text wechselt komplett)
|
||||||
|
- Spinner steht links neben Loading-Text, nicht zentriert
|
||||||
|
|
||||||
|
## Anforderungen
|
||||||
|
|
||||||
|
1. **Spinner zentriert als Overlay** über dem Button-Inhalt
|
||||||
|
2. **Button-Größe stabil** beim Wechsel Loading/Idle
|
||||||
|
3. **Text bekommt `opacity-50`** beim Laden, damit Spinner besser sichtbar ist
|
||||||
|
4. **Konsistentes Pattern** über alle Auth-Buttons
|
||||||
|
|
||||||
|
## Lösungsansatz
|
||||||
|
|
||||||
|
`relative`/`absolute` Positionierung — Text bleibt im Document-Flow, Spinner wird darüber gelegt.
|
||||||
|
|
||||||
|
### Markup-Pattern
|
||||||
|
|
||||||
|
```html
|
||||||
|
<x-primary-button class="w-full relative" wire:loading.attr="disabled">
|
||||||
|
<!-- Text-Layer: immer sichtbar, dimmt beim Laden -->
|
||||||
|
<span wire:loading.class="opacity-50"
|
||||||
|
wire:target="<method>"
|
||||||
|
class="flex items-center gap-2 transition-opacity">
|
||||||
|
<x-heroicon-o-... class="w-4 h-4" />
|
||||||
|
Button-Text
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- Spinner-Layer: absolute overlay, nur beim Laden sichtbar -->
|
||||||
|
<span wire:loading
|
||||||
|
wire:target="<method>"
|
||||||
|
class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<svg class="animate-spin w-4 h-4 text-white" ...>
|
||||||
|
<!-- Spinner-Path -->
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</x-primary-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key-Mechanismen
|
||||||
|
|
||||||
|
| Aspekt | Implementierung |
|
||||||
|
|--------|-----------------|
|
||||||
|
| Button-Größe stabil | Text-Span bleibt immer im Flow (kein `display:none`) |
|
||||||
|
| Spinner zentriert | `absolute inset-0 flex items-center justify-center` |
|
||||||
|
| Text dimmt | `wire:loading.class="opacity-50"` + `transition-opacity` |
|
||||||
|
| Spinner über Text | `absolute` positioniert auf `relative` Button |
|
||||||
|
| Disabled-State | `wire:loading.attr="disabled"` (unverändert) |
|
||||||
|
|
||||||
|
## Scope — 5 Dateien
|
||||||
|
|
||||||
|
| Datei | Aktion |
|
||||||
|
|-------|--------|
|
||||||
|
| `livewire/pages/auth/login.blade.php` | Pattern umbauen |
|
||||||
|
| `livewire/pages/auth/confirm-password.blade.php` | Pattern umbauen |
|
||||||
|
| `livewire/pages/auth/forgot-password.blade.php` | Pattern umbauen |
|
||||||
|
| `livewire/pages/auth/reset-password.blade.php` | Pattern umbauen |
|
||||||
|
| `livewire/pages/auth/verify-email.blade.php` | Spinner neu hinzufügen (gleiches Pattern) |
|
||||||
|
|
||||||
|
`primary-button.blade.php` bleibt unverändert (Slot-basiert).
|
||||||
|
|
||||||
|
## Nicht im Scope
|
||||||
|
|
||||||
|
- Andere Buttons im Codebase (z.B. Dashboard, Settings) — nur Auth-Pages
|
||||||
|
- Änderungen an `primary-button.blade.php` selbst — Pattern wird per Page erstellt
|
||||||
|
- Backend-Logic, Routes, Auth-Flow — reine UI-Änderung
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Bestehende 46 Tests (Pest) laufen unverändert durch
|
||||||
|
- Keine neuen Tests nötig (UI-only)
|
||||||
|
- Manuelle Verifikation: Login-Button im Browser, Submit auslösen, Spinner-Position prüfen
|
||||||
|
|
||||||
|
## Verifikation nach Implementation
|
||||||
|
|
||||||
|
1. Curl: Login-HTML enthält `relative` + `absolute inset-0` Klassen
|
||||||
|
2. Browser: Spinner mittig sichtbar beim Submit, Text dimmt, Button springt nicht
|
||||||
|
3. `php artisan test`: 46 passed
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "lernschiff",
|
"name": "html",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
|
"pusher-js": "^8.5.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
|
@ -2440,6 +2444,13 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||||
|
|
@ -3299,7 +3310,6 @@
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
|
|
@ -3414,6 +3424,30 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.5.tgz",
|
||||||
|
"integrity": "sha512-QCwxUDULPlXv8F6tqMMKx5dNkTe6OaBYRMPYeXKBlyOoKvAmE0ac6pW7fFhSscJ/5SI7666/U/B+MElbsrJlIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.20.1",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.22.0",
|
"version": "5.22.0",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
|
||||||
|
|
@ -4600,6 +4634,19 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-echo": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-rpALCIK1uw2SrttcK9P5JzItt5I85RcfXQKUNnkcorzhtKeXi5GS0PVFFBH8ppNo8wnbdBKuD1EtIHgTbXo9FQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pusher-js": "*",
|
||||||
|
"socket.io-client": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.1.0.tgz",
|
||||||
|
|
@ -4972,7 +5019,6 @@
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
|
|
@ -5199,6 +5245,15 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.5.0.tgz",
|
||||||
|
"integrity": "sha512-V7uzGi9bqOOOyM/6IkJdpFyjGZj7llz1v0oWnYkZKcYLvbz6VcHVLmzKqkvegjuMumpfIEKGLmWHwFb39XFCpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
|
|
@ -5698,6 +5753,36 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.8.0-beta.0",
|
"version": "0.8.0-beta.0",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz",
|
||||||
|
|
@ -6039,6 +6124,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/type-fest": {
|
"node_modules/type-fest": {
|
||||||
"version": "0.16.0",
|
"version": "0.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
|
||||||
|
|
@ -6753,6 +6844,37 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,5 +13,9 @@
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vite-plugin-pwa": "^1.3.0"
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"laravel-echo": "^2.3.4",
|
||||||
|
"pusher-js": "^8.5.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 43 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none">
|
||||||
|
<rect width="512" height="512" rx="112" fill="oklch(0.97 0.005 85)"/>
|
||||||
|
<path d="M80 336 L432 336 L368 432 L144 432 Z" fill="oklch(0.50 0.18 255)"/>
|
||||||
|
<path d="M144 96 L256 160 L256 336 L144 336 Z" fill="oklch(0.42 0.22 255)"/>
|
||||||
|
<path d="M256 160 L368 96 L368 336 L256 336 Z" fill="oklch(0.60 0.16 255)"/>
|
||||||
|
<line x1="256" y1="96" x2="256" y2="336" stroke="oklch(0.22 0.015 270)" stroke-width="16"/>
|
||||||
|
<path d="M256 192 L272 240 L320 248 L272 256 L256 304 L240 256 L192 248 L240 240 Z" fill="oklch(0.97 0.005 85)" opacity="0.9"/>
|
||||||
|
<circle cx="256" cy="248" r="12" fill="oklch(0.22 0.015 270)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 681 B |
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="oklch(0.97 0.005 85)"/>
|
||||||
|
<path d="M10 42 L54 42 L46 54 L18 54 Z" fill="oklch(0.50 0.18 255)"/>
|
||||||
|
<path d="M18 12 L32 20 L32 42 L18 42 Z" fill="oklch(0.42 0.22 255)"/>
|
||||||
|
<path d="M32 20 L46 12 L46 42 L32 42 Z" fill="oklch(0.60 0.16 255)"/>
|
||||||
|
<line x1="32" y1="12" x2="32" y2="42" stroke="oklch(0.22 0.015 270)" stroke-width="2"/>
|
||||||
|
<path d="M32 24 L34 30 L40 31 L34 32 L32 38 L30 32 L24 31 L30 30 Z" fill="oklch(0.97 0.005 85)" opacity="0.85"/>
|
||||||
|
<circle cx="32" cy="31" r="1.4" fill="oklch(0.22 0.015 270)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 635 B |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<path d="M10 42 L54 42 L46 54 L18 54 Z" fill="currentColor"/>
|
||||||
|
<path d="M18 12 L32 20 L32 42 L18 42 Z" fill="currentColor" opacity="0.85"/>
|
||||||
|
<path d="M32 20 L46 12 L46 42 L32 42 Z" fill="currentColor" opacity="0.7"/>
|
||||||
|
<line x1="32" y1="12" x2="32" y2="42" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<path d="M32 24 L34 30 L40 31 L34 32 L32 38 L30 32 L24 31 L30 30 Z" fill="white" opacity="0.95"/>
|
||||||
|
<circle cx="32" cy="31" r="1.4" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 538 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<rect width="64" height="64" rx="14" fill="oklch(0.97 0.005 85)"/>
|
||||||
|
<path d="M10 42 L54 42 L46 54 L18 54 Z" fill="oklch(0.50 0.18 255)"/>
|
||||||
|
<path d="M18 12 L32 20 L32 42 L18 42 Z" fill="oklch(0.42 0.22 255)"/>
|
||||||
|
<path d="M32 20 L46 12 L46 42 L32 42 Z" fill="oklch(0.60 0.16 255)"/>
|
||||||
|
<line x1="32" y1="12" x2="32" y2="42" stroke="oklch(0.22 0.015 270)" stroke-width="2"/>
|
||||||
|
<path d="M32 24 L34 30 L40 31 L34 32 L32 38 L30 32 L24 31 L30 30 Z" fill="oklch(0.97 0.005 85)" opacity="0.85"/>
|
||||||
|
<circle cx="32" cy="31" r="1.4" fill="oklch(0.22 0.015 270)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 635 B |
|
|
@ -0,0 +1,10 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 64" fill="none">
|
||||||
|
<path d="M8 44 L56 44 L48 56 L16 56 Z" fill="oklch(0.50 0.18 255)"/>
|
||||||
|
<path d="M16 14 L32 22 L32 44 L16 44 Z" fill="oklch(0.42 0.22 255)"/>
|
||||||
|
<path d="M32 22 L48 14 L48 44 L32 44 Z" fill="oklch(0.60 0.16 255)"/>
|
||||||
|
<line x1="32" y1="14" x2="32" y2="44" stroke="oklch(0.22 0.015 270)" stroke-width="1.5"/>
|
||||||
|
<path d="M32 26 L34 32 L40 33 L34 34 L32 40 L30 34 L24 33 L30 32 Z" fill="oklch(0.97 0.005 85)" opacity="0.85"/>
|
||||||
|
<circle cx="32" cy="33" r="1.3" fill="oklch(0.22 0.015 270)"/>
|
||||||
|
<path d="M4 60 Q14 56 24 60 T44 60 T64 60" stroke="oklch(0.50 0.18 255)" stroke-width="2" stroke-linecap="round" fill="none" opacity="0.5"/>
|
||||||
|
<text x="76" y="42" font-family="Plus Jakarta Sans, system-ui, sans-serif" font-weight="800" font-size="28" letter-spacing="-0.02em" fill="oklch(0.22 0.015 270)">Lernschiff</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 893 B |
|
|
@ -2,21 +2,31 @@
|
||||||
"name": "Lernschiff",
|
"name": "Lernschiff",
|
||||||
"short_name": "Lernschiff",
|
"short_name": "Lernschiff",
|
||||||
"description": "Kinderlernplattform für 3–14 Jahre",
|
"description": "Kinderlernplattform für 3–14 Jahre",
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#f0eee9",
|
|
||||||
"theme_color": "#f0eee9",
|
|
||||||
"lang": "de",
|
"lang": "de",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f5f3ee",
|
||||||
|
"theme_color": "#f5f3ee",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-192.png",
|
"src": "/icons/icon-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icons/icon-512.png",
|
"src": "/icons/icon-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512-maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,47 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-bg-base: #f0eee9;
|
/* === Background-Layers === */
|
||||||
|
--color-bg-base: #f5f3ee;
|
||||||
|
--color-bg-soft: #ffffff;
|
||||||
|
--color-bg-elev: #fafaf7;
|
||||||
|
--color-bg-tint: #f0eee9;
|
||||||
|
|
||||||
|
/* === Ink-Hierarchie === */
|
||||||
|
--color-ink-1: oklch(0.22 0.015 270);
|
||||||
|
--color-ink-2: oklch(0.42 0.012 270);
|
||||||
|
--color-ink-3: oklch(0.58 0.01 270);
|
||||||
|
--color-ink-4: oklch(0.72 0.008 270);
|
||||||
|
|
||||||
|
/* === Lines / Borders === */
|
||||||
|
--color-line: oklch(0.92 0.006 85);
|
||||||
|
--color-line-strong: oklch(0.85 0.008 85);
|
||||||
|
|
||||||
|
/* === Brand / Primary === */
|
||||||
|
--color-primary: oklch(0.50 0.18 255);
|
||||||
|
--color-primary-soft: oklch(0.95 0.04 255);
|
||||||
|
--color-primary-ink: oklch(0.42 0.22 255);
|
||||||
|
--color-primary-hover: oklch(0.45 0.20 255);
|
||||||
|
|
||||||
|
/* === Subject-Color-Tokens === */
|
||||||
|
--color-rose-soft: oklch(0.95 0.04 25);
|
||||||
|
--color-rose-ink: oklch(0.50 0.16 25);
|
||||||
|
|
||||||
|
--color-violet-soft: oklch(0.95 0.04 295);
|
||||||
|
--color-violet-ink: oklch(0.50 0.16 295);
|
||||||
|
|
||||||
|
--color-green-soft: oklch(0.95 0.04 155);
|
||||||
|
--color-green-ink: oklch(0.50 0.14 155);
|
||||||
|
|
||||||
|
--color-amber-soft: oklch(0.95 0.05 75);
|
||||||
|
--color-amber-ink: oklch(0.55 0.14 75);
|
||||||
|
|
||||||
|
/* === Feedback === */
|
||||||
|
--color-success: oklch(0.55 0.16 155);
|
||||||
|
--color-warning: oklch(0.65 0.16 75);
|
||||||
|
--color-danger: oklch(0.55 0.20 25);
|
||||||
|
|
||||||
|
/* === Legacy-Tokens (Auth-Pages) === */
|
||||||
--color-text-primary: oklch(0.22 0.015 270);
|
--color-text-primary: oklch(0.22 0.015 270);
|
||||||
--color-text-secondary: oklch(0.42 0.012 270);
|
--color-text-secondary: oklch(0.42 0.012 270);
|
||||||
--color-text-muted: oklch(0.58 0.01 270);
|
--color-text-muted: oklch(0.58 0.01 270);
|
||||||
|
|
@ -10,10 +50,24 @@
|
||||||
--color-accent: oklch(0.42 0.22 255);
|
--color-accent: oklch(0.42 0.22 255);
|
||||||
--color-accent-hover: oklch(0.36 0.24 255);
|
--color-accent-hover: oklch(0.36 0.24 255);
|
||||||
--color-accent-muted: oklch(0.96 0.04 255);
|
--color-accent-muted: oklch(0.96 0.04 255);
|
||||||
--color-success: oklch(0.52 0.18 155);
|
|
||||||
--color-danger: oklch(0.50 0.22 25);
|
/* === Fonts === */
|
||||||
--font-display: 'Plus Jakarta Sans', sans-serif;
|
--font-display: 'Plus Jakarta Sans', system-ui, sans-serif;
|
||||||
--font-mono: 'JetBrains Mono', monospace;
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
|
||||||
|
/* === Radii === */
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--radius-xl: 18px;
|
||||||
|
|
||||||
|
/* === Shadows === */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20, 20, 30, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(20, 20, 30, 0.06);
|
||||||
|
|
||||||
|
/* === Layout-Spacing === */
|
||||||
|
--spacing-sb: 240px;
|
||||||
|
--spacing-tb: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -21,7 +75,7 @@
|
||||||
src: url('/fonts/PlusJakartaSans-VariableFont_wght.woff2') format('woff2');
|
src: url('/fonts/PlusJakartaSans-VariableFont_wght.woff2') format('woff2');
|
||||||
font-weight: 100 900;
|
font-weight: 100 900;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -29,11 +83,19 @@
|
||||||
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-bg-base);
|
background-color: var(--color-bg-base);
|
||||||
color: var(--color-text-primary);
|
color: var(--color-ink-1);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
font-feature-settings: 'cv11','ss01';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.font-mono, .mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-feature-settings: 'tnum','zero';
|
||||||
|
}
|
||||||
|
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
sm:max-w-md
|
||||||
|
md:max-w-xl
|
||||||
|
lg:max-w-3xl
|
||||||
|
xl:max-w-5xl
|
||||||
|
2xl:max-w-7xl
|
||||||
|
|
@ -1 +1 @@
|
||||||
//
|
import './echo';
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
// Auth entry point
|
// Auth entry point
|
||||||
|
import './echo';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import Echo from 'laravel-echo';
|
||||||
|
import Pusher from 'pusher-js';
|
||||||
|
|
||||||
|
if (!window.Echo) {
|
||||||
|
window.Pusher = Pusher;
|
||||||
|
window.Echo = new Echo({
|
||||||
|
broadcaster: 'reverb',
|
||||||
|
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||||
|
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||||
|
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||||
|
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
@if ($status)
|
@if ($status)
|
||||||
<div {{ $attributes->merge([
|
<div {{ $attributes->merge([
|
||||||
'class' => 'flex items-center gap-2 text-sm font-medium text-[--color-success] bg-[oklch(0.96_0.05_155)] border border-[oklch(0.88_0.10_155)] rounded-lg px-4 py-3'
|
'class' => 'flex items-center gap-2 text-sm font-medium text-success bg-[oklch(0.96_0.05_155)] border border-[oklch(0.88_0.10_155)] rounded-lg px-4 py-3'
|
||||||
]) }}>
|
]) }}>
|
||||||
<x-heroicon-s-check-circle class="w-4 h-4 shrink-0" />
|
<x-heroicon-s-check-circle class="w-4 h-4 shrink-0" />
|
||||||
{{ $status }}
|
{{ $status }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
@props([
|
||||||
|
'variant' => 'default',
|
||||||
|
'size' => 'md',
|
||||||
|
'as' => 'button',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$base = 'inline-flex items-center justify-center gap-2 rounded-lg font-semibold cursor-pointer transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
$variants = [
|
||||||
|
'default' => 'border border-line bg-bg-soft text-ink-1 hover:bg-bg-tint hover:border-line-strong focus-visible:ring-line-strong',
|
||||||
|
'primary' => 'bg-ink-1 text-bg-base border border-ink-1 hover:bg-ink-2 hover:border-ink-2 focus-visible:ring-ink-1',
|
||||||
|
'accent' => 'bg-primary text-white border border-primary hover:bg-primary-hover hover:border-primary-hover focus-visible:ring-primary',
|
||||||
|
'ghost' => 'bg-transparent text-ink-2 hover:bg-bg-tint border border-transparent focus-visible:ring-line-strong',
|
||||||
|
];
|
||||||
|
|
||||||
|
$sizes = [
|
||||||
|
'sm' => 'px-2.5 py-1.5 text-xs',
|
||||||
|
'md' => 'px-3.5 py-2 text-sm',
|
||||||
|
'lg' => 'px-5 py-2.5 text-sm',
|
||||||
|
];
|
||||||
|
|
||||||
|
$classes = "{$base} {$variants[$variant]} {$sizes[$size]}";
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($as === 'a')
|
||||||
|
<a {{ $attributes->merge(['class' => $classes]) }}>{{ $slot }}</a>
|
||||||
|
@else
|
||||||
|
<button {{ $attributes->merge(['type' => 'button', 'class' => $classes]) }}>{{ $slot }}</button>
|
||||||
|
@endif
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
@props([
|
||||||
|
'variant' => 'default',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$base = 'rounded-xl p-5 border';
|
||||||
|
|
||||||
|
$variants = [
|
||||||
|
'default' => 'bg-bg-soft border-line',
|
||||||
|
'gradient-primary' => 'bg-primary-soft border-primary-soft',
|
||||||
|
'gradient-violet' => 'bg-violet-soft border-violet-soft',
|
||||||
|
'tint' => 'bg-bg-tint border-line',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div {{ $attributes->merge(['class' => "{$base} {$variants[$variant]}"]) }}>
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@props(['messages'])
|
@props(['messages'])
|
||||||
|
|
||||||
@if ($messages)
|
@if ($messages)
|
||||||
<ul {{ $attributes->merge(['class' => 'text-xs text-[--color-danger] space-y-0.5 mt-1.5']) }}>
|
<ul {{ $attributes->merge(['class' => 'text-xs text-danger space-y-0.5 mt-1.5']) }}>
|
||||||
@foreach ((array) $messages as $message)
|
@foreach ((array) $messages as $message)
|
||||||
<li class="flex items-center gap-1.5">
|
<li class="flex items-center gap-1.5">
|
||||||
<x-heroicon-s-exclamation-circle class="w-3.5 h-3.5 shrink-0" />
|
<x-heroicon-s-exclamation-circle class="w-3.5 h-3.5 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
@props(['value'])
|
@props(['value'])
|
||||||
|
|
||||||
<label {{ $attributes->merge(['class' => 'block text-sm font-medium text-[--color-text-secondary] mb-1']) }}>
|
<label {{ $attributes->merge(['class' => 'block text-sm font-medium text-text-secondary mb-1']) }}>
|
||||||
{{ $value ?? $slot }}
|
{{ $value ?? $slot }}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
{{--
|
||||||
|
Lernschiff Brand Component
|
||||||
|
Location: resources/views/components/layout/brand.blade.php
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<x-layout.brand />
|
||||||
|
<x-layout.brand tag="Lehrer · Goethe-GS" />
|
||||||
|
<x-layout.brand variant="wordmark" />
|
||||||
|
<x-layout.brand variant="icon-only" />
|
||||||
|
--}}
|
||||||
|
|
||||||
|
@props([
|
||||||
|
'tag' => 'Schüler · v1.0',
|
||||||
|
'variant' => 'default',
|
||||||
|
'href' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$element = $href !== null ? 'a' : 'div';
|
||||||
|
$linkAttrs = $href !== null ? ['href' => $href] : [];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<{{ $element }}
|
||||||
|
{{ $attributes->merge($linkAttrs)->merge(['class' => 'flex items-center gap-2.5']) }}
|
||||||
|
aria-label="Lernschiff Home"
|
||||||
|
>
|
||||||
|
@if($variant === 'wordmark')
|
||||||
|
<img
|
||||||
|
src="{{ asset('logo/lernschiff-wordmark.svg') }}"
|
||||||
|
alt="Lernschiff"
|
||||||
|
class="h-8 w-auto"
|
||||||
|
>
|
||||||
|
@elseif($variant === 'icon-only')
|
||||||
|
<img
|
||||||
|
src="{{ asset('logo/lernschiff-icon.svg') }}"
|
||||||
|
alt="Lernschiff"
|
||||||
|
class="w-8 h-8"
|
||||||
|
>
|
||||||
|
@else
|
||||||
|
<img
|
||||||
|
src="{{ asset('logo/lernschiff-icon.svg') }}"
|
||||||
|
alt=""
|
||||||
|
class="w-8 h-8 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span class="flex flex-col leading-tight">
|
||||||
|
<span class="font-display font-bold text-sm tracking-tight text-ink-1">
|
||||||
|
Lernschiff
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] uppercase tracking-wider text-ink-3 mt-0.5">
|
||||||
|
{{ $tag }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</{{ $element }}>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@props([
|
||||||
|
'icon',
|
||||||
|
'value',
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full bg-bg-soft border border-line text-sm font-semibold text-ink-1">
|
||||||
|
<x-dynamic-component :component="'heroicon-o-' . $icon" class="w-4 h-4" />
|
||||||
|
<span class="font-mono">{{ $value }}</span>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
@props([
|
||||||
|
'href' => '#',
|
||||||
|
'icon' => null,
|
||||||
|
'active' => false,
|
||||||
|
'badge' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$cls = 'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm cursor-pointer transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg-tint ';
|
||||||
|
$cls .= $active
|
||||||
|
? 'bg-ink-1 text-bg-base font-semibold shadow-sm'
|
||||||
|
: 'text-ink-2 hover:bg-bg-soft hover:text-ink-1';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ $href }}"
|
||||||
|
@if($active) aria-current="page" @endif
|
||||||
|
{{ $attributes->merge(['class' => $cls]) }}
|
||||||
|
>
|
||||||
|
@if($icon)
|
||||||
|
<x-dynamic-component :component="'heroicon-o-' . $icon" class="w-4 h-4 shrink-0" />
|
||||||
|
@endif
|
||||||
|
<span class="flex-1 truncate">{{ $slot }}</span>
|
||||||
|
@if($badge)
|
||||||
|
<span class="bg-primary text-white text-[10px] px-1.5 py-0.5 rounded-full font-bold tabular-nums">{{ $badge }}</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@props([
|
||||||
|
'placeholder' => 'Suche nach Aufgaben, Fächern, Themen…',
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="flex-1 max-w-[480px] flex items-center gap-2 px-3 py-2 rounded-lg bg-bg-soft border border-line text-ink-3 text-sm">
|
||||||
|
<x-heroicon-o-magnifying-glass class="w-4 h-4" />
|
||||||
|
<span class="flex-1 truncate">{{ $placeholder }}</span>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded bg-bg-base text-ink-3 font-mono">⌘ K</span>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
@php
|
||||||
|
$user = auth()->user();
|
||||||
|
$name = $user?->name ?? 'Gast';
|
||||||
|
$meta = $user?->email ?? '';
|
||||||
|
$initials = collect(explode(' ', $name))
|
||||||
|
->map(fn($p) => mb_strtoupper(mb_substr($p, 0, 1)))
|
||||||
|
->take(2)
|
||||||
|
->implode('');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 p-2 bg-bg-soft rounded-lg border border-line">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-primary text-white grid place-items-center font-bold text-xs shrink-0">
|
||||||
|
{{ $initials }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 truncate">{{ $name }}</div>
|
||||||
|
<div class="text-xs text-ink-3 truncate">{{ $meta }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="{{ route('logout') }}" class="shrink-0">
|
||||||
|
@csrf
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-8 h-8 grid place-items-center rounded-lg text-ink-3 hover:text-danger hover:bg-bg-tint transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-1 focus-visible:ring-offset-bg-soft"
|
||||||
|
aria-label="Abmelden"
|
||||||
|
title="Abmelden"
|
||||||
|
data-testid="sidebar-logout"
|
||||||
|
>
|
||||||
|
<x-heroicon-o-arrow-right-on-rectangle class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
@props([
|
||||||
|
'active' => 'dashboard',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$groups = config('sidebar.groups', []);
|
||||||
|
$user = auth()->user();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<aside class="fixed top-0 left-0 h-screen w-60 bg-bg-tint border-r border-line flex flex-col z-20">
|
||||||
|
|
||||||
|
{{-- Header (fixed) --}}
|
||||||
|
<div class="shrink-0 px-5 py-5 border-b border-line">
|
||||||
|
<x-layout.brand :href="route('dashboard')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Content (scrollable) --}}
|
||||||
|
<div class="flex-1 overflow-y-auto px-3 py-4 flex flex-col gap-6">
|
||||||
|
@foreach($groups as $group)
|
||||||
|
<nav class="flex flex-col gap-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5 px-2.5">{{ $group['label'] }}</div>
|
||||||
|
@foreach($group['items'] as $item)
|
||||||
|
@php
|
||||||
|
$rolesAllowed = ! isset($item['roles']) || ($user && $user->hasAnyRole((array) $item['roles']));
|
||||||
|
if ($rolesAllowed && ! Route::has($item['route']) && config('app.debug')) {
|
||||||
|
throw new \RuntimeException("Sidebar config references missing route: {$item['route']}");
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
@if($rolesAllowed && Route::has($item['route']))
|
||||||
|
<x-layout.nav-item
|
||||||
|
:href="route($item['route'])"
|
||||||
|
:icon="$item['icon']"
|
||||||
|
:active="$active === $item['key']"
|
||||||
|
:badge="$item['badge'] ?? null"
|
||||||
|
>
|
||||||
|
{{ $item['label'] }}
|
||||||
|
</x-layout.nav-item>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Footer (fixed) --}}
|
||||||
|
<div class="shrink-0 px-3 py-3 border-t border-line">
|
||||||
|
<x-layout.sidebar-user />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
@props([
|
||||||
|
'streak' => 0,
|
||||||
|
'points' => 0,
|
||||||
|
])
|
||||||
|
|
||||||
|
<header class="h-14 px-6 flex items-center gap-4 border-b border-line bg-bg-elev sticky top-0 z-10 backdrop-blur-sm">
|
||||||
|
<x-layout.search-input />
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2.5 ml-auto">
|
||||||
|
<x-layout.gamification-chip icon="fire" :value="$streak . ' Tage'" />
|
||||||
|
<x-layout.gamification-chip icon="star" :value="number_format((int) $points, 0, ',', '.')" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mitteilungen"
|
||||||
|
class="relative w-9 h-9 grid place-items-center rounded-lg border border-line bg-bg-soft hover:bg-bg-tint hover:border-line-strong transition-all duration-150 cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg-elev"
|
||||||
|
>
|
||||||
|
<x-heroicon-o-bell class="w-4 h-4 text-ink-2" />
|
||||||
|
<span class="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-danger ring-2 ring-bg-soft"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full bg-[--color-bg-base] font-[--font-display]">
|
<body class="h-full bg-bg-base font-display">
|
||||||
<livewire:layout.navigation />
|
<livewire:layout.navigation />
|
||||||
<main class="py-8">
|
<main class="py-8">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full bg-[--color-bg-base] font-[--font-display]">
|
<body class="h-full bg-bg-base font-display">
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
@props([
|
||||||
|
'variant' => 'default',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$base = 'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold';
|
||||||
|
|
||||||
|
$variants = [
|
||||||
|
'default' => 'bg-bg-soft border border-line text-ink-2',
|
||||||
|
'primary' => 'bg-primary text-white border border-primary',
|
||||||
|
'success' => 'bg-green-soft text-green-ink border border-green-soft',
|
||||||
|
'warning' => 'bg-amber-soft text-amber-ink border border-amber-soft',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<span {{ $attributes->merge(['class' => "{$base} {$variants[$variant]}"]) }}>
|
||||||
|
{{ $slot }}
|
||||||
|
</span>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<button {{ $attributes->merge([
|
<button {{ $attributes->merge([
|
||||||
'type' => 'submit',
|
'type' => 'submit',
|
||||||
'class' => 'inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-[--color-accent] hover:bg-[--color-accent-hover] text-white font-semibold text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-[--color-accent] focus:ring-offset-2 focus:ring-offset-white transition-all duration-150 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed'
|
'class' => 'inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent-hover text-white font-semibold text-sm rounded-lg focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-white transition-all duration-150 cursor-pointer disabled:opacity-60 disabled:cursor-not-allowed'
|
||||||
]) }}>
|
]) }}>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
@props([
|
||||||
|
'title',
|
||||||
|
'emoji',
|
||||||
|
'status' => 'Verfügbar',
|
||||||
|
'price' => 0,
|
||||||
|
'available' => true,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="group grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 transition-colors hover:bg-bg-tint -mx-2 px-2 rounded-lg">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-bg-base grid place-items-center text-lg transition-transform group-hover:scale-105">
|
||||||
|
{{ $emoji }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 truncate">{{ $title }}</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5 truncate">{{ $status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono tabular-nums text-xs font-bold {{ $available ? 'text-green-ink' : 'text-ink-3' }}">
|
||||||
|
{{ number_format((int) $price, 0, ',', '.') }} Pkt.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
@props([
|
||||||
|
'rows' => 3,
|
||||||
|
])
|
||||||
|
|
||||||
|
{{-- Page-level skeleton placeholder used as Livewire placeholder() during Lazy hydration --}}
|
||||||
|
<div {{ $attributes->merge(['class' => 'flex flex-col gap-6 animate-pulse']) }} data-testid="page-skeleton">
|
||||||
|
|
||||||
|
{{-- Hero header skeleton --}}
|
||||||
|
<div class="flex justify-between items-end gap-6">
|
||||||
|
<div class="flex-1 max-w-xl flex flex-col gap-3">
|
||||||
|
<div class="h-3 bg-line rounded w-24"></div>
|
||||||
|
<div class="h-8 bg-line rounded w-3/5"></div>
|
||||||
|
<div class="h-3 bg-line rounded w-4/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-10 w-40 bg-line rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Content card skeleton --}}
|
||||||
|
<div class="bg-bg-soft border border-line rounded-xl p-5 shadow-sm flex flex-col gap-4">
|
||||||
|
@for($i = 0; $i < (int) $rows; $i++)
|
||||||
|
<div class="grid grid-cols-[44px_1fr_auto] gap-3 items-center py-2 border-t border-line first:border-t-0">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-line"></div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="h-3 bg-line rounded w-3/5"></div>
|
||||||
|
<div class="h-2 bg-line rounded w-2/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-7 w-20 bg-line rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
@props([
|
||||||
|
'label',
|
||||||
|
'value',
|
||||||
|
'unit' => null,
|
||||||
|
'trend' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div {{ $attributes->merge(['class' => 'bg-bg-soft border border-line rounded-xl p-4 shadow-sm transition-shadow hover:shadow-md']) }}>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.1em] text-ink-3 font-semibold">{{ $label }}</div>
|
||||||
|
<div class="text-3xl font-bold tracking-tight mt-2 font-mono tabular-nums text-ink-1">
|
||||||
|
{{ $value }}@if($unit)<span class="text-sm text-ink-3 font-semibold ml-1 font-display">{{ $unit }}</span>@endif
|
||||||
|
</div>
|
||||||
|
@if($trend)
|
||||||
|
<div class="text-xs text-green-ink font-semibold mt-1.5 flex items-center gap-1">
|
||||||
|
<span>↑</span><span>{{ $trend }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
@props([
|
||||||
|
'name',
|
||||||
|
'icon',
|
||||||
|
'color' => 'primary',
|
||||||
|
'lessons',
|
||||||
|
'progress' => 0,
|
||||||
|
'next' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$colorMap = [
|
||||||
|
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink'],
|
||||||
|
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink'],
|
||||||
|
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink'],
|
||||||
|
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink'],
|
||||||
|
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink'],
|
||||||
|
];
|
||||||
|
$c = $colorMap[$color] ?? $colorMap['primary'];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<button type="button" {{ $attributes->merge(['class' => 'group text-left bg-bg-soft border border-line rounded-xl p-4 shadow-sm transition-all duration-150 hover:shadow-md hover:-translate-y-0.5 hover:border-line-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 cursor-pointer']) }}>
|
||||||
|
<div class="w-10 h-10 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center font-bold text-lg mb-3 transition-transform group-hover:scale-105">
|
||||||
|
{{ $icon }}
|
||||||
|
</div>
|
||||||
|
<div class="font-bold text-sm text-ink-1">{{ $name }}</div>
|
||||||
|
@if($next)
|
||||||
|
<div class="text-xs text-ink-3 mt-1 leading-snug">Nächste Lektion · {{ $next }}</div>
|
||||||
|
@endif
|
||||||
|
<div class="h-1.5 rounded-full bg-bg-base mt-3.5 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full {{ $c['bar'] }} transition-all duration-500" style="width:{{ (int) $progress }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-[11px] text-ink-3 mt-2 font-semibold font-mono tabular-nums">
|
||||||
|
<span>{{ $lessons }} Lektionen</span>
|
||||||
|
<span class="{{ $c['ink'] }}">{{ (int) $progress }} %</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
@props([
|
||||||
|
'title',
|
||||||
|
'meta',
|
||||||
|
'icon',
|
||||||
|
'color' => 'primary',
|
||||||
|
'points' => 0,
|
||||||
|
'startUrl' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$colorMap = [
|
||||||
|
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink'],
|
||||||
|
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink'],
|
||||||
|
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink'],
|
||||||
|
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink'],
|
||||||
|
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink'],
|
||||||
|
];
|
||||||
|
$c = $colorMap[$color] ?? $colorMap['primary'];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="group grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 transition-colors hover:bg-bg-tint -mx-2 px-2 rounded-lg">
|
||||||
|
<div class="w-10 h-10 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center font-bold transition-transform group-hover:scale-105">
|
||||||
|
{{ $icon }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 truncate">{{ $title }}</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5 truncate">{{ $meta }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span class="font-mono tabular-nums text-xs text-amber-ink font-bold">+{{ (int) $points }} Pkt.</span>
|
||||||
|
@if($startUrl)
|
||||||
|
<x-btn variant="primary" size="sm" as="a" :href="$startUrl">Starten</x-btn>
|
||||||
|
@else
|
||||||
|
<x-btn variant="primary" size="sm">Starten</x-btn>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
<input
|
<input
|
||||||
@disabled($disabled)
|
@disabled($disabled)
|
||||||
{{ $attributes->merge([
|
{{ $attributes->merge([
|
||||||
'class' => 'w-full px-3.5 py-2.5 bg-white border border-[--color-border-subtle] rounded-lg text-sm text-[--color-text-primary] placeholder-[--color-text-muted] focus:outline-none focus:ring-2 focus:ring-[--color-accent] focus:border-transparent transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed'
|
'class' => 'w-full px-3.5 py-2.5 bg-white border border-border-subtle rounded-lg text-sm text-text-primary placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
]) }}
|
]) }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<x-layouts.app>
|
<x-layouts.app>
|
||||||
<div class="max-w-4xl mx-auto px-4">
|
<div class="max-w-4xl mx-auto px-4">
|
||||||
<h1 class="text-3xl font-bold text-[--color-text-primary] mb-4">
|
<h1 class="text-3xl font-bold text-text-primary mb-4">
|
||||||
{{ __('Willkommen') }}, {{ auth()->user()->name }}
|
{{ __('Willkommen') }}, {{ auth()->user()->name }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-[--color-text-secondary] flex items-center gap-2">
|
<p class="text-text-secondary flex items-center gap-2">
|
||||||
<x-heroicon-o-academic-cap class="w-5 h-5" /> Lernschiff MVP-1
|
<x-heroicon-o-academic-cap class="w-5 h-5" /> Lernschiff MVP-1
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full bg-[--color-bg-base] font-[--font-display]">
|
<body class="h-full bg-bg-base font-display">
|
||||||
<livewire:layout.navigation />
|
<livewire:layout.navigation />
|
||||||
<main class="py-8">
|
<main class="py-8">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="h-full bg-[--color-bg-base] font-[--font-display]">
|
<body class="h-full bg-bg-base font-display">
|
||||||
<div class="min-h-screen flex items-center justify-center">
|
<div class="min-h-screen flex items-center justify-center">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
|
<title>{{ config('app.name', 'Lernschiff') }}</title>
|
||||||
|
<link rel="preload" href="{{ asset('fonts/PlusJakartaSans-VariableFont_wght.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="preload" href="{{ asset('fonts/JetBrainsMono-Regular.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ asset('logo/favicon.svg') }}">
|
||||||
|
<link rel="icon" type="image/x-icon" href="{{ asset('logo/favicon.ico') }}">
|
||||||
|
<link rel="apple-touch-icon" href="{{ asset('icons/apple-touch-icon.png') }}">
|
||||||
|
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||||
|
<meta name="theme-color" content="#f5f3ee">
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
@livewireStyles
|
||||||
|
</head>
|
||||||
|
<body class="h-full bg-bg-base font-display antialiased text-ink-1">
|
||||||
|
|
||||||
|
<x-layout.sidebar :active="$active ?? 'dashboard'" />
|
||||||
|
|
||||||
|
<main class="ml-60 flex flex-col min-h-screen min-w-0">
|
||||||
|
<x-layout.topbar :streak="$streak ?? 0" :points="$points ?? 0" />
|
||||||
|
|
||||||
|
<div class="px-8 py-8 max-w-[1440px] w-full mx-auto">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<livewire:livewire-ui-modal />
|
||||||
|
|
||||||
|
@livewireScripts
|
||||||
|
@stack('scripts')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -5,28 +5,32 @@
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
<title>{{ config('app.name', 'Lernschiff') }}</title>
|
<title>{{ config('app.name', 'Lernschiff') }}</title>
|
||||||
|
<link rel="preload" href="{{ asset('fonts/PlusJakartaSans-VariableFont_wght.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="preload" href="{{ asset('fonts/JetBrainsMono-Regular.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ asset('logo/favicon.svg') }}">
|
||||||
|
<link rel="icon" type="image/x-icon" href="{{ asset('logo/favicon.ico') }}">
|
||||||
|
<link rel="apple-touch-icon" href="{{ asset('icons/apple-touch-icon.png') }}">
|
||||||
|
<link rel="manifest" href="{{ asset('manifest.webmanifest') }}">
|
||||||
|
<meta name="theme-color" content="#f5f3ee">
|
||||||
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
@vite(['resources/css/app.css', 'resources/js/auth.js'])
|
||||||
@livewireStyles
|
@livewireStyles
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen bg-[--color-bg-base] flex items-center justify-center px-4 py-12">
|
<body class="min-h-screen bg-bg-base flex items-center justify-center px-4 py-12">
|
||||||
|
|
||||||
<div class="w-full max-w-[420px]">
|
<div class="w-full max-w-[420px]">
|
||||||
|
|
||||||
{{-- Logo --}}
|
{{-- Brand --}}
|
||||||
<div class="flex items-center justify-center gap-2.5 mb-8">
|
<div class="flex items-center justify-center mb-8">
|
||||||
<div class="w-9 h-9 rounded-xl bg-[--color-accent] flex items-center justify-center shadow-sm">
|
<x-layout.brand variant="wordmark" />
|
||||||
<x-heroicon-o-academic-cap class="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span class="font-bold text-xl tracking-tight text-[--color-text-primary]">Lernschiff</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Card --}}
|
{{-- Card --}}
|
||||||
<div class="bg-white rounded-2xl border border-[--color-border-subtle] shadow-sm px-8 py-8">
|
<div class="bg-white rounded-2xl border border-border-subtle shadow-sm px-8 py-8">
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Footer --}}
|
{{-- Footer --}}
|
||||||
<p class="text-center text-xs text-[--color-text-muted] mt-6">
|
<p class="text-center text-xs text-text-muted mt-6">
|
||||||
© {{ date('Y') }} Lernschiff · DACH · Alle Rechte vorbehalten
|
© {{ date('Y') }} Lernschiff · DACH · Alle Rechte vorbehalten
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
|
||||||
|
{{-- ─────────── HERO HEADER ─────────── --}}
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">{{ $today }}</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Hallo {{ $firstName }}! Bereit für heute? 👋</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Du bist seit {{ $streakDays }} Tagen am Lernen — weiter so. Heute warten {{ count($tasks) }} Aufgaben auf dich.</p>
|
||||||
|
</div>
|
||||||
|
<x-btn variant="primary" size="lg" class="shrink-0 shadow-sm">
|
||||||
|
<x-heroicon-s-play class="w-4 h-4" />
|
||||||
|
Tagesplan starten
|
||||||
|
</x-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{-- ─────────── STATS ─────────── --}}
|
||||||
|
<section class="grid grid-cols-4 gap-4">
|
||||||
|
<x-stat-tile label="Gesamtpunkte" :value="number_format($totalPoints, 0, ',', '.')" trend="+180 diese Woche" />
|
||||||
|
<x-stat-tile label="Lernserie" :value="$streakDays" unit="Tage" trend="+2 persönlicher Rekord" />
|
||||||
|
<x-stat-tile label="Diese Woche" :value="$weekMinutes" unit="Min." trend="+12% vs. letzte Woche" />
|
||||||
|
<x-stat-tile label="Bestandene Tests" :value="$testRatio" trend="75% Erfolgsquote" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ─────────── SUBJECTS ─────────── --}}
|
||||||
|
<section class="flex flex-col gap-4">
|
||||||
|
<div class="flex justify-between items-end">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-bold text-base text-ink-1">Meine Fächer</h2>
|
||||||
|
<p class="text-xs text-ink-3 mt-1">Wähle ein Fach, um zu lernen oder eine Prüfung abzulegen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2.5 items-center">
|
||||||
|
<x-pill>Klasse 4 · Niveau B</x-pill>
|
||||||
|
<x-btn>
|
||||||
|
Alle anzeigen
|
||||||
|
<x-heroicon-o-arrow-right class="w-3.5 h-3.5" />
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
@foreach($subjects as $s)
|
||||||
|
<x-subject-card
|
||||||
|
:name="$s['name']"
|
||||||
|
:icon="$s['icon']"
|
||||||
|
:color="$s['color']"
|
||||||
|
:lessons="$s['lessons']"
|
||||||
|
:progress="$s['progress']"
|
||||||
|
:next="$s['next']"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- ─────────── TASKS + SIDE (KI + Shop) ─────────── --}}
|
||||||
|
<section class="grid grid-cols-3 gap-5">
|
||||||
|
<x-card class="col-span-2 shadow-sm">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h2 class="font-bold text-base text-ink-1">Heutige Aufgaben</h2>
|
||||||
|
<x-pill variant="primary">{{ count($tasks) }} offen</x-pill>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
@foreach($tasks as $t)
|
||||||
|
<x-task-row
|
||||||
|
:title="$t['title']"
|
||||||
|
:meta="$t['meta']"
|
||||||
|
:icon="$t['icon']"
|
||||||
|
:color="$t['color']"
|
||||||
|
:points="$t['points']"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
{{-- KI Foto-Hilfe --}}
|
||||||
|
<x-card variant="gradient-primary" class="shadow-sm">
|
||||||
|
<div class="flex items-center gap-2 mb-2.5 text-primary-ink">
|
||||||
|
<x-heroicon-o-camera class="w-4 h-4" />
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-[0.12em]">Neu · KI-Hilfe</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-lg font-bold tracking-tight mb-1.5 text-ink-1 leading-snug">Du steckst bei einer Aufgabe fest?</div>
|
||||||
|
<p class="text-sm text-ink-2 mb-4 leading-relaxed">Mach ein Foto — die KI erklärt dir den Lösungsweg Schritt für Schritt mit Beispielen.</p>
|
||||||
|
<x-btn variant="primary" class="w-full justify-center shadow-sm">
|
||||||
|
<x-heroicon-o-camera class="w-4 h-4" />
|
||||||
|
Foto-Hilfe öffnen
|
||||||
|
</x-btn>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
{{-- Belohnungs-Shop --}}
|
||||||
|
<x-card class="shadow-sm">
|
||||||
|
<div class="flex justify-between items-center mb-3">
|
||||||
|
<h2 class="font-bold text-base text-ink-1">Belohnungs-Shop</h2>
|
||||||
|
<span class="font-mono text-amber-ink font-bold text-sm tabular-nums">{{ number_format($totalPoints, 0, ',', '.') }} Pkt.</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
@foreach($rewards as $r)
|
||||||
|
<x-reward-row
|
||||||
|
:title="$r['title']"
|
||||||
|
:emoji="$r['emoji']"
|
||||||
|
:status="$r['status']"
|
||||||
|
:price="$r['price']"
|
||||||
|
:available="$r['available']"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -1,24 +1,24 @@
|
||||||
<div class="min-h-screen bg-[--color-bg-base] flex items-center justify-center px-4 py-12">
|
<div class="min-h-screen bg-bg-base flex items-center justify-center px-4 py-12">
|
||||||
<div class="w-full max-w-[480px]">
|
<div class="w-full max-w-[480px]">
|
||||||
|
|
||||||
{{-- Header --}}
|
{{-- Header --}}
|
||||||
<div class="flex items-center gap-2.5 mb-8">
|
<div class="flex items-center gap-2.5 mb-8">
|
||||||
<div class="w-9 h-9 rounded-xl bg-[--color-accent] flex items-center justify-center shadow-sm">
|
<div class="w-9 h-9 rounded-xl bg-accent flex items-center justify-center shadow-sm">
|
||||||
<x-heroicon-o-academic-cap class="w-5 h-5 text-white" />
|
<x-heroicon-o-academic-cap class="w-5 h-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="font-bold text-xl tracking-tight text-[--color-text-primary]">Lernschiff</span>
|
<span class="font-bold text-xl tracking-tight text-text-primary">Lernschiff</span>
|
||||||
<span class="ml-2 text-xs font-semibold uppercase tracking-widest text-[--color-text-muted]">Dev</span>
|
<span class="ml-2 text-xs font-semibold uppercase tracking-widest text-text-muted">Dev</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white rounded-2xl border border-[--color-border-subtle] shadow-sm px-8 py-8">
|
<div class="bg-white rounded-2xl border border-border-subtle shadow-sm px-8 py-8">
|
||||||
|
|
||||||
<div class="flex items-center gap-2 mb-6">
|
<div class="flex items-center gap-2 mb-6">
|
||||||
<x-heroicon-o-bolt class="w-5 h-5 text-[--color-accent]" />
|
<x-heroicon-o-bolt class="w-5 h-5 text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-bold text-[--color-text-primary]">Quick Login</h1>
|
<h1 class="text-lg font-bold text-text-primary">Quick Login</h1>
|
||||||
<p class="text-xs text-[--color-text-muted]">Nur lokal sichtbar · ALLOW_QUICK_LOGIN=true</p>
|
<p class="text-xs text-text-muted">Nur lokal sichtbar · ALLOW_QUICK_LOGIN=true</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -28,22 +28,22 @@
|
||||||
wire:click="loginAs('{{ $user->email }}')"
|
wire:click="loginAs('{{ $user->email }}')"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
wire:target="loginAs('{{ $user->email }}')"
|
wire:target="loginAs('{{ $user->email }}')"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 rounded-xl border border-[--color-border-subtle] hover:border-[--color-accent] hover:bg-[--color-accent-muted] transition-all duration-150 group cursor-pointer disabled:opacity-50"
|
class="w-full flex items-center justify-between px-4 py-3 rounded-xl border border-border-subtle hover:border-accent hover:bg-accent-muted transition-all duration-150 group cursor-pointer disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
<p class="text-sm font-semibold text-[--color-text-primary] group-hover:text-[--color-accent]">
|
<p class="text-sm font-semibold text-text-primary group-hover:text-accent">
|
||||||
{{ $user->name }}
|
{{ $user->name }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-[--color-text-muted] font-mono">{{ $user->email }}</p>
|
<p class="text-xs text-text-muted font-mono">{{ $user->email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<x-heroicon-o-arrow-right class="w-4 h-4 text-[--color-text-muted] group-hover:text-[--color-accent] transition-colors" />
|
<x-heroicon-o-arrow-right class="w-4 h-4 text-text-muted group-hover:text-accent transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-center text-xs text-[--color-text-muted] mt-6">
|
<p class="text-center text-xs text-text-muted mt-6">
|
||||||
© {{ date('Y') }} Lernschiff · DACH
|
© {{ date('Y') }} Lernschiff · DACH
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
@php
|
||||||
|
$colorMap = [
|
||||||
|
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink'],
|
||||||
|
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink'],
|
||||||
|
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink'],
|
||||||
|
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink'],
|
||||||
|
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Inbox</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Mitteilungen</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Nachrichten von Lehrer:innen, Erinnerungen und Lernschiff-Updates.</p>
|
||||||
|
</div>
|
||||||
|
<x-btn wire:click="markAllRead">
|
||||||
|
<x-heroicon-o-check class="w-3.5 h-3.5" />
|
||||||
|
Alle als gelesen
|
||||||
|
</x-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex gap-2" wire:key="notif-filters">
|
||||||
|
@foreach(['all' => 'Alle', 'unread' => 'Ungelesen'] as $key => $label)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="setFilter('{{ $key }}')"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-semibold transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 {{ $filter === $key ? 'bg-ink-1 text-bg-base border-ink-1' : 'bg-bg-soft text-ink-2 border-line hover:bg-bg-tint' }}"
|
||||||
|
data-testid="notif-filter-{{ $key }}"
|
||||||
|
@if($filter === $key) aria-current="page" @endif
|
||||||
|
>
|
||||||
|
{{ $label }} <span class="font-mono tabular-nums">({{ $this->counts[$key] }})</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Skeleton with bg-line bars on bg-bg-tint container --}}
|
||||||
|
<div wire:loading.flex wire:target="setFilter,markAllRead,markRead" class="hidden flex-col bg-bg-soft border border-line rounded-xl shadow-sm" data-testid="notif-skeleton">
|
||||||
|
@for($i = 0; $i < 4; $i++)
|
||||||
|
<div class="grid grid-cols-[44px_1fr_auto] gap-4 items-start p-4 border-t border-line first:border-t-0 animate-pulse">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-line"></div>
|
||||||
|
<div class="flex flex-col gap-2 flex-1">
|
||||||
|
<div class="h-3 bg-line rounded w-2/5"></div>
|
||||||
|
<div class="h-2 bg-line rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 bg-line rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<x-card class="shadow-sm p-0 overflow-hidden" wire:loading.remove wire:target="setFilter,markAllRead,markRead">
|
||||||
|
@if(empty($this->filteredNotifications))
|
||||||
|
<div class="py-8 text-center text-sm text-ink-3">Keine Mitteilungen.</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col divide-y divide-line">
|
||||||
|
@foreach($this->filteredNotifications as $n)
|
||||||
|
@php $c = $colorMap[$n['color']]; @endphp
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:key="notif-{{ $n['key'] }}"
|
||||||
|
wire:click="markRead('{{ $n['key'] }}'); $dispatch('openModal', { component: 'notifications.modals.detail', arguments: { notification: {{ \Illuminate\Support\Js::from($n) }} } })"
|
||||||
|
class="text-left grid grid-cols-[44px_1fr_auto] gap-4 items-start p-4 transition-colors hover:bg-bg-tint cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="w-10 h-10 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center">
|
||||||
|
<x-dynamic-component :component="'heroicon-o-' . $n['icon']" class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
@if(! $n['read'])
|
||||||
|
<span class="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-primary ring-2 ring-bg-soft"></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 truncate mb-0.5">{{ $n['title'] }}</div>
|
||||||
|
<p class="text-xs text-ink-2 leading-relaxed line-clamp-2">{{ $n['body'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-ink-3 font-medium whitespace-nowrap">{{ $n['time'] }}</div>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<div>
|
||||||
|
<header class="flex items-start justify-between gap-4 p-6 border-b border-line">
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-1">{{ $notification['time'] ?? '' }}</div>
|
||||||
|
<div class="font-bold text-lg text-ink-1 leading-tight">{{ $notification['title'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">{{ $notification['body'] ?? '' }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="px-6 pb-6 flex gap-2">
|
||||||
|
<x-btn wire:click="closeModal" class="flex-1 justify-center">Schließen</x-btn>
|
||||||
|
<x-btn variant="primary" class="flex-1 justify-center">Aktion öffnen</x-btn>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
@ -8,7 +8,6 @@ use Livewire\Volt\Component;
|
||||||
new #[Layout('layouts.guest')] class extends Component
|
new #[Layout('layouts.guest')] class extends Component
|
||||||
{
|
{
|
||||||
public string $password = '';
|
public string $password = '';
|
||||||
public bool $showPassword = false;
|
|
||||||
|
|
||||||
public function confirmPassword(): void
|
public function confirmPassword(): void
|
||||||
{
|
{
|
||||||
|
|
@ -32,8 +31,8 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-[--color-text-primary]">Sicherheitsbereich</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-text-primary">Sicherheitsbereich</h1>
|
||||||
<p class="text-sm text-[--color-text-muted] mt-1">
|
<p class="text-sm text-text-muted mt-1">
|
||||||
Bitte bestätige dein Passwort, um fortzufahren.
|
Bitte bestätige dein Passwort, um fortzufahren.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,40 +41,41 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="password" value="Aktuelles Passwort" />
|
<x-input-label for="password" value="Aktuelles Passwort" />
|
||||||
<div class="relative">
|
<div class="relative" x-data="{ showPassword: false }">
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
id="password"
|
id="password"
|
||||||
:type="$showPassword ? 'text' : 'password'"
|
type="password"
|
||||||
|
x-bind:type="showPassword ? 'text' : 'password'"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="pr-10"
|
class="pr-10"
|
||||||
/>
|
/>
|
||||||
<button type="button" wire:click="$set('showPassword', !$showPassword)"
|
<button type="button" @click="showPassword = !showPassword"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[--color-text-muted] hover:text-[--color-text-secondary] transition-colors duration-150 cursor-pointer"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors duration-150 cursor-pointer"
|
||||||
aria-label="Passwort anzeigen">
|
aria-label="Passwort anzeigen">
|
||||||
@if($showPassword)
|
<span x-show="!showPassword">
|
||||||
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
|
||||||
@else
|
|
||||||
<x-heroicon-o-eye class="w-4 h-4" />
|
<x-heroicon-o-eye class="w-4 h-4" />
|
||||||
@endif
|
</span>
|
||||||
|
<span x-show="showPassword" x-cloak>
|
||||||
|
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<x-input-error :messages="$errors->get('password')" />
|
<x-input-error :messages="$errors->get('password')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-primary-button class="w-full" wire:loading.attr="disabled">
|
<x-primary-button class="w-full relative" style="position:relative;" wire:loading.attr="disabled">
|
||||||
<span wire:loading.remove wire:target="confirmPassword">
|
<span wire:loading.class="opacity-50" wire:target="confirmPassword" class="flex items-center gap-2 transition-opacity">
|
||||||
<x-heroicon-o-shield-check class="w-4 h-4" />
|
<x-heroicon-o-shield-check class="w-4 h-4" />
|
||||||
Bestätigen
|
Bestätigen
|
||||||
</span>
|
</span>
|
||||||
<span wire:loading wire:target="confirmPassword" class="flex items-center gap-2">
|
<span wire:loading.flex wire:target="confirmPassword" style="position:absolute;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;">
|
||||||
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Wird geprüft…
|
|
||||||
</span>
|
</span>
|
||||||
</x-primary-button>
|
</x-primary-button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-[--color-text-primary]">Passwort zurücksetzen</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-text-primary">Passwort zurücksetzen</h1>
|
||||||
<p class="text-sm text-[--color-text-muted] mt-1">
|
<p class="text-sm text-text-muted mt-1">
|
||||||
Gib deine E-Mail-Adresse ein — wir senden dir einen Reset-Link.
|
Gib deine E-Mail-Adresse ein — wir senden dir einen Reset-Link.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,7 +43,7 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="email"
|
wire:model="email"
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
|
@ -53,24 +53,23 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
<x-input-error :messages="$errors->get('email')" />
|
<x-input-error :messages="$errors->get('email')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-primary-button class="w-full" wire:loading.attr="disabled">
|
<x-primary-button class="w-full relative" style="position:relative;" wire:loading.attr="disabled">
|
||||||
<span wire:loading.remove wire:target="sendPasswordResetLink">
|
<span wire:loading.class="opacity-50" wire:target="sendPasswordResetLink" class="flex items-center gap-2 transition-opacity">
|
||||||
<x-heroicon-o-envelope class="w-4 h-4" />
|
<x-heroicon-o-envelope class="w-4 h-4" />
|
||||||
Reset-Link senden
|
Reset-Link senden
|
||||||
</span>
|
</span>
|
||||||
<span wire:loading wire:target="sendPasswordResetLink" class="flex items-center gap-2">
|
<span wire:loading.flex wire:target="sendPasswordResetLink" style="position:absolute;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;">
|
||||||
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Wird gesendet…
|
|
||||||
</span>
|
</span>
|
||||||
</x-primary-button>
|
</x-primary-button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-5 text-center">
|
<div class="mt-5 text-center">
|
||||||
<a href="{{ route('login') }}" wire:navigate class="text-sm text-[--color-accent] hover:underline font-medium">
|
<a href="{{ route('login') }}" wire:navigate class="text-sm text-accent hover:underline font-medium">
|
||||||
← Zurück zur Anmeldung
|
← Zurück zur Anmeldung
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
{
|
{
|
||||||
public LoginForm $form;
|
public LoginForm $form;
|
||||||
|
|
||||||
public bool $showPassword = false;
|
|
||||||
|
|
||||||
public function login(): void
|
public function login(): void
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
@ -22,8 +20,8 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-[--color-text-primary]">Willkommen zurück</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-text-primary">Willkommen zurück</h1>
|
||||||
<p class="text-sm text-[--color-text-muted] mt-1">Melde dich mit deinem Konto an</p>
|
<p class="text-sm text-text-muted mt-1">Melde dich mit deinem Konto an</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-auth-session-status class="mb-5" :status="session('status')" />
|
<x-auth-session-status class="mb-5" :status="session('status')" />
|
||||||
|
|
@ -35,7 +33,7 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="form.email"
|
wire:model="form.email"
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
|
@ -47,11 +45,12 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="password" value="Passwort" />
|
<x-input-label for="password" value="Passwort" />
|
||||||
<div class="relative">
|
<div class="relative" x-data="{ showPassword: false }">
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="form.password"
|
wire:model="form.password"
|
||||||
id="password"
|
id="password"
|
||||||
:type="$showPassword ? 'text' : 'password'"
|
type="password"
|
||||||
|
x-bind:type="showPassword ? 'text' : 'password'"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
|
|
@ -59,15 +58,16 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
wire:click="$set('showPassword', !$showPassword)"
|
@click="showPassword = !showPassword"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[--color-text-muted] hover:text-[--color-text-secondary] transition-colors duration-150 cursor-pointer"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors duration-150 cursor-pointer"
|
||||||
aria-label="Passwort anzeigen / verbergen"
|
aria-label="Passwort anzeigen / verbergen"
|
||||||
>
|
>
|
||||||
@if($showPassword)
|
<span x-show="!showPassword">
|
||||||
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
|
||||||
@else
|
|
||||||
<x-heroicon-o-eye class="w-4 h-4" />
|
<x-heroicon-o-eye class="w-4 h-4" />
|
||||||
@endif
|
</span>
|
||||||
|
<span x-show="showPassword" x-cloak>
|
||||||
|
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<x-input-error :messages="$errors->get('form.password')" />
|
<x-input-error :messages="$errors->get('form.password')" />
|
||||||
|
|
@ -79,46 +79,45 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
wire:model="form.remember"
|
wire:model="form.remember"
|
||||||
id="remember"
|
id="remember"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="w-4 h-4 rounded border-[--color-border-subtle] accent-[--color-accent] cursor-pointer"
|
class="w-4 h-4 rounded border-border-subtle accent-accent cursor-pointer"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-[--color-text-secondary]">Angemeldet bleiben</span>
|
<span class="text-sm text-text-secondary">Angemeldet bleiben</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@if (Route::has('password.request'))
|
@if (Route::has('password.request'))
|
||||||
<a
|
<a
|
||||||
href="{{ route('password.request') }}"
|
href="{{ route('password.request') }}"
|
||||||
wire:navigate
|
wire:navigate
|
||||||
class="text-sm text-[--color-accent] hover:underline font-medium"
|
class="text-sm text-accent hover:underline font-medium"
|
||||||
>
|
>
|
||||||
Passwort vergessen?
|
Passwort vergessen?
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-primary-button class="w-full mt-2" wire:loading.attr="disabled">
|
<x-primary-button class="w-full mt-2 relative" style="position:relative;" wire:loading.attr="disabled">
|
||||||
<span wire:loading.remove wire:target="login">
|
<span wire:loading.class="opacity-50" wire:target="login" class="flex items-center gap-2 transition-opacity">
|
||||||
<x-heroicon-o-arrow-right-on-rectangle class="w-4 h-4" />
|
<x-heroicon-o-arrow-right-on-rectangle class="w-4 h-4" />
|
||||||
Anmelden
|
Anmelden
|
||||||
</span>
|
</span>
|
||||||
<span wire:loading wire:target="login" class="flex items-center gap-2">
|
<span wire:loading.flex wire:target="login" style="position:absolute;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;">
|
||||||
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Wird angemeldet…
|
|
||||||
</span>
|
</span>
|
||||||
</x-primary-button>
|
</x-primary-button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@if(app()->isLocal() && env('ALLOW_QUICK_LOGIN', false))
|
@if(app()->isLocal() && env('ALLOW_QUICK_LOGIN', false))
|
||||||
<div class="mt-6 pt-5 border-t border-[--color-border-subtle]">
|
<div class="mt-6 pt-5 border-t border-border-subtle">
|
||||||
<p class="text-xs font-medium text-[--color-text-muted] uppercase tracking-widest mb-3">
|
<p class="text-xs font-medium text-text-muted uppercase tracking-widest mb-3">
|
||||||
Dev · Quick Login
|
Dev · Quick Login
|
||||||
</p>
|
</p>
|
||||||
<a
|
<a
|
||||||
href="{{ route('dev.quick-login') }}"
|
href="{{ route('dev.quick-login') }}"
|
||||||
class="inline-flex items-center gap-1.5 text-xs text-[--color-accent] hover:underline"
|
class="inline-flex items-center gap-1.5 text-xs text-accent hover:underline"
|
||||||
>
|
>
|
||||||
<x-heroicon-o-bolt class="w-3.5 h-3.5" />
|
<x-heroicon-o-bolt class="w-3.5 h-3.5" />
|
||||||
Alle Seed-User anzeigen
|
Alle Seed-User anzeigen
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
public string $password = '';
|
public string $password = '';
|
||||||
public string $password_confirmation = '';
|
public string $password_confirmation = '';
|
||||||
|
|
||||||
public bool $showPassword = false;
|
|
||||||
public bool $showPasswordConfirmation = false;
|
|
||||||
|
|
||||||
public function mount(string $token): void
|
public function mount(string $token): void
|
||||||
{
|
{
|
||||||
$this->token = $token;
|
$this->token = $token;
|
||||||
|
|
@ -58,8 +55,8 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-[--color-text-primary]">Neues Passwort</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-text-primary">Neues Passwort</h1>
|
||||||
<p class="text-sm text-[--color-text-muted] mt-1">Wähle ein sicheres neues Passwort für dein Konto.</p>
|
<p class="text-sm text-text-muted mt-1">Wähle ein sicheres neues Passwort für dein Konto.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form wire:submit="resetPassword" class="space-y-4">
|
<form wire:submit="resetPassword" class="space-y-4">
|
||||||
|
|
@ -69,7 +66,7 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="email"
|
wire:model="email"
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="text"
|
||||||
name="email"
|
name="email"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
|
@ -80,24 +77,26 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="password" value="Neues Passwort" />
|
<x-input-label for="password" value="Neues Passwort" />
|
||||||
<div class="relative">
|
<div class="relative" x-data="{ showPassword: false }">
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="password"
|
wire:model="password"
|
||||||
id="password"
|
id="password"
|
||||||
:type="$showPassword ? 'text' : 'password'"
|
type="password"
|
||||||
|
x-bind:type="showPassword ? 'text' : 'password'"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
class="pr-10"
|
class="pr-10"
|
||||||
/>
|
/>
|
||||||
<button type="button" wire:click="$set('showPassword', !$showPassword)"
|
<button type="button" @click="showPassword = !showPassword"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[--color-text-muted] hover:text-[--color-text-secondary] transition-colors duration-150 cursor-pointer"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors duration-150 cursor-pointer"
|
||||||
aria-label="Passwort anzeigen">
|
aria-label="Passwort anzeigen">
|
||||||
@if($showPassword)
|
<span x-show="!showPassword">
|
||||||
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
|
||||||
@else
|
|
||||||
<x-heroicon-o-eye class="w-4 h-4" />
|
<x-heroicon-o-eye class="w-4 h-4" />
|
||||||
@endif
|
</span>
|
||||||
|
<span x-show="showPassword" x-cloak>
|
||||||
|
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<x-input-error :messages="$errors->get('password')" />
|
<x-input-error :messages="$errors->get('password')" />
|
||||||
|
|
@ -105,40 +104,41 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-input-label for="password_confirmation" value="Passwort bestätigen" />
|
<x-input-label for="password_confirmation" value="Passwort bestätigen" />
|
||||||
<div class="relative">
|
<div class="relative" x-data="{ showPassword: false }">
|
||||||
<x-text-input
|
<x-text-input
|
||||||
wire:model="password_confirmation"
|
wire:model="password_confirmation"
|
||||||
id="password_confirmation"
|
id="password_confirmation"
|
||||||
:type="$showPasswordConfirmation ? 'text' : 'password'"
|
type="password"
|
||||||
|
x-bind:type="showPassword ? 'text' : 'password'"
|
||||||
name="password_confirmation"
|
name="password_confirmation"
|
||||||
required
|
required
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
class="pr-10"
|
class="pr-10"
|
||||||
/>
|
/>
|
||||||
<button type="button" wire:click="$set('showPasswordConfirmation', !$showPasswordConfirmation)"
|
<button type="button" @click="showPassword = !showPassword"
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-[--color-text-muted] hover:text-[--color-text-secondary] transition-colors duration-150 cursor-pointer"
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-secondary transition-colors duration-150 cursor-pointer"
|
||||||
aria-label="Passwort anzeigen">
|
aria-label="Passwort anzeigen">
|
||||||
@if($showPasswordConfirmation)
|
<span x-show="!showPassword">
|
||||||
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
|
||||||
@else
|
|
||||||
<x-heroicon-o-eye class="w-4 h-4" />
|
<x-heroicon-o-eye class="w-4 h-4" />
|
||||||
@endif
|
</span>
|
||||||
|
<span x-show="showPassword" x-cloak>
|
||||||
|
<x-heroicon-o-eye-slash class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<x-input-error :messages="$errors->get('password_confirmation')" />
|
<x-input-error :messages="$errors->get('password_confirmation')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<x-primary-button class="w-full" wire:loading.attr="disabled">
|
<x-primary-button class="w-full relative" style="position:relative;" wire:loading.attr="disabled">
|
||||||
<span wire:loading.remove wire:target="resetPassword">
|
<span wire:loading.class="opacity-50" wire:target="resetPassword" class="flex items-center gap-2 transition-opacity">
|
||||||
<x-heroicon-o-lock-closed class="w-4 h-4" />
|
<x-heroicon-o-lock-closed class="w-4 h-4" />
|
||||||
Passwort speichern
|
Passwort speichern
|
||||||
</span>
|
</span>
|
||||||
<span wire:loading wire:target="resetPassword" class="flex items-center gap-2">
|
<span wire:loading.flex wire:target="resetPassword" style="position:absolute;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;">
|
||||||
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Wird gespeichert…
|
|
||||||
</span>
|
</span>
|
||||||
</x-primary-button>
|
</x-primary-button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,30 +28,38 @@ new #[Layout('layouts.guest')] class extends Component
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-[--color-text-primary]">E-Mail bestätigen</h1>
|
<h1 class="text-2xl font-bold tracking-tight text-text-primary">E-Mail bestätigen</h1>
|
||||||
<p class="text-sm text-[--color-text-muted] mt-1">
|
<p class="text-sm text-text-muted mt-1">
|
||||||
Bitte klicke auf den Link in der E-Mail, die wir dir gesendet haben.
|
Bitte klicke auf den Link in der E-Mail, die wir dir gesendet haben.
|
||||||
Kein Link erhalten? Wir schicken dir einen neuen.
|
Kein Link erhalten? Wir schicken dir einen neuen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (session('status') == 'verification-link-sent')
|
@if (session('status') == 'verification-link-sent')
|
||||||
<div class="flex items-center gap-2 text-sm font-medium text-[--color-success] bg-[oklch(0.96_0.05_155)] border border-[oklch(0.88_0.10_155)] rounded-lg px-4 py-3 mb-5">
|
<div class="flex items-center gap-2 text-sm font-medium text-success bg-[oklch(0.96_0.05_155)] border border-[oklch(0.88_0.10_155)] rounded-lg px-4 py-3 mb-5">
|
||||||
<x-heroicon-s-check-circle class="w-4 h-4 shrink-0" />
|
<x-heroicon-s-check-circle class="w-4 h-4 shrink-0" />
|
||||||
Neuer Bestätigungslink wurde gesendet.
|
Neuer Bestätigungslink wurde gesendet.
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<x-primary-button class="w-full" wire:click="sendVerification" wire:loading.attr="disabled">
|
<x-primary-button class="w-full relative" style="position:relative;" wire:click="sendVerification" wire:loading.attr="disabled">
|
||||||
|
<span wire:loading.class="opacity-50" wire:target="sendVerification" class="flex items-center gap-2 transition-opacity">
|
||||||
<x-heroicon-o-envelope class="w-4 h-4" />
|
<x-heroicon-o-envelope class="w-4 h-4" />
|
||||||
Bestätigungslink erneut senden
|
Bestätigungslink erneut senden
|
||||||
|
</span>
|
||||||
|
<span wire:loading.flex wire:target="sendVerification" style="position:absolute;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;">
|
||||||
|
<svg class="animate-spin w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</x-primary-button>
|
</x-primary-button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
wire:click="logout"
|
wire:click="logout"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full flex items-center justify-center gap-2 px-5 py-2.5 text-sm font-medium text-[--color-text-secondary] hover:text-[--color-text-primary] border border-[--color-border-subtle] rounded-lg hover:bg-[--color-bg-base] transition-all duration-150 cursor-pointer"
|
class="w-full flex items-center justify-center gap-2 px-5 py-2.5 text-sm font-medium text-text-secondary hover:text-text-primary border border-border-subtle rounded-lg hover:bg-bg-base transition-all duration-150 cursor-pointer"
|
||||||
>
|
>
|
||||||
<x-heroicon-o-arrow-left-on-rectangle class="w-4 h-4" />
|
<x-heroicon-o-arrow-left-on-rectangle class="w-4 h-4" />
|
||||||
Abmelden
|
Abmelden
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<div class="flex flex-col gap-6" x-data="{
|
||||||
|
file: null,
|
||||||
|
preview: null,
|
||||||
|
status: 'idle',
|
||||||
|
handleFile(event) {
|
||||||
|
const f = event.target.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
this.file = f;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => this.preview = e.target.result;
|
||||||
|
reader.readAsDataURL(f);
|
||||||
|
},
|
||||||
|
analyze() {
|
||||||
|
if (!this.file) return;
|
||||||
|
this.status = 'uploading';
|
||||||
|
setTimeout(() => { this.status = 'analyzing'; }, 800);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.status = 'idle';
|
||||||
|
Livewire.dispatch('openModal', { component: 'photo-help.modals.result' });
|
||||||
|
}, 2200);
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.file = null;
|
||||||
|
this.preview = null;
|
||||||
|
this.status = 'idle';
|
||||||
|
},
|
||||||
|
}">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">KI-Unterstützung</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Foto-Hilfe</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Foto der Aufgabe hochladen — die KI erklärt den Lösungsweg Schritt für Schritt.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-5">
|
||||||
|
|
||||||
|
<x-card class="col-span-2 shadow-sm">
|
||||||
|
<div x-show="!preview" class="border-2 border-dashed border-line rounded-lg p-12 text-center hover:border-primary-ink transition-colors">
|
||||||
|
<div class="w-14 h-14 rounded-full bg-primary-soft text-primary-ink grid place-items-center mx-auto mb-4">
|
||||||
|
<x-heroicon-o-camera class="w-7 h-7" />
|
||||||
|
</div>
|
||||||
|
<div class="font-bold text-ink-1 mb-1">Foto hochladen</div>
|
||||||
|
<p class="text-sm text-ink-2 mb-5 max-w-sm mx-auto leading-relaxed">JPG oder PNG, max. 10 MB. Achte auf gute Beleuchtung und scharfen Fokus.</p>
|
||||||
|
<label class="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-ink-1 text-bg-base text-sm font-semibold cursor-pointer hover:bg-ink-2 transition-all shadow-sm">
|
||||||
|
<x-heroicon-o-arrow-up-tray class="w-4 h-4" />
|
||||||
|
Datei wählen
|
||||||
|
<input type="file" accept="image/png,image/jpeg" class="sr-only" @change="handleFile($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="preview" x-cloak class="flex flex-col gap-4">
|
||||||
|
<div class="relative rounded-lg overflow-hidden border border-line bg-bg-tint">
|
||||||
|
<img :src="preview" alt="Foto-Preview" class="w-full max-h-96 object-contain">
|
||||||
|
<button type="button" @click="reset()" class="absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-full bg-ink-1/80 text-bg-base hover:bg-ink-1 transition-all" aria-label="Entfernen">
|
||||||
|
<x-heroicon-o-x-mark class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-btn variant="primary" class="flex-1 justify-center" @click="analyze()" x-bind:disabled="status !== 'idle'">
|
||||||
|
<span x-show="status === 'idle'" class="inline-flex items-center gap-2">
|
||||||
|
<x-heroicon-o-sparkles class="w-4 h-4" />
|
||||||
|
Lösungsweg erklären
|
||||||
|
</span>
|
||||||
|
<span x-show="status === 'uploading'" x-cloak class="inline-flex items-center gap-2">
|
||||||
|
<x-heroicon-o-arrow-path class="w-4 h-4 animate-spin" />
|
||||||
|
Hochladen…
|
||||||
|
</span>
|
||||||
|
<span x-show="status === 'analyzing'" x-cloak class="inline-flex items-center gap-2">
|
||||||
|
<x-heroicon-o-arrow-path class="w-4 h-4 animate-spin" />
|
||||||
|
KI analysiert…
|
||||||
|
</span>
|
||||||
|
</x-btn>
|
||||||
|
<x-btn @click="reset()">Anderes Foto</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<x-card variant="gradient-primary" class="shadow-sm">
|
||||||
|
<div class="flex items-center gap-2 mb-2 text-primary-ink">
|
||||||
|
<x-heroicon-o-light-bulb class="w-4 h-4" />
|
||||||
|
<span class="text-[10px] font-bold uppercase tracking-[0.12em]">Tipp</span>
|
||||||
|
</div>
|
||||||
|
<div class="font-bold text-sm text-ink-1 mb-1.5">Halte das Blatt gerade</div>
|
||||||
|
<p class="text-xs text-ink-2 leading-relaxed">Die KI versteht dein Foto besser, wenn die Aufgabe gerade im Bild liegt und gut zu lesen ist.</p>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<x-card class="shadow-sm">
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-3">Letzte Anfragen</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="flex items-center gap-2.5 text-xs">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-primary-soft text-primary-ink grid place-items-center font-bold">∑</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-semibold text-ink-1 truncate">Bruchrechnung</div>
|
||||||
|
<div class="text-ink-3">vor 2 Std.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2.5 text-xs">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-rose-soft text-rose-ink grid place-items-center font-bold">A</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-semibold text-ink-1 truncate">Diktat-Korrektur</div>
|
||||||
|
<div class="text-ink-3">gestern</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<div>
|
||||||
|
<header class="flex items-center justify-between gap-4 p-6 border-b border-line">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-primary-soft text-primary-ink grid place-items-center">
|
||||||
|
<x-heroicon-o-sparkles class="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-[0.12em] text-ink-3 font-semibold">KI-Analyse</div>
|
||||||
|
<div class="font-bold text-base text-ink-1">Lösungsweg</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 flex flex-col gap-4">
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-primary text-white grid place-items-center text-xs font-bold shrink-0">1</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 mb-1">Erkennung</div>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">Aufgabe erkannt: schriftliche Multiplikation. Zwei dreistellige Zahlen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-primary text-white grid place-items-center text-xs font-bold shrink-0">2</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 mb-1">Methode</div>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">Stellengerechte Multiplikation — beginne mit der Einer-Stelle des unteren Faktors.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 items-start">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-primary text-white grid place-items-center text-xs font-bold shrink-0">3</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 mb-1">Schritte</div>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">Einer-Produkt notieren, Zehner-Produkt um eine Stelle versetzt darunter schreiben, dann addieren.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-primary-soft border border-primary-soft rounded-lg p-4 mt-2">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.12em] text-primary-ink font-semibold mb-1">Hinweis</div>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">Vergiss nicht den Übertrag bei jedem Stellenwechsel. Achte auf saubere Spalten.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="px-6 pb-6">
|
||||||
|
<x-btn variant="primary" class="w-full justify-center" wire:click="closeModal">
|
||||||
|
Verstanden
|
||||||
|
</x-btn>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
@php
|
||||||
|
$subjects = [
|
||||||
|
['name' => 'Mathematik', 'icon' => '∑', 'color' => 'primary', 'progress' => 75, 'lessons' => '18 / 24'],
|
||||||
|
['name' => 'Deutsch', 'icon' => 'A', 'color' => 'rose', 'progress' => 65, 'lessons' => '13 / 20'],
|
||||||
|
['name' => 'Englisch', 'icon' => 'E', 'color' => 'violet', 'progress' => 50, 'lessons' => '8 / 16'],
|
||||||
|
['name' => 'Sachkunde', 'icon' => '🌱', 'color' => 'green', 'progress' => 78, 'lessons' => '14 / 18'],
|
||||||
|
['name' => 'Musik', 'icon' => '♪', 'color' => 'amber', 'progress' => 42, 'lessons' => '5 / 12'],
|
||||||
|
['name' => 'Sport', 'icon' => '⚽', 'color' => 'green', 'progress' => 64, 'lessons' => '9 / 14'],
|
||||||
|
];
|
||||||
|
$weekDays = [
|
||||||
|
['day' => 'Mo', 'minutes' => 12],
|
||||||
|
['day' => 'Di', 'minutes' => 18],
|
||||||
|
['day' => 'Mi', 'minutes' => 22],
|
||||||
|
['day' => 'Do', 'minutes' => 9],
|
||||||
|
['day' => 'Fr', 'minutes' => 15],
|
||||||
|
['day' => 'Sa', 'minutes' => 6],
|
||||||
|
['day' => 'So', 'minutes' => 4],
|
||||||
|
];
|
||||||
|
$maxMin = max(array_column($weekDays, 'minutes')) ?: 1;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Analyse</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Fortschritt</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Verlauf, Streaks und Bestleistungen — sieh wie weit du gekommen bist.</p>
|
||||||
|
</div>
|
||||||
|
<x-btn>
|
||||||
|
<x-heroicon-o-arrow-down-tray class="w-3.5 h-3.5" />
|
||||||
|
Export
|
||||||
|
</x-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{-- Stats Overview --}}
|
||||||
|
<section class="grid grid-cols-4 gap-4">
|
||||||
|
<x-stat-tile label="Gesamtpunkte" value="2.480" trend="+180 diese Woche" />
|
||||||
|
<x-stat-tile label="Lernserie" value="7" unit="Tage" trend="+2 persönlicher Rekord" />
|
||||||
|
<x-stat-tile label="Lernzeit gesamt" value="14h" unit="32 Min." trend="+12% vs. Vormonat" />
|
||||||
|
<x-stat-tile label="Erfolgsquote" value="75 %" trend="Sehr gut" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Week Chart + Subjects --}}
|
||||||
|
<section class="grid grid-cols-3 gap-5">
|
||||||
|
<x-card class="col-span-2 shadow-sm">
|
||||||
|
<div class="flex justify-between items-center mb-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-bold text-base text-ink-1">Diese Woche</h2>
|
||||||
|
<p class="text-xs text-ink-3 mt-0.5">Lernzeit pro Tag in Minuten</p>
|
||||||
|
</div>
|
||||||
|
<x-pill variant="primary">86 Min. gesamt</x-pill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-7 gap-3 items-end h-48 pt-4">
|
||||||
|
@foreach($weekDays as $d)
|
||||||
|
@php $h = (int) round(($d['minutes'] / $maxMin) * 100); @endphp
|
||||||
|
<div class="flex flex-col items-center gap-2 h-full">
|
||||||
|
<div class="flex-1 w-full flex items-end">
|
||||||
|
<div class="w-full bg-primary-soft rounded-t-md relative group transition-all hover:bg-primary-ink/20" style="height:{{ $h }}%;">
|
||||||
|
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-[10px] font-mono tabular-nums font-bold text-ink-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{{ $d['minutes'] }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 rounded-t-md bg-primary-ink" style="height:100%;opacity:0.15;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] font-semibold text-ink-3">{{ $d['day'] }}</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
<x-card class="shadow-sm">
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-3">Streak</h2>
|
||||||
|
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-5xl font-extrabold tracking-tight text-amber-ink font-mono tabular-nums">7</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-1 font-semibold uppercase tracking-wider">Tage am Stück</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-7 gap-1 mt-2">
|
||||||
|
@for($i = 0; $i < 7; $i++)
|
||||||
|
<div class="aspect-square rounded-md bg-amber-soft border border-amber-soft grid place-items-center">
|
||||||
|
<x-heroicon-s-fire class="w-4 h-4 text-amber-ink" />
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-[11px] text-ink-3 mt-3 text-center">Persönlicher Rekord: 12 Tage</div>
|
||||||
|
</x-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Per-Subject Progress --}}
|
||||||
|
<section>
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-4">Fortschritt pro Fach</h2>
|
||||||
|
|
||||||
|
<x-card class="shadow-sm">
|
||||||
|
<div class="flex flex-col">
|
||||||
|
@foreach($subjects as $s)
|
||||||
|
@php
|
||||||
|
$colorMap = ['primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink'], 'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink'], 'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink'], 'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink'], 'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink']];
|
||||||
|
$c = $colorMap[$s['color']];
|
||||||
|
@endphp
|
||||||
|
<div class="grid grid-cols-[44px_1fr_140px_60px] gap-4 items-center py-3 border-t border-line first:border-t-0">
|
||||||
|
<div class="w-10 h-10 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center font-bold">{{ $s['icon'] }}</div>
|
||||||
|
<div class="font-semibold text-sm text-ink-1">{{ $s['name'] }}</div>
|
||||||
|
<div class="h-2 rounded-full bg-bg-base overflow-hidden">
|
||||||
|
<div class="h-full rounded-full {{ $c['bar'] }} transition-all duration-500" style="width:{{ $s['progress'] }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="font-mono tabular-nums text-xs font-semibold {{ $c['ink'] }} text-right">{{ $s['progress'] }} %</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
@php
|
||||||
|
$balance = 2480;
|
||||||
|
$items = [
|
||||||
|
['key' => 'r1', 'title' => 'Avatar-Brille (gold)', 'emoji' => '🕶', 'price' => 800, 'category' => 'Avatar', 'desc' => 'Goldene Sonnenbrille für deinen Avatar. Cool und stylisch.'],
|
||||||
|
['key' => 'r2', 'title' => 'Einhorn-Begleiter', 'emoji' => '🦄', 'price' => 1500, 'category' => 'Tier', 'desc' => 'Ein magischer Begleiter, der dich beim Lernen unterstützt.'],
|
||||||
|
['key' => 'r3', 'title' => 'Raketen-Theme', 'emoji' => '🚀', 'price' => 3200, 'category' => 'Theme', 'desc' => 'Verwandle dein Dashboard in eine Weltraumstation.'],
|
||||||
|
['key' => 'r4', 'title' => 'Cap (rot)', 'emoji' => '🧢', 'price' => 500, 'category' => 'Avatar', 'desc' => 'Stylische rote Cap für deinen Avatar.'],
|
||||||
|
['key' => 'r5', 'title' => 'Drache', 'emoji' => '🐉', 'price' => 2000, 'category' => 'Tier', 'desc' => 'Mächtiger Drachen-Begleiter mit Spezialeffekten.'],
|
||||||
|
['key' => 'r6', 'title' => 'Dunkles Theme', 'emoji' => '🌙', 'price' => 1200, 'category' => 'Theme', 'desc' => 'Augenfreundliches Dark-Mode-Theme für späte Lern-Sessions.'],
|
||||||
|
['key' => 'r7', 'title' => 'Trophy-Display', 'emoji' => '🏆', 'price' => 700, 'category' => 'Profil', 'desc' => 'Zeige deine Trophäen prominent in deinem Profil.'],
|
||||||
|
['key' => 'r8', 'title' => 'Smiley-Pack', 'emoji' => '😎', 'price' => 300, 'category' => 'Profil', 'desc' => 'Coole Emoji-Sticker für Reaktionen und Kommentare.'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Shop</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Belohnungen</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Gib deine Punkte für Avatare, Themes und Begleiter aus.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 px-4 py-2.5 rounded-lg bg-amber-soft border border-amber-soft">
|
||||||
|
<x-heroicon-s-star class="w-5 h-5 text-amber-ink" />
|
||||||
|
<span class="text-xl font-bold font-mono tabular-nums text-amber-ink">{{ number_format($balance, 0, ',', '.') }}</span>
|
||||||
|
<span class="text-xs font-semibold text-amber-ink">Pkt.</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-4 gap-4">
|
||||||
|
@foreach($items as $item)
|
||||||
|
@php $available = $balance >= $item['price']; @endphp
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'rewards.modals.purchase', arguments: { item: {{ \Illuminate\Support\Js::from($item) }}, balance: {{ $balance }} } })"
|
||||||
|
class="text-left bg-bg-soft border border-line rounded-xl p-4 shadow-sm transition-all duration-150 hover:shadow-md hover:-translate-y-0.5 hover:border-line-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 cursor-pointer group"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-lg bg-bg-tint grid place-items-center text-2xl mb-3 transition-transform group-hover:scale-105">
|
||||||
|
{{ $item['emoji'] }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold mb-1">{{ $item['category'] }}</div>
|
||||||
|
<div class="font-bold text-sm text-ink-1 mb-2 leading-tight">{{ $item['title'] }}</div>
|
||||||
|
<div class="flex justify-between items-baseline mt-3 pt-3 border-t border-line">
|
||||||
|
<span class="font-mono tabular-nums text-sm font-bold {{ $available ? 'text-green-ink' : 'text-ink-3' }}">
|
||||||
|
{{ number_format($item['price'], 0, ',', '.') }} Pkt.
|
||||||
|
</span>
|
||||||
|
@if($available)
|
||||||
|
<x-pill variant="success">Verfügbar</x-pill>
|
||||||
|
@else
|
||||||
|
<x-pill>Noch {{ number_format($item['price'] - $balance, 0, ',', '.') }}</x-pill>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<div>
|
||||||
|
@if(! $purchased)
|
||||||
|
<header class="flex items-start justify-between gap-4 p-6 border-b border-line">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-14 h-14 rounded-lg bg-bg-tint grid place-items-center text-3xl">
|
||||||
|
{{ $item['emoji'] ?? '🎁' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold mb-0.5">{{ $item['category'] ?? '' }}</div>
|
||||||
|
<div class="font-bold text-lg text-ink-1">{{ $item['title'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 flex flex-col gap-4">
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed">{{ $item['desc'] ?? '' }}</p>
|
||||||
|
|
||||||
|
<div class="bg-bg-tint rounded-lg p-4 border border-line">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="text-sm text-ink-2">Preis</span>
|
||||||
|
<span class="text-xl font-bold font-mono tabular-nums text-amber-ink">{{ number_format($item['price'] ?? 0, 0, ',', '.') }} Pkt.</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-baseline mt-1">
|
||||||
|
<span class="text-xs text-ink-3">Dein Guthaben</span>
|
||||||
|
<span class="text-xs font-mono tabular-nums text-ink-3">{{ number_format($balance, 0, ',', '.') }} Pkt.</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-baseline mt-1 pt-2 border-t border-line">
|
||||||
|
<span class="text-xs text-ink-3">Nach Kauf</span>
|
||||||
|
<span class="text-xs font-mono tabular-nums font-bold {{ ($balance - ($item['price'] ?? 0)) >= 0 ? 'text-green-ink' : 'text-danger' }}">
|
||||||
|
{{ number_format($balance - ($item['price'] ?? 0), 0, ',', '.') }} Pkt.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(! $confirming)
|
||||||
|
@if(($item['price'] ?? 0) > $balance)
|
||||||
|
<x-btn variant="primary" class="w-full justify-center" disabled>
|
||||||
|
<x-heroicon-o-shopping-bag class="w-4 h-4" />
|
||||||
|
Nicht genug Punkte
|
||||||
|
</x-btn>
|
||||||
|
@else
|
||||||
|
<x-btn variant="primary" class="w-full justify-center" wire:click="confirm">
|
||||||
|
<x-heroicon-o-shopping-bag class="w-4 h-4" />
|
||||||
|
Einlösen
|
||||||
|
</x-btn>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="bg-amber-soft border border-amber-soft rounded-lg p-3">
|
||||||
|
<p class="text-sm text-ink-1 font-semibold">Wirklich einlösen?</p>
|
||||||
|
<p class="text-xs text-ink-2 mt-0.5">Punkte werden sofort abgezogen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-btn wire:click="$set('confirming', false)" class="flex-1 justify-center">Abbrechen</x-btn>
|
||||||
|
<x-btn variant="primary" wire:click="purchase" class="flex-1 justify-center">Bestätigen</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-green-soft text-green-ink grid place-items-center mx-auto mb-4">
|
||||||
|
<x-heroicon-s-check class="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-bold text-ink-1 mb-1">Eingelöst!</div>
|
||||||
|
<p class="text-sm text-ink-2 mb-1">{{ $item['title'] ?? '' }} ist jetzt deins.</p>
|
||||||
|
<p class="text-xs text-ink-3 mb-6 font-mono tabular-nums">
|
||||||
|
Neues Guthaben: {{ number_format($balance, 0, ',', '.') }} Pkt.
|
||||||
|
</p>
|
||||||
|
<x-btn variant="primary" class="w-full justify-center" wire:click="closeModal">Weiter shoppen</x-btn>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
@php
|
||||||
|
$user = auth()->user();
|
||||||
|
$name = $user?->name ?? '—';
|
||||||
|
$email = $user?->email ?? '—';
|
||||||
|
|
||||||
|
$sections = [
|
||||||
|
['key' => 'profile', 'label' => 'Profil', 'icon' => 'user-circle'],
|
||||||
|
['key' => 'language', 'label' => 'Sprache & Region', 'icon' => 'language'],
|
||||||
|
['key' => 'security', 'label' => 'Sicherheit', 'icon' => 'shield-check'],
|
||||||
|
['key' => 'notifications', 'label' => 'Benachrichtigungen','icon' => 'bell'],
|
||||||
|
['key' => 'danger', 'label' => 'Gefahrenzone', 'icon' => 'exclamation-triangle'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Konto</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Einstellungen</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Verwalte dein Profil, Sprache, Sicherheit und Benachrichtigungen.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[200px_1fr] gap-6">
|
||||||
|
|
||||||
|
{{-- Section Sub-Nav (server-rendered active state, no Alpine flicker) --}}
|
||||||
|
<nav class="flex flex-col gap-1" wire:key="settings-subnav">
|
||||||
|
@foreach($sections as $s)
|
||||||
|
@php
|
||||||
|
$isActive = $section === $s['key'];
|
||||||
|
$cls = 'flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm cursor-pointer transition-all border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg-base text-left';
|
||||||
|
$cls .= $isActive
|
||||||
|
? ' bg-bg-soft text-ink-1 font-semibold shadow-sm border-line'
|
||||||
|
: ' text-ink-2 hover:bg-bg-soft border-transparent';
|
||||||
|
@endphp
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="setSection('{{ $s['key'] }}')"
|
||||||
|
class="{{ $cls }}"
|
||||||
|
data-testid="settings-nav-{{ $s['key'] }}"
|
||||||
|
@if($isActive) aria-current="page" @endif
|
||||||
|
>
|
||||||
|
<x-dynamic-component :component="'heroicon-o-' . $s['icon']" class="w-4 h-4 shrink-0" />
|
||||||
|
<span>{{ $s['label'] }}</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{-- Sections (server-renders only the active one, no Alpine flicker) --}}
|
||||||
|
<div class="flex flex-col gap-5" wire:key="settings-section-{{ $section }}">
|
||||||
|
|
||||||
|
@if($section === 'profile')
|
||||||
|
<x-card class="shadow-sm" data-section="profile">
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-4">Profil</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Name</label>
|
||||||
|
<input type="text" value="{{ $name }}" class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm text-ink-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">E-Mail-Adresse</label>
|
||||||
|
<input type="text" value="{{ $email }}" class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm text-ink-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Über mich</label>
|
||||||
|
<textarea rows="3" placeholder="Erzähl etwas über dich…" class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm text-ink-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5 pt-4 border-t border-line">
|
||||||
|
<x-btn>Abbrechen</x-btn>
|
||||||
|
<x-btn variant="primary">Speichern</x-btn>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($section === 'language')
|
||||||
|
<x-card class="shadow-sm" data-section="language">
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-4">Sprache & Region</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Sprache</label>
|
||||||
|
<select class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm text-ink-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<option>Deutsch</option>
|
||||||
|
<option>English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Zeitzone</label>
|
||||||
|
<select class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm text-ink-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<option>Europa/Wien (UTC+1)</option>
|
||||||
|
<option>Europa/Berlin (UTC+1)</option>
|
||||||
|
<option>Europa/Zürich (UTC+1)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5 pt-4 border-t border-line">
|
||||||
|
<x-btn variant="primary">Speichern</x-btn>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($section === 'security')
|
||||||
|
<x-card class="shadow-sm" data-section="security">
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-4">Sicherheit</h2>
|
||||||
|
<div class="flex flex-col gap-4" x-data="{ showPw: false, showNewPw: false }">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Aktuelles Passwort</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="password" x-bind:type="showPw ? 'text' : 'password'" class="w-full px-3.5 py-2.5 pr-10 bg-bg-soft border border-line rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<button type="button" @click="showPw = !showPw" class="absolute right-3 top-1/2 -translate-y-1/2 text-ink-3 hover:text-ink-1 transition-colors" aria-label="Passwort anzeigen">
|
||||||
|
<span x-show="!showPw"><x-heroicon-o-eye class="w-4 h-4" /></span>
|
||||||
|
<span x-show="showPw" x-cloak><x-heroicon-o-eye-slash class="w-4 h-4" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-ink-2 mb-1.5">Neues Passwort</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="password" x-bind:type="showNewPw ? 'text' : 'password'" class="w-full px-3.5 py-2.5 pr-10 bg-bg-soft border border-line rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||||
|
<button type="button" @click="showNewPw = !showNewPw" class="absolute right-3 top-1/2 -translate-y-1/2 text-ink-3 hover:text-ink-1 transition-colors" aria-label="Passwort anzeigen">
|
||||||
|
<span x-show="!showNewPw"><x-heroicon-o-eye class="w-4 h-4" /></span>
|
||||||
|
<span x-show="showNewPw" x-cloak><x-heroicon-o-eye-slash class="w-4 h-4" /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 pt-4 border-t border-line">
|
||||||
|
<x-btn variant="primary">Passwort ändern</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-5 border-t border-line">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'settings.modals.logout-all' })"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-danger hover:underline font-semibold cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-2 rounded"
|
||||||
|
data-testid="logout-all-trigger"
|
||||||
|
>
|
||||||
|
<x-heroicon-o-arrow-left-on-rectangle class="w-4 h-4" />
|
||||||
|
Auf allen Geräten abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($section === 'notifications')
|
||||||
|
<x-card class="shadow-sm" data-section="notifications">
|
||||||
|
<h2 class="font-bold text-base text-ink-1 mb-4">Benachrichtigungen</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
@php
|
||||||
|
$notifOpts = [
|
||||||
|
['key' => 'reminders', 'label' => 'Tägliche Erinnerungen', 'desc' => 'Erinnert dich bei offenen Aufgaben.', 'enabled' => true],
|
||||||
|
['key' => 'streak', 'label' => 'Streak-Warnungen', 'desc' => 'Bevor deine Lernserie verloren geht.', 'enabled' => true],
|
||||||
|
['key' => 'teacher', 'label' => 'Lehrer-Nachrichten', 'desc' => 'Direkte Nachrichten von Lehrkräften.', 'enabled' => true],
|
||||||
|
['key' => 'newsletter', 'label' => 'Lernschiff-Newsletter', 'desc' => 'Tipps, neue Features und Aktionen.', 'enabled' => false],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
@foreach($notifOpts as $opt)
|
||||||
|
<div class="flex items-start justify-between gap-4 py-2" x-data="{ enabled: {{ $opt['enabled'] ? 'true' : 'false' }} }">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-sm text-ink-1">{{ $opt['label'] }}</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5">{{ $opt['desc'] }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="enabled = !enabled" :aria-pressed="enabled"
|
||||||
|
:class="enabled ? 'bg-primary' : 'bg-line-strong'"
|
||||||
|
class="relative shrink-0 inline-flex h-5 w-9 items-center rounded-full transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2">
|
||||||
|
<span :class="enabled ? 'translate-x-4' : 'translate-x-0.5'" class="inline-block h-4 w-4 transform rounded-full bg-bg-soft shadow transition-transform"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end gap-2 mt-5 pt-4 border-t border-line">
|
||||||
|
<x-btn variant="primary">Speichern</x-btn>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($section === 'danger')
|
||||||
|
<x-card class="shadow-sm border-danger/30" data-section="danger">
|
||||||
|
<h2 class="font-bold text-base text-danger mb-1">Gefahrenzone</h2>
|
||||||
|
<p class="text-xs text-ink-3 mb-5">Diese Aktionen können nicht rückgängig gemacht werden.</p>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center py-3 border-t border-line">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-sm text-ink-1">Fortschritt zurücksetzen</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5">Alle Punkte, Streaks und Lektionen werden gelöscht.</div>
|
||||||
|
</div>
|
||||||
|
<x-btn>Zurücksetzen</x-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center py-3 border-t border-line">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-sm text-danger">Konto löschen</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5">Alle Daten unwiderruflich löschen.</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'settings.modals.delete' })"
|
||||||
|
class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-danger text-white text-sm font-semibold hover:opacity-90 cursor-pointer transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-danger focus-visible:ring-offset-2"
|
||||||
|
data-testid="delete-trigger"
|
||||||
|
>
|
||||||
|
<x-heroicon-o-trash class="w-4 h-4" />
|
||||||
|
Konto löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</x-card>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-danger/10 text-danger grid place-items-center mb-4">
|
||||||
|
<x-heroicon-o-trash class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-base text-ink-1 mb-1">Konto löschen?</h3>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed mb-4">
|
||||||
|
Alle deine Daten werden unwiderruflich gelöscht. Tippe <span class="font-mono font-bold">LÖSCHEN</span> zur Bestätigung.
|
||||||
|
</p>
|
||||||
|
<input type="text"
|
||||||
|
wire:model.live="confirmText"
|
||||||
|
class="w-full px-3.5 py-2.5 bg-bg-soft border border-line rounded-lg text-sm mb-5 focus:outline-none focus:ring-2 focus:ring-danger focus:border-transparent">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-btn wire:click="closeModal" class="flex-1 justify-center">Abbrechen</x-btn>
|
||||||
|
<button type="button"
|
||||||
|
wire:click="delete"
|
||||||
|
@disabled($confirmText !== 'LÖSCHEN')
|
||||||
|
class="flex-1 inline-flex items-center justify-center gap-2 px-3.5 py-2 rounded-lg bg-danger text-white text-sm font-semibold hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-all">
|
||||||
|
Endgültig löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-amber-soft text-amber-ink grid place-items-center mb-4">
|
||||||
|
<x-heroicon-o-exclamation-triangle class="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-base text-ink-1 mb-1">Auf allen Geräten abmelden?</h3>
|
||||||
|
<p class="text-sm text-ink-2 leading-relaxed mb-5">Du wirst auf allen Geräten ausgeloggt und musst dich neu anmelden.</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<x-btn wire:click="closeModal" class="flex-1 justify-center">Abbrechen</x-btn>
|
||||||
|
<x-btn variant="primary" wire:click="logoutAll" class="flex-1 justify-center">Abmelden</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
@php
|
||||||
|
$subjects = [
|
||||||
|
['key' => 'math', 'name' => 'Mathematik', 'icon' => '∑', 'color' => 'primary', 'lessons' => '18 / 24', 'progress' => 75, 'next' => 'Schriftliches Multiplizieren', 'level' => 'Klasse 4 · Niveau B'],
|
||||||
|
['key' => 'german', 'name' => 'Deutsch', 'icon' => 'A', 'color' => 'rose', 'lessons' => '13 / 20', 'progress' => 65, 'next' => 'Wortarten erkennen', 'level' => 'Klasse 4 · Niveau B'],
|
||||||
|
['key' => 'english', 'name' => 'Englisch', 'icon' => 'E', 'color' => 'violet', 'lessons' => '8 / 16', 'progress' => 50, 'next' => 'Past Simple üben', 'level' => 'Klasse 4 · Niveau A'],
|
||||||
|
['key' => 'science', 'name' => 'Sachkunde', 'icon' => '🌱', 'color' => 'green', 'lessons' => '14 / 18', 'progress' => 78, 'next' => 'Wasserkreislauf', 'level' => 'Klasse 4 · Niveau B'],
|
||||||
|
['key' => 'music', 'name' => 'Musik', 'icon' => '♪', 'color' => 'amber', 'lessons' => '5 / 12', 'progress' => 42, 'next' => 'Notenlehre Grundlagen', 'level' => 'Klasse 4 · Niveau A'],
|
||||||
|
['key' => 'art', 'name' => 'Kunst', 'icon' => '🎨', 'color' => 'violet', 'lessons' => '4 / 10', 'progress' => 40, 'next' => 'Farbenlehre', 'level' => 'Klasse 4 · Niveau A'],
|
||||||
|
['key' => 'sport', 'name' => 'Sport', 'icon' => '⚽', 'color' => 'green', 'lessons' => '9 / 14', 'progress' => 64, 'next' => 'Koordinationsübungen', 'level' => 'Klasse 4 · Niveau B'],
|
||||||
|
['key' => 'philo', 'name' => 'Philosophie','icon' => '?', 'color' => 'amber', 'lessons' => '3 / 8', 'progress' => 38, 'next' => 'Was ist Freundschaft?', 'level' => 'Klasse 4 · Niveau A'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Lernbereich</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Meine Fächer</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">{{ count($subjects) }} Fächer · Klick auf ein Fach für Details und nächste Lektionen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 items-center">
|
||||||
|
<x-pill>Klasse 4 · Niveau B</x-pill>
|
||||||
|
<x-btn>
|
||||||
|
<x-heroicon-o-funnel class="w-3.5 h-3.5" />
|
||||||
|
Filter
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid grid-cols-4 gap-4">
|
||||||
|
@foreach($subjects as $s)
|
||||||
|
<x-subject-card
|
||||||
|
:name="$s['name']"
|
||||||
|
:icon="$s['icon']"
|
||||||
|
:color="$s['color']"
|
||||||
|
:lessons="$s['lessons']"
|
||||||
|
:progress="$s['progress']"
|
||||||
|
:next="$s['next']"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'subjects.modals.detail', arguments: { subject: {{ \Illuminate\Support\Js::from($s) }} } })"
|
||||||
|
/>
|
||||||
|
@endforeach
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
@php
|
||||||
|
$colorMap = [
|
||||||
|
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink', 'bar' => 'bg-primary-ink'],
|
||||||
|
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink', 'bar' => 'bg-rose-ink'],
|
||||||
|
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink', 'bar' => 'bg-violet-ink'],
|
||||||
|
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink', 'bar' => 'bg-green-ink'],
|
||||||
|
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink', 'bar' => 'bg-amber-ink'],
|
||||||
|
];
|
||||||
|
$c = $colorMap[$subject['color'] ?? 'primary'] ?? $colorMap['primary'];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<header class="flex items-start justify-between gap-4 p-6 border-b border-line">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-12 h-12 rounded-lg grid place-items-center font-bold text-xl {{ $c['bg'] }} {{ $c['ink'] }}">
|
||||||
|
{{ $subject['icon'] ?? '?' }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-lg text-ink-1">{{ $subject['name'] ?? '' }}</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5">{{ $subject['level'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 flex flex-col gap-5">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-2">Fortschritt</div>
|
||||||
|
<div class="flex justify-between items-baseline mb-2">
|
||||||
|
<span class="text-sm text-ink-2 font-mono tabular-nums">{{ $subject['lessons'] ?? '0 / 0' }} Lektionen</span>
|
||||||
|
<span class="text-2xl font-bold tracking-tight text-ink-1 font-mono tabular-nums">{{ (int) ($subject['progress'] ?? 0) }} %</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-full bg-bg-base overflow-hidden">
|
||||||
|
<div class="h-full rounded-full {{ $c['bar'] }} transition-all duration-500" style="width:{{ (int) ($subject['progress'] ?? 0) }}%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-bg-tint rounded-lg p-4 border border-line">
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-1">Nächste Lektion</div>
|
||||||
|
<div class="font-semibold text-sm text-ink-1">{{ $subject['next'] ?? '—' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<x-btn variant="primary" class="flex-1 justify-center">
|
||||||
|
<x-heroicon-s-play class="w-4 h-4" />
|
||||||
|
Lektion starten
|
||||||
|
</x-btn>
|
||||||
|
<x-btn class="flex-1 justify-center" wire:click="closeModal">
|
||||||
|
<x-heroicon-o-list-bullet class="w-4 h-4" />
|
||||||
|
Alle Lektionen
|
||||||
|
</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
@php
|
||||||
|
$colorMap = [
|
||||||
|
'primary' => ['bg' => 'bg-primary-soft', 'ink' => 'text-primary-ink'],
|
||||||
|
'rose' => ['bg' => 'bg-rose-soft', 'ink' => 'text-rose-ink'],
|
||||||
|
'violet' => ['bg' => 'bg-violet-soft', 'ink' => 'text-violet-ink'],
|
||||||
|
'green' => ['bg' => 'bg-green-soft', 'ink' => 'text-green-ink'],
|
||||||
|
'amber' => ['bg' => 'bg-amber-soft', 'ink' => 'text-amber-ink'],
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<header class="flex justify-between items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.14em] text-ink-3 font-semibold mb-1.5">Heute</div>
|
||||||
|
<h1 class="text-[28px] leading-tight font-extrabold tracking-tight text-ink-1">Aufgaben</h1>
|
||||||
|
<p class="text-sm text-ink-2 mt-2 max-w-xl leading-relaxed">Deine offenen Aufgaben — sortiert nach Fälligkeit und Punkten.</p>
|
||||||
|
</div>
|
||||||
|
<x-btn variant="primary" size="lg">
|
||||||
|
<x-heroicon-s-play class="w-4 h-4" />
|
||||||
|
Alle starten
|
||||||
|
</x-btn>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{-- Filter Pills (URL-synced via #[Url]) --}}
|
||||||
|
<div class="flex gap-2" wire:key="filter-bar">
|
||||||
|
@foreach(['open' => 'Offen', 'done' => 'Erledigt', 'all' => 'Alle'] as $key => $label)
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="setFilter('{{ $key }}')"
|
||||||
|
wire:loading.attr="disabled"
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-semibold transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 {{ $filter === $key ? 'bg-ink-1 text-bg-base border-ink-1' : 'bg-bg-soft text-ink-2 border-line hover:bg-bg-tint' }}"
|
||||||
|
data-testid="filter-{{ $key }}"
|
||||||
|
@if($filter === $key) aria-current="page" @endif
|
||||||
|
>
|
||||||
|
@if($key === 'open')
|
||||||
|
<x-heroicon-o-clock class="w-3.5 h-3.5" />
|
||||||
|
@elseif($key === 'done')
|
||||||
|
<x-heroicon-o-check-circle class="w-3.5 h-3.5" />
|
||||||
|
@endif
|
||||||
|
{{ $label }} <span class="font-mono tabular-nums">({{ $this->counts[$key] }})</span>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Skeleton during filter switch — bg-bg-tint container, bg-line bars for contrast --}}
|
||||||
|
<div wire:loading.flex wire:target="setFilter" class="hidden flex-col bg-bg-soft border border-line rounded-xl p-5 shadow-sm" data-testid="tasks-skeleton">
|
||||||
|
@for($i = 0; $i < 4; $i++)
|
||||||
|
<div class="grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0 animate-pulse">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-line"></div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="h-3 bg-line rounded w-3/5"></div>
|
||||||
|
<div class="h-2 bg-line rounded w-2/5"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="h-3 bg-line rounded w-16"></div>
|
||||||
|
<div class="h-7 bg-line rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Task list — modal triggered ONLY by Detail button, row itself is passive --}}
|
||||||
|
<x-card class="shadow-sm" wire:loading.remove wire:target="setFilter">
|
||||||
|
@if(empty($this->filteredTasks))
|
||||||
|
<div class="py-8 text-center text-sm text-ink-3">Keine Aufgaben in dieser Ansicht.</div>
|
||||||
|
@else
|
||||||
|
<div class="flex flex-col">
|
||||||
|
@foreach($this->filteredTasks as $t)
|
||||||
|
@php $c = $colorMap[$t['color']]; @endphp
|
||||||
|
<div
|
||||||
|
wire:key="task-{{ $t['key'] }}"
|
||||||
|
class="group grid grid-cols-[44px_1fr_auto] gap-3 items-center py-3 border-t border-line first:border-t-0"
|
||||||
|
data-testid="task-row"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-lg {{ $c['bg'] }} {{ $c['ink'] }} grid place-items-center font-bold">
|
||||||
|
{{ $t['icon'] }}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="font-semibold text-sm text-ink-1 truncate">{{ $t['title'] }}</div>
|
||||||
|
@if($t['status'] === 'done')
|
||||||
|
<x-heroicon-s-check-circle class="w-4 h-4 text-green-ink shrink-0" />
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-ink-3 mt-0.5 truncate">{{ $t['meta'] }} · {{ $t['due'] }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-mono tabular-nums text-xs text-amber-ink font-bold">+{{ $t['points'] }} Pkt.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
wire:click="$dispatch('openModal', { component: 'tasks.modals.detail', arguments: { task: {{ \Illuminate\Support\Js::from($t) }} } })"
|
||||||
|
class="w-8 h-8 grid place-items-center rounded-lg border border-line bg-bg-soft text-ink-2 hover:bg-bg-tint hover:text-ink-1 transition-all cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"
|
||||||
|
aria-label="Details anzeigen"
|
||||||
|
data-testid="task-detail-btn"
|
||||||
|
>
|
||||||
|
<x-heroicon-o-information-circle class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
@if($t['status'] === 'open')
|
||||||
|
<x-btn variant="primary" size="sm"
|
||||||
|
wire:click="startTask('{{ $t['key'] }}')"
|
||||||
|
data-testid="task-start-btn">
|
||||||
|
Starten
|
||||||
|
</x-btn>
|
||||||
|
@else
|
||||||
|
<x-btn size="sm"
|
||||||
|
wire:click="repeatTask('{{ $t['key'] }}')"
|
||||||
|
data-testid="task-repeat-btn">
|
||||||
|
Wiederholen
|
||||||
|
</x-btn>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</x-card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<div>
|
||||||
|
<header class="flex items-start justify-between gap-4 p-6 border-b border-line">
|
||||||
|
<div>
|
||||||
|
<div class="text-[11px] uppercase tracking-[0.12em] text-ink-3 font-semibold mb-1">{{ $task['meta'] ?? '' }}</div>
|
||||||
|
<div class="font-bold text-lg text-ink-1">{{ $task['title'] ?? '' }}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" wire:click="closeModal" aria-label="Schließen" class="text-ink-3 hover:text-ink-1 transition-colors p-1">
|
||||||
|
<x-heroicon-o-x-mark class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="p-6 flex flex-col gap-4">
|
||||||
|
<div class="grid grid-cols-3 gap-3">
|
||||||
|
<div class="bg-bg-tint rounded-lg p-3 border border-line">
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Punkte</div>
|
||||||
|
<div class="text-xl font-bold font-mono tabular-nums text-amber-ink mt-1">+{{ (int) ($task['points'] ?? 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-bg-tint rounded-lg p-3 border border-line">
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Fällig</div>
|
||||||
|
<div class="text-xs font-semibold text-ink-1 mt-1">{{ $task['due'] ?? '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-bg-tint rounded-lg p-3 border border-line">
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-ink-3 font-semibold">Status</div>
|
||||||
|
<div class="text-xs font-semibold mt-1 {{ ($task['status'] ?? 'open') === 'done' ? 'text-green-ink' : 'text-primary-ink' }}">
|
||||||
|
{{ ($task['status'] ?? 'open') === 'done' ? 'Erledigt' : 'Offen' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<x-btn variant="primary" class="flex-1 justify-center">
|
||||||
|
<x-heroicon-s-play class="w-4 h-4" />
|
||||||
|
Aufgabe starten
|
||||||
|
</x-btn>
|
||||||
|
<x-btn wire:click="closeModal" class="flex-1 justify-center">Schließen</x-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<x-layouts.auth>
|
<x-layouts.auth>
|
||||||
<div class="text-center p-8">
|
<div class="text-center p-8">
|
||||||
<h1 class="text-2xl font-bold text-[--color-text-primary] mb-4">Lizenz abgelaufen</h1>
|
<h1 class="text-2xl font-bold text-text-primary mb-4">Lizenz abgelaufen</h1>
|
||||||
<p class="text-[--color-text-secondary] mb-6">
|
<p class="text-text-secondary mb-6">
|
||||||
Deine Lizenz ist abgelaufen. Bitte wende dich an deinen Schuladministrator.
|
Deine Lizenz ist abgelaufen. Bitte wende dich an deinen Schuladministrator.
|
||||||
</p>
|
</p>
|
||||||
<a href="{{ route('login') }}" class="underline text-[--color-text-muted]">Zurück zur Anmeldung</a>
|
<a href="{{ route('login') }}" class="underline text-text-muted">Zurück zur Anmeldung</a>
|
||||||
</div>
|
</div>
|
||||||
</x-layouts.auth>
|
</x-layouts.auth>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<div>
|
||||||
|
@isset($jsPath)
|
||||||
|
<script>{!! file_get_contents($jsPath) !!}</script>
|
||||||
|
@endisset
|
||||||
|
@isset($cssPath)
|
||||||
|
<style>{!! file_get_contents($cssPath) !!}</style>
|
||||||
|
@endisset
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-data="LivewireUIModal()"
|
||||||
|
x-on:close.stop="setShowPropertyTo(false)"
|
||||||
|
x-on:keydown.escape.window="show && closeModalOnEscape()"
|
||||||
|
x-show="show"
|
||||||
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
|
style="display: none;"
|
||||||
|
data-testid="modal-root"
|
||||||
|
>
|
||||||
|
{{-- Backdrop --}}
|
||||||
|
<div
|
||||||
|
x-show="show"
|
||||||
|
x-on:click="closeModalOnClickAway()"
|
||||||
|
x-transition:enter="ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 bg-ink-1/60 backdrop-blur-sm"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{{-- Centered modal container --}}
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center p-4 pointer-events-none">
|
||||||
|
<div
|
||||||
|
x-show="show && showActiveComponent"
|
||||||
|
x-transition:enter="ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
x-bind:class="modalWidth"
|
||||||
|
class="relative w-full bg-bg-soft rounded-xl shadow-md border border-line max-h-[90vh] overflow-y-auto pointer-events-auto"
|
||||||
|
id="modal-container"
|
||||||
|
x-trap.noscroll.inert="show && showActiveComponent"
|
||||||
|
aria-modal="true"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
@forelse($components as $id => $component)
|
||||||
|
<div x-show.immediate="activeComponent == '{{ $id }}'" x-ref="{{ $id }}" wire:key="{{ $id }}">
|
||||||
|
@livewire($component['name'], $component['arguments'], key($id))
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -9,7 +9,21 @@ Route::middleware(['auth', 'role:school-admin,teacher'])->group(function () {
|
||||||
Route::get('/school/dashboard', fn() => response('ok'))->name('school.dashboard');
|
Route::get('/school/dashboard', fn() => response('ok'))->name('school.dashboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/dashboard', fn() => view('dashboard'))->name('dashboard')->middleware(['auth', 'license']);
|
Route::middleware(['auth', 'license'])->group(function () {
|
||||||
|
Route::get('/dashboard', \App\Livewire\Dashboard\Index::class)->name('dashboard');
|
||||||
|
Route::get('/subjects', \App\Livewire\Subjects\Index::class)->name('subjects');
|
||||||
|
Route::get('/tasks', \App\Livewire\Tasks\Index::class)->name('tasks');
|
||||||
|
Route::get('/photo', \App\Livewire\PhotoHelp\Index::class)->name('photo');
|
||||||
|
Route::get('/rewards', \App\Livewire\Rewards\Index::class)->name('rewards');
|
||||||
|
Route::get('/progress', \App\Livewire\Progress\Index::class)->name('progress');
|
||||||
|
Route::get('/notifications', \App\Livewire\Notifications\Index::class)->name('notifications');
|
||||||
|
Route::get('/settings', \App\Livewire\Settings\Index::class)->name('settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::post('/logout', function (\App\Livewire\Actions\Logout $logout) {
|
||||||
|
$logout();
|
||||||
|
return redirect()->route('login');
|
||||||
|
})->middleware('auth')->name('logout');
|
||||||
|
|
||||||
Route::get('/lizenz-abgelaufen', fn() => view('lizenz-abgelaufen'))->name('lizenz.abgelaufen');
|
Route::get('/lizenz-abgelaufen', fn() => view('lizenz-abgelaufen'))->name('lizenz.abgelaufen');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,8 @@ test('navigation menu can be rendered', function () {
|
||||||
|
|
||||||
$response
|
$response
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSeeVolt('layout.navigation');
|
->assertSee('Lernschiff')
|
||||||
|
->assertSee('Startseite');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('users can logout', function () {
|
test('users can logout', function () {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\License;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->seed(\Database\Seeders\RolesSeeder::class);
|
||||||
|
|
||||||
|
$tenant = Tenant::firstOrCreate(
|
||||||
|
['name' => 'Test Schule'],
|
||||||
|
['type' => 'school', 'active' => true]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->user = User::withoutGlobalScopes()->create([
|
||||||
|
'name' => 'Logout Tester',
|
||||||
|
'email' => 'logout@test.example',
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$role = Role::where('name', 'child')->first();
|
||||||
|
DB::table('role_user')->insert([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'role_id' => $role->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$license = License::withoutGlobalScopes()->create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'type' => 'school_flat',
|
||||||
|
'expires_at' => now()->addYear(),
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('license_assignments')->insert([
|
||||||
|
'license_id' => $license->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout route is registered as POST', function () {
|
||||||
|
expect(\Illuminate\Support\Facades\Route::has('logout'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /logout signs out authenticated user', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
expect(auth()->check())->toBeTrue();
|
||||||
|
|
||||||
|
$this->post('/logout')
|
||||||
|
->assertRedirect(route('login'));
|
||||||
|
|
||||||
|
expect(auth()->check())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /logout returns 405 (only POST allowed)', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$this->get('/logout')->assertStatus(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dashboard renders logout button in sidebar', function () {
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
$response = $this->get(route('dashboard'));
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('data-testid="sidebar-logout"', false);
|
||||||
|
$response->assertSee('action="' . route('logout') . '"', false);
|
||||||
|
});
|
||||||
|
|
@ -10,8 +10,12 @@ test('registration screen is disabled (invite-only)', function () {
|
||||||
$this->get('/register')->assertNotFound();
|
$this->get('/register')->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new users can register', function () {
|
test('self-registration POST endpoint is not reachable', function () {
|
||||||
// Lernschiff does not support self-registration — users require a tenant_id.
|
// Lernschiff is invite-only — POST /register must return 404 like the GET.
|
||||||
// Registration is handled by school admins. This test is skipped.
|
$this->post('/register', [
|
||||||
$this->markTestSkipped('Self-registration is not supported in Lernschiff (tenant_id required).');
|
'name' => 'Should Not Work',
|
||||||
|
'email' => 'no-register@example.com',
|
||||||
|
'password' => 'password',
|
||||||
|
'password_confirmation' => 'password',
|
||||||
|
])->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
|
||||||