Kdy použít Livewire (server) a kdy Alpine.js (okamžitá odezva v prohlížeči)
Livewire a Alpine.js tvoří perfektní tandem. Pochopte hranici mezi operacemi vyžadujícími server (validace, databáze, autorizace) a čistě klientskými mikro-interakcemi (kopírování do schránky, přepínání tabů, animace).
TALL Stack: Dokonalá symbióza Livewire a Alpine.js
V moderním ekosystému Laravelu tvoří kombinace Tailwind CSS + Alpine.js + Laravel + Livewire (tzv. TALL stack) jeden z nejproduktivnějších vývojářských nástrojů současnosti.
Častou chybou méně zkušených vývojářů je však snaha řešit veškerou interaktivitu na serveru přes Livewire. Odesílat síťový požadavek na server kvůli rozbalení dropdown menu nebo přepnutí záložky je architektonický antipattern, který vede ke zbytečné latenci a zatížení serveru.
Pojďme se podívat na to, jak je tato dělící linie navržena v naší aplikaci.
1. Klientská mikro-interaktivita: Alpine.js (0 ms latence)
Alpine.js je lehký JavaScriptový framework (cca 15 kB), který vkládá reaktivitu přímo do HTML atributů podobně jako Vue, ale bez nutnosti build stepu nebo virtuálního DOMu.
Podívejte se do souboru resources/views/course/lesson.blade.php:
Příklad A: Kopírování úryvku kódu do schránky
<div class="rounded-xl bg-slate-950" x-data="{ copied: false }">
<button type="button"
@click="navigator.clipboard.writeText($refs.codeSnippet.innerText); copied = true; setTimeout(() => copied = false, 2000)">
<span x-show="!copied">Kopírovat kód</span>
<span x-show="copied" class="text-emerald-400">✓ Zkopírováno!</span>
</button>
<pre><code x-ref="codeSnippet">{{ $lesson->code_snippet }}</code></pre>
</div>
- Pomocí
x-data="{ copied: false }"deklarujeme lokální reaktivní stav komponenty. - Direktiva
@click(zkratka prox-on:click) zavolá nativní Web Clipboard API prohlížeče. - Stav se změní okamžitě na klientovi – uživatel vidí potvrzení „✓ Zkopírováno!“ s nulovou latencí a server o tom vůbec nemusí vědět.
Příklad B: Přepínání záložek anatomie
Pravý panel anatomie zobrazuje několik souborů. Všechny soubory jsou vyrenderovány do DOMu a přepínání mezi nimi řídí Alpine:
<div x-data="{ activeTab: 0 }">
<button @click="activeTab = 0">bootstrap/app.php</button>
<button @click="activeTab = 1">routes/web.php</button>
<div x-show="activeTab === 0">...kód 1...</div>
<div x-show="activeTab === 1" style="display: none;">...kód 2...</div>
</div>
Přepnutí záložky proběhne za 0 milisekund bez ohledu na rychlost internetového připojení.
2. Kdy naopak MUSÍ nastoupit Livewire (Server)
Serverovou komponentu Livewire použijeme vždy, když:
- Ověřujeme bezpečnost a byznys pravidla: Při vyhodnocení kvízu student nesmí mít v JavaScriptu k dispozici pole se správnými odpověďmi (
correct_option), jinak by mohl výsledek snadno podvrhnout v DevTools. - Přistupujeme k databázi nebo storage: Zápis dokončené lekce a skóre do PostgreSQL (
student_profiles). - Provádíme složité serverové operace: Čtení a filtrování souborů z disku (
AnatomyInspectorService), volání externích API nebo odesílání e-mailů.
3. Zlaté pravidlo architektury
| Úloha | Správný nástroj | Důvod |
|---|---|---|
| Otevření / zavření modálního okna nebo menu | Alpine.js | Čistě vizuální stav klienta (0 ms latence) |
| Kopírování do schránky, tooltipy, akordeony | Alpine.js | Využívá Browser API, server není potřeba |
| Odeslání formuláře s validací do databáze | Livewire | Zabezpečení, práce s PostgreSQL |
| Vyhodnocení kvízu a uložení postupu | Livewire | Utajení správných odpovědí, autorizace |
| Dynamické vyhledávání v databázi | Livewire | Serverové SQL dotazy s ochranou před SQLi |
{{-- resources/views/course/lesson.blade.php --}}
{{-- 1. Klient: Okamžitá odezva přes Alpine.js (žádný síťový požadavek) --}}
<div class="rounded-xl bg-slate-950 border border-slate-800" x-data="{ copied: false }">
<div class="flex items-center justify-between px-4 py-2.5 bg-slate-900 border-b border-slate-800">
<span class="font-mono text-slate-300 font-semibold">💻 Klíčová konstrukce z praxe</span>
<button type="button"
@click="navigator.clipboard.writeText($refs.codeSnippet.innerText); copied = true; setTimeout(() => copied = false, 2000)"
class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-[11px] font-mono 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"><code x-ref="codeSnippet">{{ $lesson->code_snippet }}</code></pre>
</div>
{{-- 2. Server: Bezpečná logika a uložení postupu do databáze přes Livewire --}}
<div class="pt-4">
<livewire:lesson-quiz :lesson="$lesson" />
</div>
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Proč je přepínání záložek anatomie v pravém sloupci lesson.blade.php implementováno pomocí Alpine.js (x-data="{ activeTab: 0 }") namísto Livewire?
Co dělá direktiva @click v kontextu šablony využívající Alpine.js?
Která z následujících operací by MĚLA být zpracována na serveru přes Livewire namísto čistého Alpine.js?
| 1 | <div class="rounded-2xl bg-slate-900 border border-slate-800 p-6 space-y-6 shadow-xl"> |
| 2 | |
| 3 | {{-- Horní stavová lišta dokončení lekce a skóre --}} |
| 4 | <div class="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-800"> |
| 5 | <div> |
| 6 | <h3 class="text-base font-bold text-white flex items-center gap-2"> |
| 7 | <span>🎯</span> |
| 8 | <span>Ověření znalostí z lekce</span> |
| 9 | </h3> |
| 10 | <p class="text-xs text-slate-400 mt-0.5"> |
| 11 | Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci. |
| 12 | </p> |
| 13 | </div> |
| 14 | |
| 15 | <div class="flex items-center gap-3"> |
| 16 | @if($bestScore !== null) |
| 17 | <div class="px-3 py-1 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs font-mono text-amber-300"> |
| 18 | Nejlepší skóre: <strong class="font-bold">{{ $bestScore }}%</strong> |
| 19 | </div> |
| 20 | @endif |
| 21 | |
| 22 | {{-- Tlačítko pro ruční označení / odznačení lekce --}} |
| 23 | <button type="button" |
| 24 | wire:click="toggleComplete" |
| 25 | wire:loading.attr="disabled" |
| 26 | class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition focus:outline-none focus:ring-2 focus:ring-indigo-500 {{ $isCompleted ? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-sm' : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700' }}"> |
| 27 | <span wire:loading.remove wire:target="toggleComplete"> |
| 28 | @if($isCompleted) |
| 29 | <span>✓ Lekce dokončena</span> |
| 30 | @else |
| 31 | <span>Označit jako dokončené</span> |
| 32 | @endif |
| 33 | </span> |
| 34 | <span wire:loading wire:target="toggleComplete" class="text-xs"> |
| 35 | Ukládám... |
| 36 | </span> |
| 37 | </button> |
| 38 | </div> |
| 39 | </div> |
| 40 | |
| 41 | {{-- Kvízové otázky --}} |
| 42 | @if($lesson->quizQuestions->isEmpty()) |
| 43 | <div class="p-6 text-center rounded-xl bg-slate-950 border border-slate-800 space-y-2"> |
| 44 | <p class="text-xs text-slate-400"> |
| 45 | Tato lekce neobsahuje samostatný kvíz. Můžete ji rovnou označit jako dokončenou tlačítkem výše. |
| 46 | </p> |
| 47 | </div> |
| 48 | @else |
| 49 | <div class="space-y-6"> |
| 50 | @foreach($lesson->quizQuestions as $qIndex => $question) |
| 51 | @php |
| 52 | $userAnswer = $selectedAnswers[$question->id] ?? null; |
| 53 | $isCorrect = $submitted && $userAnswer !== null && (int) $userAnswer === (int) $question->correct_option; |
| 54 | $isIncorrect = $submitted && $userAnswer !== null && (int) $userAnswer !== (int) $question->correct_option; |
| 55 | @endphp |
| 56 | <div class="rounded-xl bg-slate-950 border {{ $submitted ? ($isCorrect ? 'border-emerald-800/80 bg-emerald-950/20' : ($isIncorrect ? 'border-rose-800/80 bg-rose-950/20' : 'border-slate-800')) : 'border-slate-800' }} p-5 space-y-4"> |
| 57 | {{-- Text otázky --}} |
| 58 | <div class="flex items-start justify-between gap-4"> |
| 59 | <div class="space-y-1"> |
| 60 | <span class="text-[11px] font-mono font-bold text-indigo-400 uppercase tracking-wider"> |
| 61 | Otázka {{ $qIndex + 1 }} z {{ $lesson->quizQuestions->count() }} |
| 62 | </span> |
| 63 | <h4 class="text-sm font-semibold text-white"> |
| 64 | {{ $question->question }} |
| 65 | </h4> |
| 66 | </div> |
| 67 | |
| 68 | @if($submitted) |
| 69 | @if($isCorrect) |
| 70 | <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30"> |
| 71 | ✓ Správně |
| 72 | </span> |
| 73 | @elseif($isIncorrect) |
| 74 | <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30"> |
| 75 | ✗ Chybně |
| 76 | </span> |
| 77 | @else |
| 78 | <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400"> |
| 79 | Nezodpovězeno |
| 80 | </span> |
| 81 | @endif |
| 82 | @endif |
| 83 | </div> |
| 84 | |
| 85 | {{-- Možnosti odpovědí --}} |
| 86 | <div class="space-y-2"> |
| 87 | @foreach($question->options as $optIndex => $optionText) |
| 88 | @php |
| 89 | $isSelected = $userAnswer !== null && (int) $userAnswer === (int) $optIndex; |
| 90 | $isThisCorrect = (int) $optIndex === (int) $question->correct_option; |
| 91 | |
| 92 | if ($submitted) { |
| 93 | if ($isThisCorrect) { |
| 94 | $btnClass = 'bg-emerald-950/60 border-emerald-500 text-emerald-200 ring-1 ring-emerald-500/40 font-medium'; |
| 95 | } elseif ($isSelected && ! $isThisCorrect) { |
| 96 | $btnClass = 'bg-rose-950/60 border-rose-500 text-rose-200 ring-1 ring-rose-500/40'; |
| 97 | } else { |
| 98 | $btnClass = 'bg-slate-900/40 border-slate-800 text-slate-500 opacity-60 cursor-not-allowed'; |
| 99 | } |
| 100 | } else { |
| 101 | if ($isSelected) { |
| 102 | $btnClass = 'bg-indigo-950/80 border-indigo-500 text-white font-medium ring-1 ring-indigo-500/50'; |
| 103 | } else { |
| 104 | $btnClass = 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white'; |
| 105 | } |
| 106 | } |
| 107 | @endphp |
| 108 | |
| 109 | <button type="button" |
| 110 | wire:click="selectOption({{ $question->id }}, {{ $optIndex }})" |
| 111 | @if($submitted) disabled @endif |
| 112 | class="w-full text-left p-3 rounded-lg border text-xs transition flex items-center justify-between gap-3 {{ $btnClass }}"> |
| 113 | <div class="flex items-center gap-3"> |
| 114 | <span class="w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px] shrink-0 {{ $isSelected ? 'bg-indigo-500 text-white font-bold' : 'bg-slate-800 text-slate-400' }}"> |
| 115 | {{ chr(65 + $optIndex) }} |
| 116 | </span> |
| 117 | <span>{{ $optionText }}</span> |
| 118 | </div> |
| 119 | |
| 120 | @if($submitted) |
| 121 | @if($isThisCorrect) |
| 122 | <span class="text-emerald-400 font-bold shrink-0">✓</span> |
| 123 | @elseif($isSelected && ! $isThisCorrect) |
| 124 | <span class="text-rose-400 font-bold shrink-0">✗</span> |
| 125 | @endif |
| 126 | @endif |
| 127 | </button> |
| 128 | @endforeach |
| 129 | </div> |
| 130 | |
| 131 | {{-- Vysvětlení správné odpovědi po odeslání --}} |
| 132 | @if($submitted && !empty($question->explanation)) |
| 133 | <div class="mt-3 p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs text-slate-300 space-y-1"> |
| 134 | <span class="text-[11px] font-semibold text-amber-400 block font-mono"> |
| 135 | 💡 Architektonické vysvětlení: |
| 136 | </span> |
| 137 | <p class="leading-relaxed text-slate-300"> |
| 138 | {{ $question->explanation }} |
| 139 | </p> |
| 140 | </div> |
| 141 | @endif |
| 142 | </div> |
| 143 | @endforeach |
| 144 | </div> |
| 145 | |
| 146 | {{-- Akce kvízu: Vyhodnotit / Výsledky / Zkusit znovu --}} |
| 147 | <div class="pt-4 border-t border-slate-800"> |
| 148 | @if(! $submitted) |
| 149 | <div class="flex flex-wrap items-center justify-between gap-4"> |
| 150 | <span class="text-xs text-slate-400 font-mono"> |
| 151 | Zodpovězeno: {{ count($selectedAnswers) }} / {{ $lesson->quizQuestions->count() }} |
| 152 | </span> |
| 153 | |
| 154 | <button type="button" |
| 155 | wire:click="submitQuiz" |
| 156 | wire:loading.attr="disabled" |
| 157 | @if(count($selectedAnswers) === 0) disabled @endif |
| 158 | class="px-5 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold text-xs transition shadow-sm flex items-center gap-2"> |
| 159 | <span wire:loading.remove wire:target="submitQuiz">Vyhodnotit kvíz</span> |
| 160 | <span wire:loading wire:target="submitQuiz">Vyhodnocuji...</span> |
| 161 | <span>→</span> |
| 162 | </button> |
| 163 | </div> |
| 164 | @else |
| 165 | <div class="rounded-xl bg-slate-950 border border-slate-800 p-5 space-y-4"> |
| 166 | <div class="flex flex-wrap items-center justify-between gap-4"> |
| 167 | <div> |
| 168 | <div class="text-xs text-slate-400">Výsledek kvízu:</div> |
| 169 | <div class="text-2xl font-bold font-mono {{ $scorePercent >= 70 ? 'text-emerald-400' : 'text-amber-400' }}"> |
| 170 | {{ $scorePercent }}% |
| 171 | <span class="text-xs text-slate-400 font-normal ml-2"> |
| 172 | ({{ $correctCount }} ze {{ $lesson->quizQuestions->count() }} správně) |
| 173 | </span> |
| 174 | </div> |
| 175 | </div> |
| 176 | |
| 177 | <button type="button" |
| 178 | wire:click="resetQuiz" |
| 179 | class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 transition"> |
| 180 | 🔄 Zkusit kvíz znovu |
| 181 | </button> |
| 182 | </div> |
| 183 | |
| 184 | <p class="text-xs text-slate-300 leading-relaxed"> |
| 185 | @if($scorePercent === 100) |
| 186 | 🎉 <strong class="text-white">Perfektní práce!</strong> Všechny otázky máte správně. Architekturu v této lekci ovládáte na výbornou. |
| 187 | @elseif($scorePercent >= 70) |
| 188 | 👍 <strong class="text-white">Skvělý výsledek!</strong> Kvíz jste úspěšně zvládli. Pokud chcete dosáhnout 100 %, projděte si vysvětlení výše a zkuste kvíz znovu. |
| 189 | @else |
| 190 | 📖 <strong class="text-amber-300">Doporučujeme zopakovat.</strong> Prohlédněte si zdrojové kódy v pravém panelu anatomie a zkuste kvíz znovu. |
| 191 | @endif |
| 192 | </p> |
| 193 | </div> |
| 194 | @endif |
| 195 | </div> |
| 196 | @endif |
| 197 | |
| 198 | </div> |
| 199 | |