Znovupoužitelné UI: Blade komponenty, atributy a sloty
Kompozice uživatelského rozhraní pomocí Blade komponent (<x-...>), sloty ($slot, pojmenované @slot), předávání dat a atributů ($attributes) na příkladu layoutu a detailu lekce.
Moderní kompozice rozhraní: Blade komponenty
Zatímco starší verze Laravelu spoléhaly převážně na direktivy @include a @extends, moderní Laravel klade důraz na Blade komponenty (zapisované pomocí XML tagu <x-...>).
Komponenty přinášejí do Blade šablon výhody známé z frontendových frameworků (React, Vue):
- Samostatnost a znovupoužitelnost kódu.
- Izolované předávání vlastností (props) a HTML atributů.
- Kompozici pomocí výchozích i pojmenovaných slotů.
1. Sloty: Výchozí obsah i pojmenované sekce
Každá komponenta může přijímat libovolný vnitřní obsah přes proměnnou $slot:
{{-- Komponenta layouts/app.blade.php --}}
<main class="flex-1">
{{ $slot }}
</main>
Použití komponenty v klientské šabloně:
<x-layouts.app>
<x-slot:header>
<h1 class="text-xl font-bold">Přehled lekce</h1>
</x-slot:header>
<div class="prose">
Zde je hlavní tělo lekce předané do výchozího $slot.
</div>
</x-layouts.app>
Pomocí <x-slot:header> můžeme definovat libovolné množství pojmenovaných slotů, které se v komponentě zobrazí jako proměnná odpovídajícího názvu ($header).
2. Balíček atributů $attributes a direktiva @props
Častou potřebou je předat komponentě HTML třídy nebo Alpine.js direktivy zvenčí. K tomu slouží speciální objekt $attributes:
{{-- resources/views/components/card.blade.php --}}
@props([
'variant' => 'default',
'title',
])
<div {{ $attributes->merge(['class' => 'rounded-xl p-5 border ' . ($variant === 'highlight' ? 'border-amber-500 bg-amber-500/10' : 'border-slate-800 bg-slate-900')]) }}>
<h3 class="font-bold text-white">{{ $title }}</h3>
<div>{{ $slot }}</div>
</div>
Metoda $attributes->merge() inteligentně sloučí výchozí CSS třídy komponenty s dodatečnými třídami předanými při volání, aniž by došlo k jejich nechtěnému přepsání!
3. Kompozice dvousloupcového rozložení v resources/views/course/lesson.blade.php
V naší aplikaci je detail lekce vzorem špičkové kompozice:
- Drobečková navigace a fáze životního cyklu: Rychlá orientace studenta v kurzu.
- Levý sloupec: Výklad lekce, interaktivní blok pro kopírování kódu (využívající Alpine.js
x-data="{ copied: false }") a Livewire kvíz<livewire:lesson-quiz :lesson="$lesson" />. - Pravý sloupec: Živý inspektor kódu a navigace mezi soubory anatomie.
{{-- resources/views/course/lesson.blade.php (Ukázka znovupoužitelné kompozice a Alpine.js) --}}
@extends('layouts.app')
@section('content')
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{{-- Drobečková navigace & štítek fáze životního cyklu --}}
<div class="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-800">
<nav class="flex items-center gap-2 text-xs text-slate-400">
<a href="{{ route('course.index') }}" class="hover:text-white">📚 Osnova</a>
<span>/</span>
<span class="text-slate-200">Modul {{ $module->number }} · Lekce {{ $lesson->number }}</span>
</nav>
@if($lesson->lifecycle_phase)
<div class="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg text-xs bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">
<span>Fáze: {{ $lesson->lifecycle_phase->label() }}</span>
</div>
@endif
</div>
{{-- Komponenta pro kód s okamžitou klientskou interakcí přes Alpine.js --}}
@if(!empty($lesson->code_snippet))
<div class="rounded-xl bg-slate-950 border border-slate-800 overflow-hidden shadow-xl" x-data="{ copied: false }">
<div class="flex items-center justify-between px-4 py-2 bg-slate-900 border-b border-slate-800 text-xs">
<span class="font-mono text-slate-300 font-semibold">💻 Klíčová konstrukce</span>
<button type="button"
@click="navigator.clipboard.writeText($refs.snippet.innerText); copied = true; setTimeout(() => copied = false, 2000)"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300 transition">
<span x-show="!copied">Kopírovat kód</span>
<span x-show="copied" class="text-emerald-400" style="display: none;">✓ Zkopírováno!</span>
</button>
</div>
<pre class="p-4 text-xs font-mono text-emerald-300 overflow-x-auto"><code x-ref="snippet">{{ $lesson->code_snippet }}</code></pre>
</div>
@endif
{{-- Začlenění reaktivní komponenty Livewire pro interaktivní kvíz --}}
<div class="pt-4">
<livewire:lesson-quiz :lesson="$lesson" />
</div>
</div>
@endsection
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Jak se v moderním Blade zapisuje anonymní komponenta s předáváním vlastností a HTML atributů?
Co představuje proměnná $slot v těle Blade komponenty layoutu?
K čemu slouží direktiva @props(['title', 'variant' => 'default']) na začátku šablony komponenty?
| 1 | <!DOCTYPE html> |
| 2 | <html lang="cs" class="h-full bg-slate-950"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <meta name="csrf-token" content="{{ csrf_token() }}"> |
| 7 | |
| 8 | <title>{{ $title ?? 'Anatomie Laravelu 12 & Moderního PHP 8.4' }} — Živá výuková platforma</title> |
| 9 | <meta name="description" content="Interaktivní výuková platforma Anatomie Laravelu 12 & Moderního PHP 8.4 na reálné živě běžící aplikaci."> |
| 10 | |
| 11 | <link rel="preconnect" href="https://fonts.bunny.net"> |
| 12 | <link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600,700|jetbrains-mono:400,500,600&display=swap" rel="stylesheet" /> |
| 13 | |
| 14 | @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) |
| 15 | @vite(['resources/css/app.css', 'resources/js/app.js']) |
| 16 | @endif |
| 17 | @livewireStyles |
| 18 | </head> |
| 19 | <body class="min-h-full flex flex-col bg-slate-950 text-slate-100 antialiased selection:bg-indigo-500 selection:text-white font-sans"> |
| 20 | @php |
| 21 | $student = $currentStudent ?? (app()->bound(\App\Services\StudentProgressService::class) ? app(\App\Services\StudentProgressService::class)->resolveProfile(request()->cookie(\App\Services\StudentProgressService::COOKIE_NAME ?? 'agy_student_token')) : null); |
| 22 | @endphp |
| 23 | |
| 24 | {{-- Horní navigace --}} |
| 25 | <header class="sticky top-0 z-50 border-b border-slate-800/80 bg-slate-950/90 backdrop-blur-md"> |
| 26 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> |
| 27 | <div class="flex items-center justify-between h-16 gap-4"> |
| 28 | {{-- Logo & Název --}} |
| 29 | <div class="flex items-center gap-3"> |
| 30 | <a href="{{ route('course.index') }}" class="flex items-center gap-3 group focus:outline-none"> |
| 31 | <div class="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500 via-purple-600 to-amber-500 p-0.5 shadow-lg shadow-indigo-500/20 group-hover:shadow-indigo-500/40 transition"> |
| 32 | <div class="w-full h-full bg-slate-950 rounded-[10px] flex items-center justify-center font-mono font-bold text-base text-amber-400 group-hover:text-amber-300"> |
| 33 | </> |
| 34 | </div> |
| 35 | </div> |
| 36 | <div> |
| 37 | <div class="flex items-center gap-2"> |
| 38 | <span class="font-bold text-base tracking-tight text-white group-hover:text-amber-300 transition"> |
| 39 | Anatomie Laravelu 12 & PHP 8.4 |
| 40 | </span> |
| 41 | <span class="hidden sm:inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-indigo-500/10 text-indigo-400 ring-1 ring-inset ring-indigo-500/20"> |
| 42 | v12.x |
| 43 | </span> |
| 44 | </div> |
| 45 | <p class="text-xs text-slate-400"> |
| 46 | Živá výuková platforma · <span class="text-slate-300">laravel.heatsharing.cz</span> |
| 47 | </p> |
| 48 | </div> |
| 49 | </a> |
| 50 | </div> |
| 51 | |
| 52 | {{-- Hlavní menu --}} |
| 53 | <nav class="hidden md:flex items-center gap-1"> |
| 54 | <a href="{{ route('course.index') }}" |
| 55 | class="px-3 py-2 rounded-lg text-sm font-medium transition {{ request()->routeIs('course.*') && !request()->routeIs('anatomy.*') ? 'bg-indigo-950/60 text-indigo-300 border border-indigo-800/50 shadow-sm' : 'text-slate-300 hover:text-white hover:bg-slate-900' }}"> |
| 56 | 📚 Osnova & Moduly |
| 57 | </a> |
| 58 | <a href="{{ route('anatomy.index') }}" |
| 59 | class="px-3 py-2 rounded-lg text-sm font-medium transition {{ request()->routeIs('anatomy.*') ? 'bg-indigo-950/60 text-indigo-300 border border-indigo-800/50 shadow-sm' : 'text-slate-300 hover:text-white hover:bg-slate-900' }}"> |
| 60 | 🔬 Živá Anatomie & Request Lifecycle |
| 61 | </a> |
| 62 | </nav> |
| 63 | |
| 64 | {{-- Postup studenta & Přenosový kód (Alpine.js dropdown) --}} |
| 65 | <div class="flex items-center gap-3" x-data="{ open: false }"> |
| 66 | <div class="relative"> |
| 67 | <button @click="open = !open" |
| 68 | type="button" |
| 69 | class="flex items-center gap-2.5 px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 hover:border-slate-700 text-xs font-medium text-slate-200 transition focus:outline-none focus:ring-2 focus:ring-indigo-500/50" |
| 70 | aria-expanded="false" |
| 71 | :aria-expanded="open.toString()"> |
| 72 | <span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span> |
| 73 | <span class="hidden sm:inline text-slate-400">Profil:</span> |
| 74 | <span class="font-mono text-amber-300 font-semibold"> |
| 75 | {{ $student->transfer_code ?? 'LRV-POSTUP' }} |
| 76 | </span> |
| 77 | <svg class="w-3.5 h-3.5 text-slate-400 transition-transform duration-200" :class="{ 'rotate-180': open }" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 78 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> |
| 79 | </svg> |
| 80 | </button> |
| 81 | |
| 82 | {{-- Dropdown panel --}} |
| 83 | <div x-show="open" |
| 84 | @click.outside="open = false" |
| 85 | x-transition:enter="transition ease-out duration-150" |
| 86 | x-transition:enter-start="opacity-0 scale-95" |
| 87 | x-transition:enter-end="opacity-100 scale-100" |
| 88 | x-transition:leave="transition ease-in duration-100" |
| 89 | x-transition:leave-start="opacity-100 scale-100" |
| 90 | x-transition:leave-end="opacity-0 scale-95" |
| 91 | class="absolute right-0 mt-2 w-80 sm:w-96 rounded-xl bg-slate-900 border border-slate-800 shadow-2xl p-4 text-xs text-slate-300 z-50" |
| 92 | style="display: none;"> |
| 93 | <div class="flex items-center justify-between pb-3 border-b border-slate-800"> |
| 94 | <div> |
| 95 | <h4 class="font-semibold text-white text-sm">Anonymní profil studenta</h4> |
| 96 | <p class="text-[11px] text-slate-400">Postup je uložen v cookie a v PostgreSQL.</p> |
| 97 | </div> |
| 98 | <span class="px-2 py-0.5 rounded text-[10px] font-semibold bg-emerald-500/10 text-emerald-400 ring-1 ring-inset ring-emerald-500/20"> |
| 99 | Aktivní |
| 100 | </span> |
| 101 | </div> |
| 102 | |
| 103 | <div class="py-3 space-y-2"> |
| 104 | <span class="text-slate-400 font-medium block">Tvůj unikátní přenosový kód:</span> |
| 105 | <div class="flex items-center gap-2"> |
| 106 | <code class="flex-1 px-3 py-2 bg-slate-950 border border-slate-800 rounded-lg text-amber-300 font-mono text-sm tracking-wider select-all text-center font-bold"> |
| 107 | {{ $student->transfer_code ?? 'LRV-GENEROVANI' }} |
| 108 | </code> |
| 109 | </div> |
| 110 | <p class="text-[11px] text-slate-500"> |
| 111 | Tento kód si uložte, pokud budete chtít pokračovat ve výuce na jiném zařízení nebo v jiném prohlížeči. |
| 112 | </p> |
| 113 | </div> |
| 114 | |
| 115 | <div class="pt-3 border-t border-slate-800"> |
| 116 | <form action="{{ route('course.switch-profile') }}" method="POST" class="space-y-2"> |
| 117 | @csrf |
| 118 | <label for="transfer_code_input" class="block text-slate-300 font-medium"> |
| 119 | Načíst profil z jiného zařízení: |
| 120 | </label> |
| 121 | <div class="flex gap-2"> |
| 122 | <input type="text" |
| 123 | id="transfer_code_input" |
| 124 | name="transfer_code" |
| 125 | placeholder="LRV-XXXX-XXXX" |
| 126 | required |
| 127 | class="flex-1 px-3 py-1.5 rounded-lg bg-slate-950 border border-slate-700 text-white font-mono text-xs uppercase placeholder:text-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"> |
| 128 | <button type="submit" |
| 129 | class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white font-semibold rounded-lg text-xs transition shadow-sm"> |
| 130 | Aktivovat |
| 131 | </button> |
| 132 | </div> |
| 133 | </form> |
| 134 | </div> |
| 135 | </div> |
| 136 | </div> |
| 137 | </div> |
| 138 | </div> |
| 139 | |
| 140 | {{-- Mobilní navigace --}} |
| 141 | <div class="flex md:hidden border-t border-slate-800/80 py-2 gap-2"> |
| 142 | <a href="{{ route('course.index') }}" |
| 143 | class="flex-1 text-center px-3 py-1.5 rounded text-xs font-medium {{ request()->routeIs('course.*') && !request()->routeIs('anatomy.*') ? 'bg-indigo-950 text-indigo-300' : 'text-slate-400 hover:text-white' }}"> |
| 144 | 📚 Osnova & Moduly |
| 145 | </a> |
| 146 | <a href="{{ route('anatomy.index') }}" |
| 147 | class="flex-1 text-center px-3 py-1.5 rounded text-xs font-medium {{ request()->routeIs('anatomy.*') ? 'bg-indigo-950 text-indigo-300' : 'text-slate-400 hover:text-white' }}"> |
| 148 | 🔬 Živá Anatomie |
| 149 | </a> |
| 150 | </div> |
| 151 | </div> |
| 152 | </header> |
| 153 | |
| 154 | {{-- Flash zprávy --}} |
| 155 | @if(session('success')) |
| 156 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4"> |
| 157 | <div class="rounded-xl bg-emerald-950/60 border border-emerald-800/80 p-4 text-emerald-200 flex items-start gap-3 shadow-lg"> |
| 158 | <svg class="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 159 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 160 | </svg> |
| 161 | <div class="text-sm"> |
| 162 | <span class="font-semibold text-white">Úspěch:</span> {{ session('success') }} |
| 163 | </div> |
| 164 | </div> |
| 165 | </div> |
| 166 | @endif |
| 167 | |
| 168 | @if(session('error')) |
| 169 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4"> |
| 170 | <div class="rounded-xl bg-rose-950/60 border border-rose-800/80 p-4 text-rose-200 flex items-start gap-3 shadow-lg"> |
| 171 | <svg class="w-5 h-5 text-rose-400 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 172 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 173 | </svg> |
| 174 | <div class="text-sm"> |
| 175 | <span class="font-semibold text-white">Chyba:</span> {{ session('error') }} |
| 176 | </div> |
| 177 | </div> |
| 178 | </div> |
| 179 | @endif |
| 180 | |
| 181 | {{-- Hlavní obsah --}} |
| 182 | <main class="flex-1"> |
| 183 | {{ $slot ?? '' }} |
| 184 | @yield('content') |
| 185 | </main> |
| 186 | |
| 187 | {{-- Patička --}} |
| 188 | <footer class="mt-20 border-t border-slate-800/80 bg-slate-950 text-slate-400 text-xs"> |
| 189 | <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10"> |
| 190 | <div class="grid grid-cols-1 md:grid-cols-3 gap-8 pb-8 border-b border-slate-900"> |
| 191 | {{-- O platformě --}} |
| 192 | <div class="space-y-3"> |
| 193 | <div class="flex items-center gap-2 text-white font-semibold"> |
| 194 | <span class="text-amber-400 font-mono"></></span> |
| 195 | <span>Anatomie Laravelu 12 & PHP 8.4</span> |
| 196 | </div> |
| 197 | <p class="text-slate-400 leading-relaxed text-xs"> |
| 198 | Sebereferenční výuková platforma. Aplikace sama slouží jako exponát – kód, architektura i konfigurace, které studujete, pohání tento běžící web. |
| 199 | </p> |
| 200 | </div> |
| 201 | |
| 202 | {{-- Produkční infrastruktura --}} |
| 203 | <div class="space-y-3"> |
| 204 | <h5 class="text-white font-semibold">Infrastruktura & Produkční Stack</h5> |
| 205 | <ul class="space-y-1.5 text-slate-400"> |
| 206 | <li class="flex items-center gap-2"> |
| 207 | <span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span> |
| 208 | <span>FrankenPHP v1 + PHP 8.4 (Worker Mode, multi-stage Docker)</span> |
| 209 | </li> |
| 210 | <li class="flex items-center gap-2"> |
| 211 | <span class="w-1.5 h-1.5 rounded-full bg-indigo-400"></span> |
| 212 | <span>Laravel 12.x + Livewire 3 + Alpine.js + Tailwind CSS</span> |
| 213 | </li> |
| 214 | <li class="flex items-center gap-2"> |
| 215 | <span class="w-1.5 h-1.5 rounded-full bg-sky-400"></span> |
| 216 | <span>PostgreSQL 18 na Hetzner serveru (<code class="text-slate-300 font-mono">debian-hell</code> / 65.109.132.79)</span> |
| 217 | </li> |
| 218 | <li class="flex items-center gap-2"> |
| 219 | <span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span> |
| 220 | <span>Reverzní proxy Caddy v2 s automatickým TLS Let's Encrypt</span> |
| 221 | </li> |
| 222 | </ul> |
| 223 | </div> |
| 224 | |
| 225 | {{-- Rozcestník & Odkazy --}} |
| 226 | <div class="space-y-3"> |
| 227 | <h5 class="text-white font-semibold">Rychlé odkazy</h5> |
| 228 | <ul class="space-y-1.5 text-slate-400"> |
| 229 | <li> |
| 230 | <a href="{{ route('course.index') }}" class="hover:text-amber-300 transition">📚 Kompletní osnova (10 modulů, 30 lekcí)</a> |
| 231 | </li> |
| 232 | <li> |
| 233 | <a href="{{ route('anatomy.index') }}" class="hover:text-amber-300 transition">🔬 Živý Průzkumník anatomie</a> |
| 234 | </li> |
| 235 | <li> |
| 236 | <a href="https://heatsharing.cz" target="_blank" rel="noopener noreferrer" class="hover:text-amber-300 transition flex items-center gap-1"> |
| 237 | <span>🌐 Rozcestník projektů heatsharing.cz</span> |
| 238 | <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> |
| 239 | <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> |
| 240 | </svg> |
| 241 | </a> |
| 242 | </li> |
| 243 | <li> |
| 244 | <a href="{{ route('api.status') }}" target="_blank" class="hover:text-amber-300 transition flex items-center gap-1 font-mono text-[11px] text-slate-500"> |
| 245 | <span>⚡ Status API: /api/status</span> |
| 246 | </a> |
| 247 | </li> |
| 248 | </ul> |
| 249 | </div> |
| 250 | </div> |
| 251 | |
| 252 | <div class="pt-6 flex flex-col sm:flex-row items-center justify-between gap-4 text-[11px] text-slate-500"> |
| 253 | <p>© {{ date('Y') }} Heatsharing.cz · Anatomie Laravelu 12. Volně přístupné vzdělávací dílo.</p> |
| 254 | <p class="font-mono text-slate-500">PHP 8.4.x · Laravel 12.x · Zero Hallucinations Policy</p> |
| 255 | </div> |
| 256 | </div> |
| 257 | </footer> |
| 258 | |
| 259 | @livewireScripts |
| 260 | </body> |
| 261 | </html> |
| 262 | |