Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 28 additions & 10 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,37 @@ import { BookGrid } from "./components/BookGrid/BookGrid.tsx";
import { BookDetails } from "./components/BookDetails/BookDetails.tsx";
import { useBooks } from "./api/useBooks.ts";
import { useDebounce } from "./components/BookGrid/useDebounce.ts";
import type { Book } from "./api/types.ts";
import type { Book, BookFilters, BookSearchParams } from "./api/types.ts";

function App() {
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
const [page, setPage] = useState(1);

const rawFilters = sessionStorage.getItem("filters");
const initFilters = JSON.parse(rawFilters ?? "{}") as BookFilters;

// Search state and the fetched books live here instead of in BookGrid, so
// they survive switching to BookDetails and back.
const [searchQuery, setSearchQuery] = useState("");
const [search, setSearch] = useState<BookSearchParams>({
query: "",
page: 1,
filters: initFilters,
});

const debouncedSearch = useDebounce(searchQuery, 500);
const query = debouncedSearch.trim() || "subject:fantasy";
const debouncedSearch = useDebounce(search, 500);
// We can perform a search if we have either a string query,
// or at least one filter.
// If we have neither, use the default "subject:fantasy".
const hasFilters = Object.keys(debouncedSearch.filters ?? {}).length > 0;
const query =
hasFilters || debouncedSearch.query.length > 0
? debouncedSearch.query
: "subject:fantasy";

const booksQuery = useBooks({ query, page });
const booksQuery = useBooks({
query,
page: search.page,
filters: debouncedSearch.filters,
});

return (
<>
Expand All @@ -33,11 +51,11 @@ function App() {
) : (
<BookGrid
booksQuery={booksQuery}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
page={page}
search={search}
onSearchChange={setSearch}
page={search.page ?? 1}
onBookSelect={setSelectedBook}
onPageChange={setPage}
onPageChange={(page) => setSearch({ ...search, page })}
/>
)}
</main>
Expand Down
61 changes: 61 additions & 0 deletions src/api/languageCodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Original ISO 639-1 language codes fetched from:
// https://gist.github.com/jrnk/8eb57b065ea0b098d571
// This is a smaller selection of languages and the codes are adjusted
// to the first three letters of the language name, as used by Open Library.
const languageCodes = [
{ code: "afr", name: "Afrikaans" },
{ code: "ara", name: "Arabic" },
{ code: "aze", name: "Azerbaijani" },
{ code: "bel", name: "Belarusian" },
{ code: "bul", name: "Bulgarian" },
{ code: "tib", name: "Tibetan" },
{ code: "bos", name: "Bosnian" },
{ code: "cze", name: "Czech" },
{ code: "wel", name: "Welsh" },
{ code: "dan", name: "Danish" },
{ code: "ger", name: "German" },
{ code: "gre", name: "Greek, Modern (1453-)" },
{ code: "eng", name: "English" },
{ code: "esp", name: "Esperanto" },
{ code: "spa", name: "Spanish; Castilian" },
{ code: "est", name: "Estonian" },
{ code: "per", name: "Persian" },
{ code: "fin", name: "Finnish" },
{ code: "fre", name: "French" },
{ code: "iri", name: "Irish" },
{ code: "hin", name: "Hindi" },
{ code: "cro", name: "Croatian" },
{ code: "hun", name: "Hungarian" },
{ code: "arm", name: "Armenian" },
{ code: "ind", name: "Indonesian" },
{ code: "ice", name: "Icelandic" },
{ code: "ita", name: "Italian" },
{ code: "jpn", name: "Japanese" },
{ code: "geo", name: "Georgian" },
{ code: "kor", name: "Korean" },
{ code: "kur", name: "Kurdish" },
{ code: "lat", name: "Latin" },
{ code: "lit", name: "Lithuanian" },
{ code: "lat", name: "Latvian" },
{ code: "mac", name: "Macedonian" },
{ code: "mal", name: "Malayalam" },
{ code: "mon", name: "Mongolian" },
{ code: "mal", name: "Maltese" },
{ code: "bur", name: "Burmese" },
{ code: "dut", name: "Dutch; Flemish" },
{ code: "nor", name: "Norwegian" },
{ code: "pan", name: "Panjabi; Punjabi" },
{ code: "pol", name: "Polish" },
{ code: "por", name: "Portuguese" },
{ code: "rom", name: "Romanian; Moldavian; Moldovan" },
{ code: "rus", name: "Russian" },
{ code: "alb", name: "Albanian" },
{ code: "ser", name: "Serbian" },
{ code: "swe", name: "Swedish" },
{ code: "tur", name: "Turkish" },
{ code: "ukr", name: "Ukrainian" },
{ code: "vie", name: "Vietnamese" },
{ code: "chi", name: "Chinese" },
] as const;

export { languageCodes };
17 changes: 15 additions & 2 deletions src/api/openLibrary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import type {
OpenLibrarySearchResponse,
BookSearchParams,
Book,
BookFilters,
OpenLibraryWork,
} from "./types";

const SEARCH_LIMIT = 12;
const BASE_URL = "https://openlibrary.org";
const SEARCH_LIMIT = 12;

// Only ask for the fields we use. Typed against OpenLibraryDoc so a misspelled
// field name is caught by TypeScript.
Expand All @@ -22,8 +23,20 @@ const SEARCH_FIELDS: (keyof OpenLibraryDoc)[] = [
async function searchBooks(
params: BookSearchParams,
): Promise<OpenLibrarySearchResponse> {
// Create a string representation of the current filters
let filterString = "";

if (params.filters) {
for (const key in params.filters) {
if (key.length === 0) continue;
filterString += `${key}:${params.filters[key as keyof BookFilters]} `;
}
}

const fullQuery = `${params.query} ${filterString}`.trim();

const url = new URL("/search.json", BASE_URL);
url.searchParams.set("q", params.query);
url.searchParams.set("q", fullQuery);
url.searchParams.set(
"limit",
params.limit ? String(params.limit) : String(SEARCH_LIMIT),
Expand Down
18 changes: 14 additions & 4 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface BookSearchParams {
lang?: string;
limit?: number;
page?: number;
filters?: BookFilters;
}

export interface Book {
Expand All @@ -29,10 +30,19 @@ export interface Book {
coverImage?: string;
}

// claude helped figure out the type of the description field.
// it can either be a string or an object with a type and a value field.
// it can also be missing.
// look at the workResponse.json file for an example.
export interface BookFilters {
author?: string;
subject?: string;
place?: string;
person?: string;
language?: string;
publisher?: string;
}

// Claude helped figure out the type of the description field.
// It can either be a string or an object with a type and a value field.
// It can also be missing.
// Look at the workResponse.json file for an example.
export interface OpenLibraryWork {
key: string;
title: string;
Expand Down
17 changes: 11 additions & 6 deletions src/components/BookGrid/BookGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { BookCard } from "../BookCard/BookCard";
import { SearchBar } from "../SearchBar/SearchBar";
import styles from "./BookGrid.module.css";
import type { useBooks } from "../../api/useBooks";
import type { Book } from "../../api/types";
import { useBooks } from "../../api/useBooks";
import type { Book, BookSearchParams } from "../../api/types";
import { Pagination } from "../Pagination/Pagination";
import { SEARCH_LIMIT } from "../../api/openLibrary";

interface BookGridProps {
// The result of useBooks, owned by App so it outlives this component.
booksQuery: ReturnType<typeof useBooks>;
searchQuery: string;
onSearchChange: (query: string) => void;
search: BookSearchParams;
onSearchChange: (query: BookSearchParams) => void;
page: number;
onBookSelect: (book: Book) => void;
onPageChange: (page: number) => void;
}

function BookGrid({
booksQuery,
searchQuery,
search,
onSearchChange,
page,
onBookSelect,
Expand All @@ -27,7 +27,12 @@ function BookGrid({
const { data, isPending, isError, error } = booksQuery;

const searchBar = (
<SearchBar query={searchQuery} onQueryChange={onSearchChange} />
<SearchBar
query={search.query}
filters={search.filters}
onQueryChange={(value) => onSearchChange({ ...search, query: value })}
onFilterChange={(value) => onSearchChange({ ...search, filters: value })}
/>
);

if (isPending) {
Expand Down
55 changes: 55 additions & 0 deletions src/components/FilterBooksSection/FilterBooksSection.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.filter-books-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
}

.filter-books-section h2 {
margin: 1rem 0;
}

.filter-inputs-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;

@media only screen and (max-width: 900px) {
grid-template-columns: 1fr;
}
}

.filter-input-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.filter-input {
padding: 0.5rem;
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
border-radius: 4px;
font-size: 1rem;

&:hover {
border-color: var(--theme-color);
}
}

.filter-label {
display: flex;
align-items: center;
gap: 0.5rem;
}

.filter-label span {
display: flex;
align-items: center;
}

.filter-label svg {
width: 1.25rem;
height: 1.25rem;
}
Loading