From 0e9a74965b4b5bfca78776b9df11df6aa75c0121 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 25 Oct 2025 20:57:27 +0200 Subject: [PATCH] feat: add TRMNL recipe catalog --- .../views/livewire/catalog/trmnl.blade.php | 194 ++++++++++++++++++ .../views/livewire/plugins/index.blade.php | 3 +- tests/Feature/Volt/CatalogTrmnlTest.php | 66 ++++++ 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 resources/views/livewire/catalog/trmnl.blade.php create mode 100644 tests/Feature/Volt/CatalogTrmnlTest.php diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php new file mode 100644 index 0000000..7d318a6 --- /dev/null +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -0,0 +1,194 @@ +loadNewest(); + } + + private function loadNewest(): void + { + $this->error = ''; + try { + $this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () { + $response = Http::get('https://usetrmnl.com/recipes.json', [ + 'sort-by' => 'newest', + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to fetch TRMNL recipes'); + } + + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); + }); + } catch (\Throwable $e) { + Log::error('TRMNL catalog load error: ' . $e->getMessage()); + $this->recipes = []; + $this->error = 'Failed to load the TRMNL catalog. Please try again later.'; + } + } + + private function searchRecipes(string $term): void + { + $this->error = ''; + $this->isSearching = true; + try { + $cacheKey = 'trmnl_recipes_search_' . md5($term); + $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { + $response = Http::get('https://usetrmnl.com/recipes.json', [ + 'search' => $term, + 'sort-by' => 'newest', + ]); + + if (!$response->successful()) { + throw new \RuntimeException('Failed to search TRMNL recipes'); + } + + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); + }); + } catch (\Throwable $e) { + Log::error('TRMNL catalog search error: ' . $e->getMessage()); + $this->recipes = []; + $this->error = 'Search failed. Please try again later.'; + } finally { + $this->isSearching = false; + } + } + + public function updatedSearch(): void + { + $term = trim($this->search); + if ($term === '') { + $this->loadNewest(); + return; + } + + if (strlen($term) < 2) { + // Require at least 2 chars to avoid noisy calls + return; + } + + $this->searchRecipes($term); + } + + /** + * @param array> $items + * @return array> + */ + private function mapRecipes(array $items): array + { + return collect($items) + ->map(function (array $item) { + return [ + 'id' => $item['id'] ?? null, + 'name' => $item['name'] ?? 'Untitled', + 'icon_url' => $item['icon_url'] ?? null, + 'screenshot_url' => $item['screenshot_url'] ?? null, + 'author_bio' => is_array($item['author_bio'] ?? null) + ? ($item['author_bio']['description'] ?? null) + : null, + 'stats' => [ + 'installs' => data_get($item, 'stats.installs'), + 'forks' => data_get($item, 'stats.forks'), + ], + 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null, + ]; + }) + ->toArray(); + } +}; ?> + +
+
+
+ +
+ Newest +
+ + @if($error) + + @endif + + @if(empty($recipes)) +
+ + No recipes found + Try a different search term +
+ @else +
+ @foreach($recipes as $recipe) +
+
+ @php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) + @if($thumb) + {{ $recipe['name'] }} + @else +
+ +
+ @endif + +
+
+
+

{{ $recipe['name'] }}

+ @if(data_get($recipe, 'stats.installs')) +

Installs: {{ data_get($recipe, 'stats.installs') }} ยท Forks: {{ data_get($recipe, 'stats.forks') }}

+ @endif +
+
+ @if($recipe['detail_url']) + + + + @endif +
+
+ + @if($recipe['author_bio']) +

{{ $recipe['author_bio'] }}

+ @endif + +
+ + + Install + + + + @if($recipe['detail_url']) + + View on TRMNL + + @endif +
+
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 086e402..dfc2c91 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -290,8 +290,7 @@ new class extends Component { Alpha -{{-- IMPLEMENT p--}} -{{-- --}} + diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php new file mode 100644 index 0000000..ed708c7 --- /dev/null +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -0,0 +1,66 @@ + Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->assertSee('Install') + ->assertSee('Installs: 10'); +}); + +it('searches TRMNL recipes when search term is provided', function () { + Http::fake([ + // First call (mount -> newest) + 'usetrmnl.com/recipes.json?*' => Http::sequence() + ->push([ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Initial Recipe', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 1, 'forks' => 0], + ], + ], + ], 200) + // Second call (search) + ->push([ + 'data' => [ + [ + 'id' => 2, + 'name' => 'Weather Search Result', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 3, 'forks' => 1], + ], + ], + ], 200), + ]); + + Volt::test('catalog.trmnl') + ->assertSee('Initial Recipe') + ->set('search', 'weather') + ->assertSee('Weather Search Result') + ->assertSee('Install'); +});