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
Boban Blaskovic 2026-05-22 21:36:04 +02:00
parent 5704d0fb58
commit 7e6d0d4ab4
116 changed files with 4266 additions and 167 deletions

8
.env.testing Normal file
View File

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

View File

@ -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],
];
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

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

View File

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

127
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "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": [

View File

@ -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'),

80
config/sidebar.php Normal file
View File

@ -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',
],
],
],
],
];

View File

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

View File

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

View File

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

View File

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

128
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/icons/favicon-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/icons/favicon-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 43 KiB

View File

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

BIN
public/logo/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

9
public/logo/favicon.svg Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,32 @@
{ {
"name": "Lernschiff", "name": "Lernschiff",
"short_name": "Lernschiff", "short_name": "Lernschiff",
"description": "Kinderlernplattform für 314 Jahre", "description": "Kinderlernplattform für 314 Jahre",
"start_url": "/", "lang": "de",
"display": "standalone", "start_url": "/",
"background_color": "#f0eee9", "scope": "/",
"theme_color": "#f0eee9", "display": "standalone",
"lang": "de", "orientation": "portrait-primary",
"icons": [ "background_color": "#f5f3ee",
{ "theme_color": "#f5f3ee",
"src": "/icons/icon-192.png", "icons": [
"sizes": "192x192", {
"type": "image/png" "src": "/icons/icon-192.png",
}, "sizes": "192x192",
{ "type": "image/png",
"src": "/icons/icon-512.png", "purpose": "any"
"sizes": "512x512", },
"type": "image/png" {
} "src": "/icons/icon-512.png",
] "sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512-maskable.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
} }

View File

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

View File

@ -0,0 +1,5 @@
sm:max-w-md
md:max-w-xl
lg:max-w-3xl
xl:max-w-5xl
2xl:max-w-7xl

View File

@ -1 +1 @@
// import './echo';

View File

@ -1 +1,2 @@
// Auth entry point // Auth entry point
import './echo';

15
resources/js/echo.js Normal file
View File

@ -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'],
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
]) }} ]) }}
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
<x-heroicon-o-envelope class="w-4 h-4" /> <span wire:loading.class="opacity-50" wire:target="sendVerification" class="flex items-center gap-2 transition-opacity">
Bestätigungslink erneut senden <x-heroicon-o-envelope class="w-4 h-4" />
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

@ -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 () {

View File

@ -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);
});

View File

@ -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();
}); });

Some files were not shown because too many files have changed in this diff Show More