diff --git a/src/App.tsx b/src/App.tsx index f8f8c3b..b258417 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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({ + 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 ( <> @@ -33,11 +51,11 @@ function App() { ) : ( setSearch({ ...search, page })} /> )} diff --git a/src/api/languageCodes.ts b/src/api/languageCodes.ts new file mode 100644 index 0000000..2a4657e --- /dev/null +++ b/src/api/languageCodes.ts @@ -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 }; diff --git a/src/api/openLibrary.ts b/src/api/openLibrary.ts index 5c62b87..830f716 100644 --- a/src/api/openLibrary.ts +++ b/src/api/openLibrary.ts @@ -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. @@ -22,8 +23,20 @@ const SEARCH_FIELDS: (keyof OpenLibraryDoc)[] = [ async function searchBooks( params: BookSearchParams, ): Promise { + // 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), diff --git a/src/api/types.ts b/src/api/types.ts index 01192c2..244a979 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -19,6 +19,7 @@ export interface BookSearchParams { lang?: string; limit?: number; page?: number; + filters?: BookFilters; } export interface Book { @@ -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; diff --git a/src/components/BookGrid/BookGrid.tsx b/src/components/BookGrid/BookGrid.tsx index 81c3b71..f067c71 100644 --- a/src/components/BookGrid/BookGrid.tsx +++ b/src/components/BookGrid/BookGrid.tsx @@ -1,16 +1,16 @@ 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; - searchQuery: string; - onSearchChange: (query: string) => void; + search: BookSearchParams; + onSearchChange: (query: BookSearchParams) => void; page: number; onBookSelect: (book: Book) => void; onPageChange: (page: number) => void; @@ -18,7 +18,7 @@ interface BookGridProps { function BookGrid({ booksQuery, - searchQuery, + search, onSearchChange, page, onBookSelect, @@ -27,7 +27,12 @@ function BookGrid({ const { data, isPending, isError, error } = booksQuery; const searchBar = ( - + onSearchChange({ ...search, query: value })} + onFilterChange={(value) => onSearchChange({ ...search, filters: value })} + /> ); if (isPending) { diff --git a/src/components/FilterBooksSection/FilterBooksSection.module.css b/src/components/FilterBooksSection/FilterBooksSection.module.css new file mode 100644 index 0000000..3fee2f3 --- /dev/null +++ b/src/components/FilterBooksSection/FilterBooksSection.module.css @@ -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; +} diff --git a/src/components/FilterBooksSection/FilterBooksSection.tsx b/src/components/FilterBooksSection/FilterBooksSection.tsx new file mode 100644 index 0000000..0318ba8 --- /dev/null +++ b/src/components/FilterBooksSection/FilterBooksSection.tsx @@ -0,0 +1,111 @@ +import { useState } from "react"; +import styles from "./FilterBooksSection.module.css"; +import type { BookFilters } from "../../api/types"; +import { languageCodes } from "../../api/languageCodes"; +import { CircleQuestionMark } from "../icons/CircleQuestionMark"; + +const FILTER_INPUTS: { + name: keyof BookFilters; + placeholder: string; + ariaLabel: string; +}[] = [ + { + name: "author", + placeholder: "Search book authors...", + ariaLabel: "Search for book author", + }, + { + name: "subject", + placeholder: "Search book subjects...", + ariaLabel: "Search for book subject", + }, + { + name: "place", + placeholder: "Search places... (e.g., New York, London)", + ariaLabel: "Search for place", + }, + { + name: "person", + placeholder: "Search for people...", + ariaLabel: "Search for person", + }, + { + name: "publisher", + placeholder: "Search for publishers...", + ariaLabel: "Search for publisher", + }, +]; + +function FilterBooksSection({ + onFilterChange, +}: { + onFilterChange?: (filter: BookFilters) => void; +}) { + const rawFilters = sessionStorage.getItem("filters"); + const initFilters = JSON.parse(rawFilters ?? "{}") as BookFilters; + + const [filters, setFilters] = useState(initFilters); + + function handleFilterChange(field: keyof BookFilters, value: string) { + const newFilters = { ...filters, [field]: value }; + + if (value === "") { + delete newFilters[field]; + } + + setFilters(newFilters); + sessionStorage.setItem("filters", JSON.stringify(newFilters)); + onFilterChange?.(newFilters); + } + + return ( +
+

Filter Books

+
    + {FILTER_INPUTS.map((input) => ( +
  • + + handleFilterChange(input.name, e.target.value)} + /> +
  • + ))} +
  • + + +
  • +
+
+ ); +} + +export { FilterBooksSection }; diff --git a/src/components/SearchBar/SearchBar.module.css b/src/components/SearchBar/SearchBar.module.css index ea27572..6d06fd0 100644 --- a/src/components/SearchBar/SearchBar.module.css +++ b/src/components/SearchBar/SearchBar.module.css @@ -1,10 +1,23 @@ -.search-bar { - display: block; +.search-bar-container { + display: flex; + justify-content: center; + align-items: center; width: 100%; - max-width: 500px; - padding: 10px 16px; + gap: 1rem; margin: 80px auto 0 auto; + @media only screen and (max-width: 600px) { + width: calc(100% - 80px); /* Adjust width to account for the margins*/ + margin: 40px 40px 0 40px; + max-width: none; + } +} + +.search-bar { + width: 500px; + padding: 0 16px; + height: 48px; + border: 1px solid var(--border-color); border-radius: var(--border-radius); font-size: 1rem; @@ -13,10 +26,35 @@ &:hover { border-color: var(--theme-color); } +} - @media only screen and (max-width: 600px) { - width: calc(100% - 80px); /* Adjust width to account for the margins*/ - margin: 40px 40px 0 40px; - max-width: none; +.filter-button { + position: relative; + padding: 0 16px; + height: 48px; + background-color: white; + color: var(--text-color); + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + font-size: 1rem; + cursor: pointer; + + &:hover { + border-color: var(--theme-color); } } + +.filter-button span { + position: absolute; + display: flex; + justify-content: center; + align-items: center; + bottom: 0; + right: 0; + transform: translate(50%, 50%); + border-radius: 100%; + background-color: var(--theme-color); + color: white; + min-width: 2rem; + min-height: 2rem; +} diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx index 549df0b..b0a1aac 100644 --- a/src/components/SearchBar/SearchBar.tsx +++ b/src/components/SearchBar/SearchBar.tsx @@ -1,21 +1,52 @@ +import { useState } from "react"; import styles from "./SearchBar.module.css"; +import { FilterIcon } from "../icons/FilterIcon"; +import { FilterBooksSection } from "../FilterBooksSection/FilterBooksSection"; +import type { BookFilters } from "../../api/types"; interface SearchBarProps { query: string; + filters?: BookFilters; onQueryChange: (newQuery: string) => void; + onFilterChange: (filter: BookFilters) => void; } -function SearchBar({ query, onQueryChange }: SearchBarProps) { +function SearchBar({ + query, + filters, + onQueryChange, + onFilterChange, +}: SearchBarProps) { + const [filtersOpen, setFiltersOpen] = useState(false); + const filtersCount = filters ? Object.keys(filters).length : 0; + + const filtersDescription = `${filtersCount} ${filtersCount === 1 ? "filter" : "filters"} active`; + return ( - onQueryChange(e.target.value)} - /> + <> +
+ onQueryChange(e.target.value)} + /> + +
+ {filtersOpen && } + ); } diff --git a/src/components/icons/CircleQuestionMark.tsx b/src/components/icons/CircleQuestionMark.tsx new file mode 100644 index 0000000..b8bee2d --- /dev/null +++ b/src/components/icons/CircleQuestionMark.tsx @@ -0,0 +1,24 @@ +// Found as a part of Lucide: +// https://lucide.dev/icons/circle-question-mark + +function CircleQuestionMark() { + return ( + + + + + + ); +} + +export { CircleQuestionMark }; diff --git a/src/components/icons/FilterIcon.tsx b/src/components/icons/FilterIcon.tsx new file mode 100644 index 0000000..5548a64 --- /dev/null +++ b/src/components/icons/FilterIcon.tsx @@ -0,0 +1,22 @@ +// Found as a part of Lucide: +// https://lucide.dev/icons/funnel + +function FilterIcon() { + return ( + + + + ); +} + +export { FilterIcon };