Fáze požadavku: 9. Blade & Livewire prezentace
Modul 5 · Lekce 2

Stavba Průzkumníku anatomie: Dynamické načítání souborů v Livewire

💡 Shrnutí lekce

Detailní pohled na implementaci komponenty AnatomyExplorer. Zjistěte, jak v reálném čase filtrovat soubory pomocí wire:model.live s debouncingem a jak bezpečně předávat data ze služeb do šablony.

Stavba reálné komponenty: AnatomyExplorer v naší aplikaci

Jedním z nejvýraznějších prvků platformy agy-laravel je živý interaktivní prohlížeč kódu na stránce /anatomie. Umožňuje studentům prohledávat desítky souborů projektu, filtrovat je podle kategorií (foundation, core, views, database, deployment) a v reálném čase si prohlížet jejich syntaxi a řádkování.

Tato funkce je celá postavena na jediné komponentě: app/Livewire/AnatomyExplorer.php.

1. Reaktivní obousměrný binding: wire:model.live

V tradičním Livewire 2 se hodnota vstupního pole odesílala na server až při ztrátě focusu (událost change nebo blur). V Livewire 3 máme direktivu wire:model.live, která synchronizuje hodnotu na server okamžitě při psaní (událost input).

Pokud by se však HTTP požadavek odesílal po každém stisknutém znaku, při rychlém psaní by došlo k zahlcení sítě a serveru. Proto v šabloně resources/views/livewire/anatomy-explorer.blade.php využíváme modifikátor debounce:

<input type="text"
       wire:model.live.debounce.250ms="search"
       placeholder="Hledat v souborech...">

Modifikátor .debounce.250ms odloží odeslání požadavku na server o 250 milisekund od okamžiku, kdy uživatel naposledy stiskl klávesu. Pokud uživatel píše plynule, odešle se pouze jediný požadavek na konci psaní.

2. Prevence „Dehydration Overhead“: Co ukládat do stavu a co ne

Častou chybou začátečníků v Livewire je ukládání velkých kolekcí nebo celých modelů do veřejných vlastností:

// ❌ ŠPATNĚ: Celý katalog a obsah aktivního souboru by se serializoval do JSON snapshotu!
public array $allCatalog;
public AnatomyFileData $activeFile;

Pokud byste to udělali, klientský JSON snapshot by měl desítky až stovky kilobajtů a při každém kliknutí by se přenášel tam a zpět.

Správné řešení použité v naší třídě AnatomyExplorer:

  1. Do stavu komponenty ukládáme pouze minimální primitivní identifikátory:
    public string $selectedPath = 'bootstrap/app.php';
    public string $categoryFilter = 'all';
    public string $search = '';
    
  2. Objemná data (filtrovaný seznam souborů a obsah aktivního souboru) načítáme až v metodě render(AnatomyInspectorService $inspectorService) a předáváme je jako parametry přímo do Blade view.
  3. Tím pádem data existují pouze v paměti během generování HTML a klientský snapshot zůstává mikroskopický (pouhých pár bajtů pro tři řetězce).

3. Magické akce $set a okamžitý reset filtrů

V šabloně si všimněte křížku pro vymazání hledaného textu:

<button type="button" wire:click="$set('search', '')">✕</button>

Namísto vytváření zbytečné metody public function clearSearch(): void { $this->search = ''; } v PHP třídě využíváme vestavěnou magickou akci Livewire $set('nazevVlastnosti', hodnota). Ta šetří kód a udržuje PHP třídu čistou a soustředěnou na byznys logiku.

💻 Klíčová konstrukce z praxe
// app/Livewire/AnatomyExplorer.php

class AnatomyExplorer extends Component
{
    // Do stavu ukládáme výhradně primitivní skaláry (minimální payload snapshotu)
    public string $selectedPath = 'bootstrap/app.php';
    public string $categoryFilter = 'all';
    public string $search = '';

    public function selectFile(string $path): void
    {
        $this->selectedPath = $path;
    }

    public function setCategory(string $category): void
    {
        $this->categoryFilter = $category;
    }

    // Těžká data (obsah souborů, filtrování) generujeme až v render()
    public function render(AnatomyInspectorService $inspectorService): View
    {
        $allCatalog = $inspectorService->getCuratedProjectCatalog();

        $filteredCatalog = array_filter($allCatalog, function (AnatomyFileNodeData $item): bool {
            if ($this->categoryFilter !== 'all' && $item->category !== $this->categoryFilter) {
                return false;
            }

            if (trim($this->search) !== '') {
                $term = mb_strtolower(trim($this->search));
                return str_contains(mb_strtolower($item->path), $term)
                    || str_contains(mb_strtolower($item->description), $term);
            }

            return true;
        });

        $activeFile = $inspectorService->inspectFile($this->selectedPath);

        return view('livewire.anatomy-explorer', [
            'catalog' => array_values($filteredCatalog),
            'allCatalog' => $allCatalog,
            'activeFile' => $activeFile,
        ]);
    }
}

🎯 Ověření znalostí z lekce

Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.

Otázka 1 z 3

Proč v AnatomyExplorer nepředáváme $activeFile jako veřejnou (public) vlastnost komponenty, ale vracíme ji z metody render()?

Otázka 2 z 3

Jaký je přesný účinek modifikátoru .debounce.250ms v direktivě wire:model.live.debounce.250ms="search"?

Otázka 3 z 3

Jak funguje magická akce wire:click="$set('search', '')" v Blade šabloně?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: V app/Livewire/AnatomyExplorer.php prozkoumejte metodu render() na řádcích 37–66. Všimněte si, jak je závislost AnatomyInspectorService injektována přímo do renderu. V resources/views/livewire/anatomy-explorer.blade.php se zaměřte na řádek 19 (wire:model.live.debounce.250ms) a řádky 78–82 (volání selectFile).
cesta: app/Livewire/AnatomyExplorer.php
67 řádků · 1.9 KB
Interaktivní průzkumník anatomických souborů s filtrováním fází a živým čtením přes AnatomyInspectorService.
1 <?php
2
3 declare(strict_types=1);
4
5 namespace App\Livewire;
6
7 use App\Data\AnatomyFileNodeData;
8 use App\Services\AnatomyInspectorService;
9 use Illuminate\Contracts\View\View;
10 use Livewire\Component;
11
12 class AnatomyExplorer extends Component
13 {
14 public string $selectedPath = 'bootstrap/app.php';
15
16 public string $categoryFilter = 'all';
17
18 public string $search = '';
19
20 public function mount(?string $initialPath = null): void
21 {
22 if ($initialPath !== null && trim($initialPath) !== '') {
23 $this->selectedPath = trim($initialPath);
24 }
25 }
26
27 public function selectFile(string $path): void
28 {
29 $this->selectedPath = $path;
30 }
31
32 public function setCategory(string $category): void
33 {
34 $this->categoryFilter = $category;
35 }
36
37 public function render(AnatomyInspectorService $inspectorService): View
38 {
39 /** @var list<AnatomyFileNodeData> $allCatalog */
40 $allCatalog = $inspectorService->getCuratedProjectCatalog();
41
42 $filteredCatalog = array_filter($allCatalog, function (AnatomyFileNodeData $item): bool {
43 if ($this->categoryFilter !== 'all' && $item->category !== $this->categoryFilter) {
44 return false;
45 }
46
47 if (trim($this->search) !== '') {
48 $term = mb_strtolower(trim($this->search));
49 $pathMatch = str_contains(mb_strtolower($item->path), $term);
50 $descMatch = str_contains(mb_strtolower($item->description), $term);
51 $catMatch = str_contains(mb_strtolower($item->category), $term);
52
53 return $pathMatch || $descMatch || $catMatch;
54 }
55
56 return true;
57 });
58
59 $activeFile = $inspectorService->inspectFile($this->selectedPath);
60
61 return view('livewire.anatomy-explorer', [
62 'catalog' => array_values($filteredCatalog),
63 'allCatalog' => $allCatalog,
64 'activeFile' => $activeFile,
65 ]);
66 }
67 }
68
cesta: resources/views/livewire/anatomy-explorer.blade.php
185 řádků · 10.6 KB
Soubor aplikační architektury v modulu Blade View.
1 @php
2 $categories = array_values(array_unique(array_filter(array_map(fn($item) => $item->category, $allCatalog))));
3 sort($categories);
4 @endphp
5
6 <div class="space-y-4">
7
8 {{-- Filtrační lišta: Hledání a výběr kategorií --}}
9 <div class="rounded-xl bg-slate-900 border border-slate-800 p-4 space-y-4 shadow-lg">
10 <div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
11 {{-- Vyhledávací pole --}}
12 <div class="relative flex-1">
13 <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-500">
14 <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
15 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
16 </svg>
17 </div>
18 <input type="text"
19 wire:model.live.debounce.250ms="search"
20 placeholder="Hledat v souborech (např. bootstrap, seeder, controller, routes)..."
21 class="w-full rounded-lg bg-slate-950 border border-slate-800 pl-9 pr-8 py-2 text-xs text-white placeholder:text-slate-600 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500">
22 @if($search !== '')
23 <button type="button"
24 wire:click="$set('search', '')"
25 class="absolute inset-y-0 right-0 flex items-center pr-3 text-slate-500 hover:text-white text-xs">
26 ✕
27 </button>
28 @endif
29 </div>
30
31 {{-- Počet nalezených souborů --}}
32 <div class="text-xs font-mono text-slate-400 shrink-0">
33 Zobrazeno <span class="text-white font-bold">{{ count($catalog) }}</span> z {{ count($allCatalog) }} souborů
34 </div>
35 </div>
36
37 {{-- Kategorie souborů --}}
38 <div class="flex flex-wrap items-center gap-1.5 pt-2 border-t border-slate-800/80">
39 <span class="text-[11px] text-slate-400 font-mono mr-1">Kategorie:</span>
40 <button type="button"
41 wire:click="setCategory('all')"
42 class="px-2.5 py-1 rounded-lg text-xs font-medium transition {{ $categoryFilter === 'all' ? 'bg-indigo-600 text-white font-semibold shadow-sm' : 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800' }}">
43 Všechny ({{ count($allCatalog) }})
44 </button>
45 @foreach($categories as $cat)
46 @php
47 $countInCat = count(array_filter($allCatalog, fn($item) => $item->category === $cat));
48 @endphp
49 <button type="button"
50 wire:click="setCategory('{{ $cat }}')"
51 class="px-2.5 py-1 rounded-lg text-xs font-medium transition {{ $categoryFilter === $cat ? 'bg-indigo-600 text-white font-semibold shadow-sm' : 'bg-slate-950 text-slate-400 hover:text-white border border-slate-800' }}">
52 {{ ucfirst($cat) }} ({{ $countInCat }})
53 </button>
54 @endforeach
55 </div>
56 </div>
57
58 {{-- IDE Rozhraní: Levý panel se seznamem souborů a Pravý prohlížeč kódu --}}
59 <div class="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
60
61 {{-- Levý panel: Seznam reálných souborů projektu --}}
62 <div class="lg:col-span-4 rounded-xl bg-slate-900 border border-slate-800 overflow-hidden shadow-xl">
63 <div class="p-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between text-xs">
64 <span class="font-semibold text-white flex items-center gap-2">
65 <span>📁</span>
66 <span>Projektový katalog</span>
67 </span>
68 <span class="text-[10px] font-mono text-slate-400">
69 {{ count($catalog) }} položek
70 </span>
71 </div>
72
73 <div class="max-h-[620px] overflow-y-auto divide-y divide-slate-800/60 p-1">
74 @forelse($catalog as $item)
75 @php
76 $isActive = $selectedPath === $item->path;
77 @endphp
78 <button type="button"
79 wire:click="selectFile('{{ $item->path }}')"
80 class="w-full text-left p-2.5 rounded-lg transition text-xs flex flex-col gap-1 {{ $isActive ? 'bg-indigo-950/80 border border-indigo-700/60 shadow-sm' : 'hover:bg-slate-950/60 border border-transparent' }}">
81 <div class="flex items-center justify-between gap-2">
82 <span class="font-mono text-xs font-medium truncate {{ $isActive ? 'text-amber-300 font-bold' : 'text-slate-200' }}">
83 {{ $item->path }}
84 </span>
85 <span class="shrink-0 text-[10px] font-mono px-1.5 py-0.5 rounded {{ $isActive ? 'bg-indigo-500/20 text-indigo-300' : 'bg-slate-800 text-slate-400' }}">
86 {{ $item->lineCount }} ř.
87 </span>
88 </div>
89 <div class="flex items-center justify-between text-[11px] text-slate-400">
90 <span class="truncate pr-2">{{ $item->description }}</span>
91 <span class="shrink-0 text-[10px] font-mono text-slate-500 uppercase">{{ $item->category }}</span>
92 </div>
93 </button>
94 @empty
95 <div class="p-8 text-center text-xs text-slate-400 space-y-2">
96 <p>Žádný soubor neodpovídá zadanému filtru.</p>
97 <button type="button"
98 wire:click="$set('search', ''); $set('categoryFilter', 'all')"
99 class="text-indigo-400 hover:text-indigo-300 font-medium">
100 Zrušit filtry
101 </button>
102 </div>
103 @endforelse
104 </div>
105 </div>
106
107 {{-- Pravý panel: Prohlížeč zdrojového kódu (IDE styl) --}}
108 <div class="lg:col-span-8 rounded-xl bg-slate-950 border border-slate-800 overflow-hidden shadow-2xl"
109 x-data="{ copied: false }">
110
111 {{-- Horní IDE lišta okna --}}
112 <div class="px-4 py-3 bg-slate-900 border-b border-slate-800 flex flex-wrap items-center justify-between gap-3 text-xs">
113 {{-- Okenní tlačítka a cesta --}}
114 <div class="flex items-center gap-3 min-w-0">
115 <div class="flex items-center gap-1.5 shrink-0">
116 <span class="w-3 h-3 rounded-full bg-rose-500/80 inline-block"></span>
117 <span class="w-3 h-3 rounded-full bg-amber-500/80 inline-block"></span>
118 <span class="w-3 h-3 rounded-full bg-emerald-500/80 inline-block"></span>
119 </div>
120 <div class="font-mono text-white font-bold truncate">
121 {{ $activeFile->path }}
122 </div>
123 </div>
124
125 {{-- Metadata a kopírování --}}
126 <div class="flex items-center gap-2 shrink-0">
127 <span class="px-2 py-0.5 rounded text-[10px] font-mono uppercase bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">
128 {{ $activeFile->category }}
129 </span>
130 <span class="text-[11px] font-mono text-slate-400">
131 {{ $activeFile->lineCount }} řádků ({{ number_format($activeFile->sizeBytes / 1024, 1) }} KB)
132 </span>
133 <button type="button"
134 @click="navigator.clipboard.writeText($refs.activeCode.innerText); copied = true; setTimeout(() => copied = false, 2000)"
135 class="px-2.5 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono transition ml-2">
136 <span x-show="!copied">Kopírovat</span>
137 <span x-show="copied" class="text-emerald-400" style="display: none;">✓ Zkopírováno</span>
138 </button>
139 </div>
140 </div>
141
142 {{-- Popis souboru --}}
143 @if(!empty($activeFile->description))
144 <div class="px-4 py-2.5 bg-slate-900/40 border-b border-slate-800/60 text-xs text-slate-300 flex items-center gap-2">
145 <span class="text-indigo-400 font-bold">ℹ</span>
146 <span>{{ $activeFile->description }}</span>
147 </div>
148 @endif
149
150 {{-- Zdrojový kód s číslováním řádků --}}
151 @if($activeFile->exists)
152 @php
153 $fileLines = explode("\n", $activeFile->content);
154 @endphp
155 <div class="max-h-[580px] overflow-y-auto overflow-x-auto text-xs font-mono text-slate-200 p-3 bg-slate-950 selection:bg-indigo-600 select-text">
156 <table class="w-full border-collapse">
157 <tbody x-ref="activeCode">
158 @foreach($fileLines as $lineIdx => $lineStr)
159 <tr class="hover:bg-slate-900/60 transition">
160 <td class="text-right pr-4 text-slate-600 select-none text-[11px] align-top w-12 py-0.5">
161 {{ $lineIdx + 1 }}
162 </td>
163 <td class="whitespace-pre font-mono text-slate-200 py-0.5"><code>{{ $lineStr }}</code></td>
164 </tr>
165 @endforeach
166 </tbody>
167 </table>
168 </div>
169 @else
170 <div class="p-12 text-center text-xs text-rose-400 font-mono space-y-2">
171 <p class="font-bold">⚠️ Soubor "{{ $activeFile->path }}" nebyl na disku nalezen.</p>
172 <p class="text-slate-400">Možná se nachází mimo povolený whitelist nebo ještě nebyl vygenerován.</p>
173 </div>
174 @endif
175
176 {{-- Spodní lišta okna --}}
177 <div class="px-4 py-2 bg-slate-900/80 border-t border-slate-800 flex items-center justify-between text-[11px] text-slate-500 font-mono">
178 <span>UTF-8 · PHP 8.4 Strict · dunglas/frankenphp:1-php8.4</span>
179 <span>Self-Referential Anatomy Inspector</span>
180 </div>
181 </div>
182
183 </div>
184
185 </div>
186
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →