feat: integrate Hardcover API for books

- Add lib/hardcover.ts with GraphQL client for Hardcover API
- Add routes/api/books/[name].ts for creating books via Hardcover ID
- Add routes/api/books/enhance/[name].ts for enhancing books
- Add routes/api/hardcover/query.ts for searching books
- Add routes/books/[name].tsx and index.tsx for book pages
This commit is contained in:
2026-02-10 18:19:10 +01:00
parent c232794cc0
commit e0bfbdd719
8 changed files with 923 additions and 0 deletions

140
lib/hardcover.ts Normal file
View File

@@ -0,0 +1,140 @@
import { createCache } from "@lib/cache.ts";
const HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql";
const CACHE_INTERVAL = 1000 * 60 * 60 * 24 * 7;
const cache = createCache("hardcover", { expires: CACHE_INTERVAL });
export interface HardcoverBookResult {
id: string;
slug: string;
title: string;
subtitle?: string;
author_names: string[];
cover_color?: string;
description?: string;
isbn?: string;
isbn13?: string;
release_year?: string;
series_names?: string[];
rating?: number;
ratings_count?: number;
pages?: number;
image?: string;
}
async function hardcoverFetch(query: string, variables: Record<string, unknown> = {}) {
const response = await fetch(HARDCOVER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": Deno.env.get("HARDCOVER_API_TOKEN") || "",
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(`Hardcover API error: ${response.statusText}`);
}
const json = await response.json();
if (json.errors) {
console.error("Hardcover GraphQL errors:", JSON.stringify(json.errors, null, 2));
throw new Error(`Hardcover GraphQL error: ${json.errors[0]?.message || "Unknown error"}`);
}
return json;
}
export const searchBook = async (query: string) => {
const id = `query:booksearch:${query}`;
if (cache.has(id)) return cache.get(id) as HardcoverBookResult[];
const graphqlQuery = `
query SearchBooks($query: String!, $perPage: Int, $page: Int) {
search(
query: $query
query_type: "Book"
per_page: $perPage
page: $page
) {
results
}
}
`;
const result = await hardcoverFetch(graphqlQuery, {
query,
perPage: 10,
page: 1,
});
const typesenseResponse = result.data?.search?.results;
const hits = typesenseResponse?.hits;
const books = (hits || []).map((hit: { document: HardcoverBookResult }) => hit.document);
cache.set(id, books);
return books as HardcoverBookResult[];
};
export const getBookDetails = async (id: string) => {
const cacheId = `query:bookdetails:${id}`;
if (cache.has(cacheId)) return cache.get(cacheId);
const graphqlQuery = `
query GetBook($id: Int!) {
books(where: { id: { _eq: $id } }) {
id
slug
title
subtitle
description
release_date
release_year
rating
ratings_count
pages
cached_image
cached_contributors
featured_book_series {
series {
id
name
slug
}
}
editions(limit: 1) {
isbn_10
isbn_13
}
}
}
`;
const result = await hardcoverFetch(graphqlQuery, { id: parseInt(id) });
const bookData = result.data?.books?.[0];
if (bookData) {
const isbn13 = bookData.editions?.[0]?.isbn_13;
const isbn10 = bookData.editions?.[0]?.isbn_10;
const cachedContributors = bookData.cached_contributors as Array<{ author: { name: string }; contribution: string }> | undefined;
const authorName = cachedContributors?.[0]?.author?.name;
const book = {
...bookData,
isbn: isbn13 || isbn10 || "",
isbn13,
isbn10,
author_names: authorName ? [authorName] : [],
image: bookData.cached_image?.url,
};
cache.set(cacheId, book);
return book;
}
return null;
};

View File

@@ -0,0 +1,75 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { getBookDetails } from "@lib/hardcover.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { AccessDeniedError } from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { ReviewResource } from "@lib/marka/schema.ts";
export const handler: Handlers = {
async GET(_, ctx) {
const book = await fetchResource(`books/${ctx.params.name}`);
return json(book?.content);
},
async POST(_, ctx) {
const session = ctx.state.session;
if (!session) throw new AccessDeniedError();
const hardcoverId = ctx.params.name;
if (!hardcoverId) throw new AccessDeniedError();
const bookDetails = await getBookDetails(hardcoverId);
if (!bookDetails) {
throw new Error("Book not found on Hardcover");
}
const title = bookDetails.title || hardcoverId;
const authorName = bookDetails.author_names?.[0] || "";
const isbn = bookDetails.isbn13 || bookDetails.isbn || "";
const releaseDate = bookDetails.release_year
? `${bookDetails.release_year}-01-01`
: undefined;
let finalPath = "";
if (bookDetails.image) {
try {
const response = await fetch(bookDetails.image);
if (response.ok) {
const buffer = await response.arrayBuffer();
const extension = fileExtension(bookDetails.image);
finalPath = `books/images/${safeFileName(title)}_cover.${extension}`;
await createResource(finalPath, buffer);
}
} catch {
console.log("Failed to download book cover");
}
}
const book: ReviewResource["content"] = {
_type: "Review",
headline: title,
subtitle: bookDetails.subtitle,
bookBody: bookDetails.description || "",
link: `https://hardcover.app/books/${bookDetails.slug}`,
image: finalPath ? `resources/${finalPath}` : undefined,
datePublished: formatDate(releaseDate),
author: authorName ? {
_type: "Person",
name: authorName,
} : undefined,
itemReviewed: {
name: title,
},
};
console.log("Creating book resource:", JSON.stringify(book, null, 2));
const fileName = toUrlSafeString(title);
await createResource(`books/${fileName}.md`, book);
return json({ name: fileName });
},
};

View File

@@ -0,0 +1,242 @@
import { Handlers } from "$fresh/server.ts";
import { Defuddle } from "defuddle/node";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
import { createStreamResponse, isValidUrl } from "@lib/helpers.ts";
import * as openai from "@lib/openai.ts";
import * as unsplash from "@lib/unsplash.ts";
import { getYoutubeVideoDetails } from "@lib/youtube.ts";
import {
extractYoutubeId,
formatDate,
isYoutubeLink,
safeFileName,
toUrlSafeString,
} from "@lib/string.ts";
import { createLogger } from "@lib/log/index.ts";
import { createResource } from "@lib/marka/index.ts";
import { webScrape } from "@lib/webScraper.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
const log = createLogger("api/book");
async function getUnsplashCoverImage(
content: string,
streamResponse: ReturnType<typeof createStreamResponse>,
): Promise<string | undefined> {
try {
streamResponse.info("creating unsplash search term");
const searchTerm = await openai.createUnsplashSearchTerm(content);
if (!searchTerm) return;
streamResponse.info(`searching for ${searchTerm}`);
const unsplashUrl = await unsplash.getImageBySearchTerm(searchTerm);
return unsplashUrl;
} catch (e) {
log.error("Failed to get unsplash cover image", e);
return undefined;
}
}
function ext(str: string) {
try {
const u = new URL(str);
if (u.searchParams.has("fm")) {
return u.searchParams.get("fm")!;
}
return fileExtension(u.pathname);
} catch (_e) {
return fileExtension(str);
}
}
async function fetchAndStoreCover(
imageUrl: string | undefined,
title: string,
streamResponse?: ReturnType<typeof createStreamResponse>,
): Promise<string | undefined> {
if (!imageUrl) return;
const imagePath = `books/images/${safeFileName(title)}_cover.${
ext(imageUrl)
}`;
try {
streamResponse?.info("downloading image");
const res = await fetch(imageUrl);
streamResponse?.info("saving image");
if (!res.ok) {
console.log(`Failed to download remote image: ${imageUrl}`, res.status);
return;
}
const buffer = await res.arrayBuffer();
await createResource(imagePath, buffer);
return `resources/${imagePath}`;
} catch (err) {
console.log(`Failed to save image: ${imageUrl}`, err);
return undefined;
}
}
async function processCreateBook(
{ fetchUrl, streamResponse }: {
fetchUrl: string;
streamResponse: ReturnType<typeof createStreamResponse>;
},
) {
log.info("create book from url", { url: fetchUrl });
streamResponse.info("downloading book");
const result = await webScrape(fetchUrl, streamResponse);
log.debug("downloaded and parse parsed", result);
streamResponse.info("parsed book, creating tags with openai");
const aiMeta = await openai.extractArticleMetadata(result.markdown);
streamResponse.info("postprocessing book");
const title = result?.title || aiMeta?.headline || "";
let coverImagePath: string | undefined = undefined;
if (result?.image?.length) {
log.debug("using local image for cover image", { image: result.image });
coverImagePath = await fetchAndStoreCover(
result.image,
title,
streamResponse,
);
} else {
const urlPath = await getUnsplashCoverImage(
result.markdown,
streamResponse,
);
coverImagePath = await fetchAndStoreCover(urlPath, title, streamResponse);
log.debug("using unsplash for cover image", { image: coverImagePath });
}
const url = toUrlSafeString(title);
const newBook: BookResource["content"] = {
_type: "Book",
headline: title,
bookBody: result.markdown,
url: fetchUrl,
datePublished: formatDate(
result?.published || aiMeta?.datePublished || undefined,
),
image: coverImagePath,
author: {
_type: "Person",
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace(
"@",
"twitter:",
),
},
} as const;
streamResponse.info("writing to disk");
log.debug("writing to disk", {
...newBook,
bookBody: newBook.bookBody?.slice(0, 200),
});
await createResource(`books/${url}.md`, newBook);
streamResponse.send({ type: "finished", url });
}
async function processCreateYoutubeVideo(
{ fetchUrl, streamResponse }: {
fetchUrl: string;
streamResponse: ReturnType<typeof createStreamResponse>;
},
) {
log.info("create youtube book from url", {
url: fetchUrl,
});
streamResponse.info("getting video infos from youtube api");
const youtubeId = extractYoutubeId(fetchUrl);
const video = await getYoutubeVideoDetails(youtubeId);
streamResponse.info("shortening title with openai");
const videoTitle = await openai.shortenTitle(video.snippet.title) ||
video.snippet.title;
const thumbnail = video?.snippet?.thumbnails?.maxres;
const coverImagePath = await fetchAndStoreCover(
thumbnail.url,
videoTitle || video.snippet.title,
streamResponse,
);
const newBook: BookResource["content"] = {
_type: "Book",
headline: video.snippet.title,
bookBody: video.snippet.description,
image: coverImagePath,
url: fetchUrl,
datePublished: formatDate(video.snippet.publishedAt),
author: {
_type: "Person",
name: video.snippet.channelTitle,
},
};
streamResponse.info("creating book");
const filename = toUrlSafeString(videoTitle);
await createResource(
`books/${filename}.md`,
newBook,
);
streamResponse.info("finished");
streamResponse.send({ type: "finished", url: filename });
}
export const handler: Handlers = {
GET(req, ctx) {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const url = new URL(req.url);
const fetchUrl = url.searchParams.get("url");
if (!fetchUrl || !isValidUrl(fetchUrl)) {
throw new BadRequestError();
}
const streamResponse = createStreamResponse();
if (isYoutubeLink(fetchUrl)) {
processCreateYoutubeVideo({ fetchUrl, streamResponse }).then(
(book) => {
log.debug("created book from youtube", { book });
},
).catch((err) => {
log.error(err);
}).finally(() => {
streamResponse.cancel();
});
} else {
processCreateBook({ fetchUrl, streamResponse }).then((book) => {
log.debug("created book from link", { book });
}).catch((err) => {
log.error(err);
}).finally(() => {
streamResponse.cancel();
});
}
return streamResponse.response;
},
};

View File

@@ -0,0 +1,244 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import {
AccessDeniedError,
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { webScrape } from "@lib/webScraper.ts";
import * as openai from "@lib/openai.ts";
import { getBookDetails } from "@lib/hardcover.ts";
import { createLogger } from "@lib/log/index.ts";
function ext(str: string) {
try {
const u = new URL(str);
if (u.searchParams.has("fm")) {
return u.searchParams.get("fm")!;
}
return fileExtension(u.pathname);
} catch (_e) {
return fileExtension(str);
}
}
const log = createLogger("api/book/enhance");
async function fetchAndStoreCover(
imageUrl: string | undefined,
title: string,
): Promise<string | undefined> {
if (!imageUrl) return;
const imagePath = `books/images/${safeFileName(title)}_cover.${
ext(imageUrl)
}`;
try {
const res = await fetch(imageUrl);
if (!res.ok) {
log.error(`Failed to download remote image: ${imageUrl}`, {
status: res.status,
});
return;
}
const buffer = await res.arrayBuffer();
await createResource(imagePath, buffer);
return `resources/${imagePath}`;
} catch (err) {
log.error(`Failed to save image: ${imageUrl}`, err);
return undefined;
}
}
async function processEnhanceFromHardcover(
name: string,
hardcoverSlug: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
streamResponse.info("fetching from Hardcover");
const bookDetails = await getBookDetails(hardcoverSlug);
if (!bookDetails) {
throw new NotFoundError("Book not found on Hardcover");
}
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const title = bookDetails.title || book.content?.headline || "";
const authorName = bookDetails.author_names?.[0] || "";
streamResponse.info("updating cover image");
if (bookDetails.image && !book.content?.image) {
const coverPath = await fetchAndStoreCover(bookDetails.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
if (!book.content?.headline || book.content.headline !== bookDetails.title) {
book.content.headline = bookDetails.title;
}
if (!book.content?.subtitle && bookDetails.subtitle) {
book.content.subtitle = bookDetails.subtitle;
}
if (!book.content?.isbn && (bookDetails.isbn13 || bookDetails.isbn)) {
book.content.isbn = bookDetails.isbn13 || bookDetails.isbn;
}
if (bookDetails.rating && !book.content?.reviewRating) {
book.content.reviewRating = {
ratingValue: bookDetails.rating,
bestRating: 5,
worstRating: 1,
};
}
if (!book.content?.datePublished && bookDetails.release_year) {
book.content.datePublished = formatDate(`${bookDetails.release_year}-01-01`);
}
const newKeywords = [
...(bookDetails.genres?.map((g: { name: string }) => g.name) || []),
...(bookDetails.tags?.map((t: { name: string }) => t.name) || []),
].filter(Boolean);
if (newKeywords.length > 0) {
book.content.keywords = [
...(book.content.keywords || []),
...newKeywords,
];
}
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceFromUrl(
name: string,
fetchUrl: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
log.info("enhancing book from url", { url: fetchUrl });
streamResponse.info("scraping url");
const result = await webScrape(fetchUrl, streamResponse);
streamResponse.info("parsing content");
log.debug("downloaded and parsed", result);
streamResponse.info("extracting metadata with openai");
const aiMeta = await openai.extractArticleMetadata(result.markdown);
const title = result?.title || aiMeta?.headline ||
name;
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
book.content ??= {
_type: "Book",
headline: title,
url: fetchUrl,
};
if (!book.content.bookBody && result.markdown) {
book.content.bookBody = result.markdown;
}
book.content.datePublished ??= formatDate(
result?.published || aiMeta?.datePublished || undefined,
);
if (!book.content.author?.name || book.content.author.name === "") {
book.content.author = {
_type: "Person",
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace("@", "twitter:"),
};
}
if (!book.content.image && result?.image?.length) {
const coverPath = await fetchAndStoreCover(result.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
log.debug("writing to disk", {
name: name,
book: {
...book,
content: {
...book.content,
bookBody: book.content.bookBody?.slice(0, 200),
},
},
});
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceBook(
name: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const hardcoverUrl = book.content?.url;
if (hardcoverUrl?.includes("hardcover.app")) {
const match = hardcoverUrl.match(/books\/(.+)/);
if (match && match[1]) {
return processEnhanceFromHardcover(name, match[1], streamResponse);
}
}
if (hardcoverUrl) {
return processEnhanceFromUrl(name, hardcoverUrl, streamResponse);
}
throw new BadRequestError("Book has no URL to enhance from.");
}
const POST = (
_req: Request,
ctx: FreshContext,
): Response => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const streamResponse = createStreamResponse();
processEnhanceBook(ctx.params.name, streamResponse)
.catch((err) => {
log.error(err);
streamResponse.error(err.message);
})
.finally(() => {
streamResponse.cancel();
});
return streamResponse.response;
};
export const handler: Handlers = {
POST,
};

10
routes/api/books/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Handlers } from "$fresh/server.ts";
import { json } from "@lib/helpers.ts";
import { fetchResource } from "@lib/marka/index.ts";
export const handler: Handlers = {
async GET() {
const books = await fetchResource("books");
return json(books?.content);
},
};

View File

@@ -0,0 +1,28 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { searchBook } from "@lib/hardcover.ts";
import { AccessDeniedError, BadRequestError } from "@lib/errors.ts";
const GET = async (
req: Request,
ctx: FreshContext,
) => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const u = new URL(req.url);
const query = u.searchParams.get("q");
if (!query) {
throw new BadRequestError();
}
const books = await searchBook(query);
console.log("Hardcover search results:", JSON.stringify(books).slice(0, 500));
return new Response(JSON.stringify(books));
};
export const handler: Handlers = {
GET,
};

122
routes/books/[name].tsx Normal file
View File

@@ -0,0 +1,122 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { KMenu } from "@islands/KMenu.tsx";
import { YoutubePlayer } from "@components/Youtube.tsx";
import { HashTags } from "@components/HashTags.tsx";
import { isYoutubeLink } from "@lib/string.ts";
import { removeImage, renderMarkdown } from "@lib/markdown.ts";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import PageHero from "@components/PageHero.tsx";
import { Star } from "@components/Stars.tsx";
import { MetaTags } from "@components/MetaTags.tsx";
import { fetchResource } from "@lib/marka/index.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { parseRating } from "@lib/helpers.ts";
export const handler: Handlers<{ book: BookResource; session: unknown }> =
{
async GET(_, ctx) {
const book = await fetchResource<BookResource>(
`books/${ctx.params.name}.md`,
);
if (!book) {
return ctx.renderNotFound();
}
return ctx.render({ book, session: ctx.state.session });
},
};
export default function Greet(
props: PageProps<
{ book: BookResource; session: Record<string, string> }
>,
) {
const { book, session } = props.data;
const { author, datePublished, reviewRating, bookBody = "", reviewBody } =
book?.content || {};
const bookContent = renderMarkdown(
removeImage(bookBody, book.image?.url),
);
const reviewContent = reviewBody
? renderMarkdown(removeImage(reviewBody, book.image?.url))
: undefined;
const rating = reviewRating?.ratingValue &&
parseRating(reviewRating.ratingValue);
return (
<MainLayout
url={props.url}
title={`Book > ${book.content.headline}`}
context={book}
>
<RedirectSearchHandler />
<KMenu type="main" context={book} />
<MetaTags resource={book} />
<PageHero
image={book.image?.url}
thumbhash={book.image?.thumbhash}
>
<PageHero.Header>
<PageHero.BackLink href="/books" />
{session && (
<PageHero.EditLink
href={`https://notes.max-richter.dev/resources/books/${book.name}`}
/>
)}
</PageHero.Header>
<PageHero.Footer>
<PageHero.Title link={book.content.url}>
{book.content.headline}
</PageHero.Title>
<PageHero.Subline
entries={[
author?.name && {
title: author.name,
href: `/?q=${encodeURIComponent(author?.name)}`,
},
datePublished?.toString(),
]}
>
{rating && <Star rating={rating} />}
</PageHero.Subline>
</PageHero.Footer>
</PageHero>
{book.content?.keywords?.length && (
<>
<br />
<HashTags tags={book.content.keywords} />
</>
)}
<div class="px-8 text-white mt-10">
{(book.content.url && isYoutubeLink(book.content.url)) && (
<YoutubePlayer link={book.content.url} />
)}
<pre
class="whitespace-break-spaces markdown-body"
data-color-mode="dark"
data-dark-theme="dark"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: bookContent || "" }}
/>
{reviewContent && (
<>
<h2 class="text-4xl font-bold mb-4 mt-8">Review</h2>
<pre
class="whitespace-break-spaces markdown-body"
data-color-mode="dark"
data-dark-theme="dark"
// deno-lint-ignore react-no-danger
dangerouslySetInnerHTML={{ __html: reviewContent }}
/>
</>
)}
</div>
</MainLayout>
);
}

62
routes/books/index.tsx Normal file
View File

@@ -0,0 +1,62 @@
import { Handlers, PageProps } from "$fresh/server.ts";
import { MainLayout } from "@components/layouts/main.tsx";
import { type BookResource, GenericResource } from "@lib/marka/schema.ts";
import { KMenu } from "@islands/KMenu.tsx";
import { Grid } from "@components/Grid.tsx";
import { IconArrowLeft } from "@components/icons.tsx";
import { RedirectSearchHandler } from "@islands/Search.tsx";
import { parseResourceUrl, searchResource } from "@lib/search.ts";
import { ResourceCard } from "@components/Card.tsx";
import { Link } from "@islands/Link.tsx";
import { listResources } from "@lib/marka/index.ts";
export const handler: Handlers<
{ books: BookResource[] | null; searchResults?: GenericResource[] }
> = {
async GET(req, ctx) {
const books = await listResources<BookResource>("books");
const searchParams = parseResourceUrl(req.url);
const searchResults = searchParams &&
await searchResource({ ...searchParams, types: ["books"] });
return ctx.render({ books, searchResults });
},
};
export default function Greet(
props: PageProps<
{ books: BookResource[] | null; searchResults: GenericResource[] }
>,
) {
const { books, searchResults } = props.data;
return (
<MainLayout
url={props.url}
title="Books"
context={{ type: "books" }}
searchResults={searchResults}
>
<header class="flex gap-4 items-center mb-5 md:hidden">
<Link
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
href="/"
>
<IconArrowLeft class="w-5 h-5" />
Back
</Link>
<h3 class="text-2xl text-white font-light">📚 Books</h3>
</header>
<RedirectSearchHandler />
<KMenu type="main" context={{ type: "books" }} />
<Grid>
{books?.map((doc, i) => (
<ResourceCard
key={doc.name || i}
sublink="books"
res={doc}
/>
))}
</Grid>
</MainLayout>
);
}