Skip to content
14 changes: 13 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
import { useState } from "react";
import { Header } from "./components/Header/Header.tsx";
import { Footer } from "./components/Footer/Footer.tsx";
import { BookGrid } from "./components/BookGrid/BookGrid.tsx";
import { BookDetails } from "./components/BookDetails/BookDetails.tsx";
import type { Book } from "./api/types.ts";

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

return (
<>
<Header />
<main>
<BookGrid />
{selectedBook ? (
<BookDetails
book={selectedBook}
onGoBack={() => setSelectedBook(null)}
/>
) : (
<BookGrid onBookSelect={setSelectedBook} />
)}
</main>
<Footer />
</>
Expand Down
50 changes: 11 additions & 39 deletions src/components/BookCard/BookCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,30 @@
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
background-color: white;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
padding: 24px;
}

.favoriteButton {
position: absolute;
.bookCardButton {
all: unset;
cursor: pointer;

display: flex;
align-items: center;
justify-content: center;
top: 8px;
right: 8px;
background-color: white;
color: var(--theme-color);
border: 1px solid var(--border-color);
flex: 1;
flex-direction: column;
width: 100%;

box-sizing: border-box;
padding: 24px;
border-radius: var(--border-radius);
width: 32px;
height: 32px;
font-size: 1.5rem;
cursor: pointer;

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

.favoriteButtonActive {
border-color: var(--theme-color);
color: green;
border: 1px solid green;
}

.coverImage {
width: 100%;
padding: 12px 24px;
object-fit: contain;
}

.coverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 2 / 3;
margin: 12px 24px;
background-color: var(--background-color);
border-radius: var(--border-radius);
color: var(--theme-color);
font-size: 0.875rem;
text-align: center;
}

.info {
margin-top: 16px;
font-size: 0.9rem;
Expand Down
56 changes: 18 additions & 38 deletions src/components/BookCard/BookCard.tsx
Original file line number Diff line number Diff line change
@@ -1,55 +1,35 @@
import { useState } from "react";
import styles from "./BookCard.module.css";
import { CheckIcon } from "../icons/CheckIcon";
import { BookCover } from "../BookCover/BookCover";
import { FavoriteButton } from "../FavoriteButton/FavoriteButton";
import type { Book } from "../../api/types";

interface BookCardProps {
book: Book;
onBookSelect: (book: Book) => void;
}

function BookCard({ book }: BookCardProps) {
const [favorites, setFavorites] = useState<string[]>(() => {
const rawFavorites = localStorage.getItem("favorites");
return JSON.parse(rawFavorites ?? "[]");
});

const saveFavorite = () => {
const rawFavorites = localStorage.getItem("favorites");
let savedFavorites: string[] = JSON.parse(rawFavorites ?? "[]");

if (savedFavorites.includes(book.id)) {
// Remove from favorites if already present
savedFavorites = savedFavorites.filter((id) => id !== book.id);
} else {
savedFavorites.push(book.id);
}

localStorage.setItem("favorites", JSON.stringify(savedFavorites));
setFavorites(savedFavorites);
function BookCard({ book, onBookSelect }: BookCardProps) {
const selectBook = () => {
onBookSelect(book);
};

return (
<article className={styles.bookCard}>
<FavoriteButton book={book} />

<button
className={`${styles.favoriteButton} ${favorites.includes(book.id) ? styles.favoriteButtonActive : ""}`}
onClick={saveFavorite}
onClick={selectBook}
className={styles.bookCardButton}
aria-label={`View details for ${book.title}`}
>
{favorites.includes(book.id) ? <CheckIcon /> : "+"}
<BookCover book={book} />

<section className={styles.info}>
<h3 className={styles.title}>{book.title}</h3>
<p>{book.author.join(", ")}</p>
<p>{book.publishedYear ?? "Unknown"}</p>
</section>
</button>
{book.coverImage ? (
<img
src={book.coverImage}
alt={`Book cover of ${book.title}`}
className={styles.coverImage}
/>
) : (
<div className={styles.coverPlaceholder}>No cover available</div>
)}
<section className={styles.info}>
<h3 className={styles.title}>{book.title}</h3>
<p className={styles.author}>{book.author.join(", ")}</p>
<p>{book.publishedYear ?? "Unknown"}</p>
</section>
</article>
);
}
Expand Down
18 changes: 18 additions & 0 deletions src/components/BookCover/BookCover.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.coverImage {
width: 100%;
padding: 12px 24px;
object-fit: contain;
}

.coverPlaceholder {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 2 / 3;
margin: 12px 24px;
background-color: var(--background-color);
border-radius: var(--border-radius);
color: var(--theme-color);
font-size: 0.875rem;
text-align: center;
}
20 changes: 20 additions & 0 deletions src/components/BookCover/BookCover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import styles from "./BookCover.module.css";
import type { Book } from "../../api/types";

interface BookCoverProps {
book: Book;
}

function BookCover({ book }: BookCoverProps) {
return book.coverImage ? (
<img
src={book.coverImage}
alt={`Book cover of ${book.title}`}
className={styles.coverImage}
/>
) : (
<p className={styles.coverPlaceholder}>No cover available</p>
);
}

export { BookCover };
72 changes: 72 additions & 0 deletions src/components/BookDetails/BookDetails.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
.bookDetails {
display: flex;
flex-direction: row;
max-width: 1200px;
margin: 0 auto;
margin-top: 60px;
padding: 40px;
gap: 10px;

@media only screen and (max-width: 900px) {
flex-direction: column;
}
}

.goBackButton {
position: absolute;
top: -42px;
left: 0;
background-color: white;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: 8px 16px;
cursor: pointer;
font-size: 0.925rem;

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

.coverSection {
position: relative;
flex: 0 0 40%;
padding: 30px;
background-color: white;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: center;
}

.info {
flex: 1;
padding: 30px;
background-color: white;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}

.title {
margin-bottom: 8px;
font-size: 1.5rem;
}

.description {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-color);
line-height: 1.5;
}

@media only screen and (max-width: 900px) and (min-width: 750px) {
.coverSection img {
margin: 50px;
}
}
@media only screen and (max-width: 1000px) and (min-width: 901px) {
.coverSection {
padding: 15px;
}
}
35 changes: 35 additions & 0 deletions src/components/BookDetails/BookDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import styles from "./BookDetails.module.css";
import { BookCover } from "../BookCover/BookCover";
import { FavoriteButton } from "../FavoriteButton/FavoriteButton";
import type { Book } from "../../api/types";

interface BookDetailsProps {
book: Book;
onGoBack: () => void;
}

// TODO: Temporary placeholder until we have a real description from the API
const PLACEHOLDER_DESCRIPTION =
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas efficitur rhoncus massa fermentum tristique. Curabitur interdum, elit eget rutrum posuere, turpis nisl malesuada neque, at consectetur massa nisl at risus. Mauris in lacinia dui. Proin vitae interdum nulla. Interdum et malesuada fames ac ante ipsum primis in faucibus. Fusce sem lacus, pulvinar nec libero id, dapibus mollis eros. Integer nisi augue, malesuada nec sem ac, laoreet elementum turpis. Maecenas rutrum purus a laoreet vehicula. Duis tristique odio eget dolor accumsan, sed laoreet ipsum mattis. Nulla molestie nisi quis urna mollis egestas. Aliquam bibendum tristique facilisis. Curabitur feugiat, lectus et vulputate vehicula, erat turpis aliquet nunc, vitae dapibus erat nulla in ipsum. Curabitur rhoncus ipsum ligula, ut ultricies ex rutrum cursus. Sed in nisl in nisi finibus lobortis.";

function BookDetails({ book, onGoBack }: BookDetailsProps) {
return (
<article className={styles.bookDetails}>
<section className={styles.coverSection}>
<button type="button" onClick={onGoBack} className={styles.goBackButton}>
Go back
</button>
<FavoriteButton book={book} />
<BookCover book={book} />
</section>
<section className={styles.info}>
<h3 className={styles.title}>{book.title}</h3>
<p>{book.author.join(", ")}</p>
<p>{book.publishedYear ?? "Unknown"}</p>
<p className={styles.description}>{PLACEHOLDER_DESCRIPTION}</p>
</section>
</article>
);
}

export { BookDetails };
9 changes: 7 additions & 2 deletions src/components/BookGrid/BookGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { BookCard } from "../BookCard/BookCard";
import styles from "./BookGrid.module.css";
import { useBooks } from "../../api/useBooks";
import type { Book } from "../../api/types";

function BookGrid() {
interface BookGridProps {
onBookSelect: (book: Book) => void;
}

function BookGrid({ onBookSelect }: BookGridProps) {
const { data, isPending, isError, error } = useBooks({
query: "subject:fantasy",
});
Expand Down Expand Up @@ -35,7 +40,7 @@ function BookGrid() {
<ul className={styles.bookGrid}>
{data.map((book) => (
<li key={book.id}>
<BookCard book={book} />
<BookCard book={book} onBookSelect={onBookSelect} />
</li>
))}
</ul>
Expand Down
25 changes: 25 additions & 0 deletions src/components/FavoriteButton/FavoriteButton.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.favoriteButton {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
top: 8px;
right: 8px;
background-color: white;
color: var(--theme-color);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 32px;
height: 32px;
font-size: 1.5rem;
cursor: pointer;

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

.favoriteButtonActive {
color: green;
border: 1px solid green;
}
Loading