Skip to content

Commit c587a28

Browse files
dotMavriQclaude
andcommitted
feat: add BookOpenLibrarySearch wizard, fix $query property clash
Rename SearchBooks $query to $searchQuery to avoid collision with Saloon\Http\Request::$query on PHP 8.4. Adds search-openlibrary route, component, and view for searching and adding books from OpenLibrary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent eea99bf commit c587a28

6 files changed

Lines changed: 516 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Livewire\Books;
6+
7+
use App\Enums\ReadingStatus;
8+
use App\Models\Book;
9+
use App\Services\OpenLibraryService;
10+
use Illuminate\Support\Facades\Auth;
11+
use Livewire\Component;
12+
13+
class BookOpenLibrarySearch extends Component
14+
{
15+
public string $step = 'search';
16+
17+
// Search state
18+
public string $query = '';
19+
public array $searchResults = [];
20+
public int $totalPages = 0;
21+
public int $currentPage = 1;
22+
23+
// Selected book configuration
24+
public string $title = '';
25+
public string $author = '';
26+
public ?string $isbn = null;
27+
public ?int $page_count = null;
28+
public string $description = '';
29+
public string $cover_url = '';
30+
public ?string $publisher = null;
31+
public ?int $published_year = null;
32+
public string $status = 'want_to_read';
33+
public ?int $rating = null;
34+
35+
// Duplicate detection
36+
public array $existingIsbns = [];
37+
38+
public function mount(): void
39+
{
40+
$userId = Auth::id();
41+
42+
$this->existingIsbns = Book::where('user_id', $userId)
43+
->whereNotNull('isbn')
44+
->pluck('isbn')
45+
->merge(
46+
Book::where('user_id', $userId)
47+
->whereNotNull('isbn13')
48+
->pluck('isbn13')
49+
)
50+
->all();
51+
}
52+
53+
public function search(): void
54+
{
55+
$query = trim($this->query);
56+
if ($query === '') {
57+
return;
58+
}
59+
60+
$service = app(OpenLibraryService::class);
61+
$result = $service->search($query, 1);
62+
63+
$this->searchResults = $result['results'];
64+
$this->totalPages = min($result['total_pages'], 50);
65+
$this->currentPage = 1;
66+
$this->step = 'results';
67+
}
68+
69+
public function loadPage(int $page): void
70+
{
71+
$service = app(OpenLibraryService::class);
72+
$result = $service->search(trim($this->query), $page);
73+
74+
$this->searchResults = $result['results'];
75+
$this->currentPage = $page;
76+
}
77+
78+
public function selectResult(int $index): void
79+
{
80+
$result = $this->searchResults[$index] ?? null;
81+
if (! $result) {
82+
return;
83+
}
84+
85+
$this->title = $result['title'];
86+
$this->author = $result['author'] ?? '';
87+
$this->isbn = $result['isbn'] ?? null;
88+
$this->page_count = $result['page_count'] ?? null;
89+
$this->cover_url = $result['cover_url_large'] ?? $result['cover_url'] ?? '';
90+
$this->publisher = $result['publisher'] ?? null;
91+
$this->published_year = $result['first_publish_year'] ?? null;
92+
$this->description = '';
93+
$this->status = 'want_to_read';
94+
$this->rating = null;
95+
96+
// Try to fetch description via ISBN if available
97+
if ($this->isbn) {
98+
$service = app(OpenLibraryService::class);
99+
$details = $service->fetchByIsbn($this->isbn);
100+
if ($details) {
101+
$this->description = $details['description'] ?? '';
102+
$this->page_count = $this->page_count ?? $details['page_count'];
103+
$this->publisher = $this->publisher ?? $details['publisher'];
104+
}
105+
}
106+
107+
$this->step = 'configure';
108+
}
109+
110+
public function addBook(): void
111+
{
112+
$publishedDate = null;
113+
if ($this->published_year) {
114+
$publishedDate = $this->published_year . '-01-01';
115+
}
116+
117+
$book = Book::create([
118+
'user_id' => Auth::id(),
119+
'title' => $this->title,
120+
'author' => $this->author ?: null,
121+
'isbn' => $this->isbn ?: null,
122+
'cover_url' => $this->cover_url ?: null,
123+
'description' => $this->description ?: null,
124+
'page_count' => $this->page_count,
125+
'publisher' => $this->publisher ?: null,
126+
'published_date' => $publishedDate,
127+
'status' => $this->status,
128+
'rating' => $this->rating,
129+
'date_added' => now(),
130+
]);
131+
132+
session()->flash('message', "Added \"{$this->title}\" to your library.");
133+
$this->redirect(route('books.show', $book));
134+
}
135+
136+
public function backToSearch(): void
137+
{
138+
$this->step = 'search';
139+
$this->searchResults = [];
140+
$this->query = '';
141+
}
142+
143+
public function backToResults(): void
144+
{
145+
$this->step = 'results';
146+
}
147+
148+
public function isResultDuplicate(array $result): bool
149+
{
150+
$isbn = $result['isbn'] ?? null;
151+
return $isbn && in_array($isbn, $this->existingIsbns);
152+
}
153+
154+
public function render()
155+
{
156+
return view('livewire.books.book-openlibrary-search', [
157+
'statuses' => ReadingStatus::cases(),
158+
])->layout('layouts.app');
159+
}
160+
}

app/Services/OpenLibraryService.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Services\Saloon\OpenLibrary\OpenLibraryConnector;
88
use App\Services\Saloon\OpenLibrary\Requests\GetIsbnDetails;
99
use App\Services\Saloon\OpenLibrary\Requests\GetWorkDetails;
10+
use App\Services\Saloon\OpenLibrary\Requests\SearchBooks;
1011
use Carbon\Carbon;
1112

1213
class OpenLibraryService
@@ -18,6 +19,51 @@ public function __construct()
1819
$this->connector = new OpenLibraryConnector();
1920
}
2021

22+
public function search(string $query, int $page = 1): array
23+
{
24+
try {
25+
$response = $this->connector->send(new SearchBooks($query, $page));
26+
27+
if (! $response->successful()) {
28+
return ['results' => [], 'total' => 0, 'total_pages' => 0];
29+
}
30+
31+
$data = $response->json();
32+
$docs = $data['docs'] ?? [];
33+
$numFound = $data['numFound'] ?? 0;
34+
35+
$results = array_map(function (array $doc) {
36+
$coverId = $doc['cover_i'] ?? null;
37+
38+
return [
39+
'key' => $doc['key'] ?? '',
40+
'title' => $doc['title'] ?? 'Unknown Title',
41+
'author' => $doc['author_name'][0] ?? null,
42+
'authors' => $doc['author_name'] ?? [],
43+
'first_publish_year' => $doc['first_publish_year'] ?? null,
44+
'cover_url' => $coverId
45+
? "https://covers.openlibrary.org/b/id/{$coverId}-M.jpg"
46+
: null,
47+
'cover_url_large' => $coverId
48+
? "https://covers.openlibrary.org/b/id/{$coverId}-L.jpg"
49+
: null,
50+
'isbn' => $doc['isbn'][0] ?? null,
51+
'page_count' => $doc['number_of_pages_median'] ?? null,
52+
'publisher' => $doc['publisher'][0] ?? null,
53+
'edition_count' => $doc['edition_count'] ?? 0,
54+
];
55+
}, $docs);
56+
57+
return [
58+
'results' => $results,
59+
'total' => $numFound,
60+
'total_pages' => (int) ceil($numFound / 20),
61+
];
62+
} catch (\Exception) {
63+
return ['results' => [], 'total' => 0, 'total_pages' => 0];
64+
}
65+
}
66+
2167
public function fetchByIsbn(string $isbn): ?array
2268
{
2369
$isbn = preg_replace('/[^0-9X]/i', '', $isbn);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Services\Saloon\OpenLibrary\Requests;
6+
7+
use Saloon\Enums\Method;
8+
use Saloon\Http\Request;
9+
10+
class SearchBooks extends Request
11+
{
12+
protected Method $method = Method::GET;
13+
14+
public function __construct(
15+
protected string $searchQuery,
16+
protected int $page = 1,
17+
protected int $limit = 20,
18+
) {}
19+
20+
public function resolveEndpoint(): string
21+
{
22+
return '/search.json';
23+
}
24+
25+
protected function defaultQuery(): array
26+
{
27+
return [
28+
'q' => $this->searchQuery,
29+
'page' => $this->page,
30+
'limit' => $this->limit,
31+
'fields' => 'key,title,author_name,first_publish_year,cover_i,isbn,number_of_pages_median,publisher,edition_count',
32+
];
33+
}
34+
}

resources/views/livewire/books/book-index.blade.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
</svg>
3333
<span class="hidden sm:inline">Import</span>
3434
</a>
35+
<a href="{{ route('books.search-openlibrary') }}" class="inline-flex items-center gap-1.5 rounded-md btn-secondary px-3 py-2 text-sm font-medium shadow-sm ring-1 ring-inset">
36+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
37+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
38+
</svg>
39+
<span class="hidden sm:inline">Search OpenLibrary</span>
40+
</a>
3541
<a href="{{ route('books.create') }}" class="inline-flex items-center gap-1.5 rounded-md btn-primary px-3 py-2 text-sm font-medium shadow-sm">
3642
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
3743
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />

0 commit comments

Comments
 (0)