feat: add trmnl catalog paginator

This commit is contained in:
Benjamin Nussbaum 2025-12-30 10:52:54 +01:00
parent 3250bb0402
commit 7f97114f6e
2 changed files with 134 additions and 13 deletions

View file

@ -13,6 +13,10 @@ class extends Component
{ {
public array $recipes = []; public array $recipes = [];
public int $page = 1;
public bool $hasMore = false;
public string $search = ''; public string $search = '';
public bool $isSearching = false; public bool $isSearching = false;
@ -43,23 +47,36 @@ class extends Component
private function loadNewest(): void private function loadNewest(): void
{ {
try { try {
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { $cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
$response = Cache::remember($cacheKey, 43200, function () {
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
'sort-by' => 'newest', 'sort-by' => 'newest',
'page' => $this->page,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new RuntimeException('Failed to fetch TRMNL recipes'); throw new RuntimeException('Failed to fetch TRMNL recipes');
} }
$json = $response->json(); return $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
}); });
$data = $response['data'] ?? [];
$mapped = $this->mapRecipes($data);
if ($this->page === 1) {
$this->recipes = $mapped;
} else {
$this->recipes = array_merge($this->recipes, $mapped);
}
$this->hasMore = ! empty($response['next_page_url']);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('TRMNL catalog load error: '.$e->getMessage()); Log::error('TRMNL catalog load error: '.$e->getMessage());
$this->recipes = []; if ($this->page === 1) {
$this->recipes = [];
}
$this->hasMore = false;
} }
} }
@ -67,32 +84,57 @@ class extends Component
{ {
$this->isSearching = true; $this->isSearching = true;
try { try {
$cacheKey = 'trmnl_recipes_search_'.md5($term); $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { $response = Cache::remember($cacheKey, 300, function () use ($term) {
$response = Http::get('https://usetrmnl.com/recipes.json', [ $response = Http::get('https://usetrmnl.com/recipes.json', [
'search' => $term, 'search' => $term,
'sort-by' => 'newest', 'sort-by' => 'newest',
'page' => $this->page,
]); ]);
if (! $response->successful()) { if (! $response->successful()) {
throw new RuntimeException('Failed to search TRMNL recipes'); throw new RuntimeException('Failed to search TRMNL recipes');
} }
$json = $response->json(); return $response->json();
$data = $json['data'] ?? [];
return $this->mapRecipes($data);
}); });
$data = $response['data'] ?? [];
$mapped = $this->mapRecipes($data);
if ($this->page === 1) {
$this->recipes = $mapped;
} else {
$this->recipes = array_merge($this->recipes, $mapped);
}
$this->hasMore = ! empty($response['next_page_url']);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('TRMNL catalog search error: '.$e->getMessage()); Log::error('TRMNL catalog search error: '.$e->getMessage());
$this->recipes = []; if ($this->page === 1) {
$this->recipes = [];
}
$this->hasMore = false;
} finally { } finally {
$this->isSearching = false; $this->isSearching = false;
} }
} }
public function loadMore(): void
{
$this->page++;
$term = mb_trim($this->search);
if ($term === '' || mb_strlen($term) < 2) {
$this->loadNewest();
} else {
$this->searchRecipes($term);
}
}
public function updatedSearch(): void public function updatedSearch(): void
{ {
$this->page = 1;
$term = mb_trim($this->search); $term = mb_trim($this->search);
if ($term === '') { if ($term === '') {
$this->loadNewest(); $this->loadNewest();
@ -286,6 +328,15 @@ class extends Component
</div> </div>
@endforeach @endforeach
</div> </div>
@if($hasMore)
<div class="flex justify-center mt-6">
<flux:button wire:click="loadMore" variant="subtle" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="loadMore">Load next page</span>
<span wire:loading wire:target="loadMore">Loading...</span>
</flux:button>
</div>
@endif
@endif @endif
<!-- Preview Modal --> <!-- Preview Modal -->

View file

@ -214,3 +214,73 @@ it('previews a recipe with async fetch', function (): void {
->assertSee('Preview Weather Chum Updated') ->assertSee('Preview Weather Chum Updated')
->assertSee('New bio'); ->assertSee('New bio');
}); });
it('supports pagination and loading more recipes', function (): void {
Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
'data' => [
[
'id' => 1,
'name' => 'Recipe Page 1',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 1, 'forks' => 0],
],
],
'next_page_url' => '/recipes.json?page=2',
], 200),
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
'data' => [
[
'id' => 2,
'name' => 'Recipe Page 2',
'icon_url' => null,
'screenshot_url' => null,
'author_bio' => null,
'stats' => ['installs' => 2, 'forks' => 0],
],
],
'next_page_url' => null,
], 200),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Recipe Page 1')
->assertDontSee('Recipe Page 2')
->assertSee('Load next page')
->call('loadMore')
->assertSee('Recipe Page 1')
->assertSee('Recipe Page 2')
->assertDontSee('Load next page');
});
it('resets pagination when search term changes', function (): void {
Http::fake([
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
->push([
'data' => [['id' => 1, 'name' => 'Initial 1']],
'next_page_url' => '/recipes.json?page=2',
])
->push([
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
'next_page_url' => null,
]),
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
'data' => [['id' => 2, 'name' => 'Weather Result']],
'next_page_url' => null,
]),
]);
Livewire::withoutLazyLoading();
Volt::test('catalog.trmnl')
->assertSee('Initial 1')
->call('loadMore')
->set('search', 'weather')
->assertSee('Weather Result')
->assertDontSee('Initial 1')
->assertSet('page', 1);
});