Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
60a510d
feat: update db seeding script to insert book genres
martakam Oct 20, 2026
6eba723
fix: force book genres to be unique
martakam Oct 20, 2026
1b0b3cd
feat: add GraphQL endpoint for genres
martakam Oct 20, 2026
bea8b4b
feat: allow filtering books based on genre
martakam Oct 20, 2026
052de9c
fix: import better-auth util from correct package
martakam Oct 20, 2026
0a5927d
fix: book genres we're searching for should be OR, not AND
martakam Oct 20, 2026
05bb2b9
chore: generate GraphQL types
martakam Oct 20, 2026
2cdf893
feat: add genre filtering to frontend
martakam Oct 20, 2026
1d3ecfa
chore: generate GraphQL types
martakam Oct 20, 2026
41b862a
feat: sort genres by popularity
martakam Oct 20, 2026
c0ecc59
test: add unit tests for genre filtering
martakam Oct 20, 2026
610864a
feat: add languages to GraphQL schema
martakam Oct 20, 2026
6458c3a
feat: add language filtering to FilterBooksSection
martakam Oct 20, 2026
51362e6
test: check language filter usage and URL synchronization
martakam Oct 20, 2026
f511662
feat: add Silder component from Shadcn
martakam Oct 20, 2026
8ea7baa
feat: add filtering based on page count
martakam Oct 20, 2026
455ef84
test: check pages filter counts as one filter
martakam Oct 20, 2026
885386b
test: pages count filter and URL synchronization
martakam Oct 20, 2026
74516ec
fix: do not allow empty strings as values in book data
martakam Oct 21, 2026
85e1e2f
chore: run Prettier after GraphQL codegen
martakam Oct 21, 2026
bda4670
feat: add book formats query to backend
martakam Oct 21, 2026
f7941ec
feat: add book format filtering to book filters
martakam Oct 21, 2026
7e8bd88
Merge branch 'main' into feat/add-book-filters
martakam Oct 22, 2026
491e5f0
refactor: rename pages filter to book length
martakam Oct 23, 2026
9d2f92b
fix: reset page search param when changing filters
martakam Oct 23, 2026
394d55f
Merge branch 'main' into feat/add-book-filters
martakam Oct 24, 2026
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
1 change: 1 addition & 0 deletions backend/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const config: CodegenConfig = {
},
}),
},
hooks: { afterAllFileWrite: ["prettier --write"] },
};

export default config;
4 changes: 2 additions & 2 deletions backend/src/db/schema/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const books = pgTable("books", {
author: text("author").notNull(),
description: text("description"),
language: text("language"),
bookFormat: text("book_format").notNull(),
bookFormat: text("book_format"),
edition: text("edition"),
pages: integer("pages").notNull(),
publisher: text("publisher"),
Expand All @@ -24,7 +24,7 @@ const books = pgTable("books", {

const bookGenres = pgTable("book_genres", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
genre: text("genre").notNull(),
genre: text("genre").unique().notNull(),
});

const bookToGenres = pgTable(
Expand Down
75 changes: 62 additions & 13 deletions backend/src/db/seed/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { parse } from "csv-parse";
import { db } from "~/db";
import { books, type InsertBook } from "~/db/schema/books";
import {
bookGenres,
books,
bookToGenres,
type InsertBook,
} from "~/db/schema/books";

type BookRow = {
bookId: string;
Expand All @@ -14,9 +19,9 @@ type BookRow = {
description: string | null;
language: string | null;
isbn: string;
genres: string[];
genres: string;
characters: string[];
bookFormat: string;
bookFormat: string | null;
edition: string | null;
pages: number | string;
publisher: string | null;
Expand Down Expand Up @@ -45,6 +50,43 @@ const parser = createReadStream(resolve(__dirname, "book-data.csv")).pipe(

let inserted = 0;
let booksToInsert: InsertBook[] = [];
let genresToInsert: string[][] = [];
const genreIds = new Map<string, number>();

async function insertBatch(batch: InsertBook[], batchGenres: string[][]) {
const insertedBooks = await db
.insert(books)
.values(batch)
.returning({ id: books.id });

const newGenres = [...new Set(batchGenres.flat())].filter(
(genre) => !genreIds.has(genre),
);

if (newGenres.length > 0) {
const insertedGenres = await db
.insert(bookGenres)
.values(newGenres.map((genre) => ({ genre })))
.returning();

insertedGenres.forEach(({ id, genre }) => genreIds.set(genre, id));
}

const bookToGenreRows = insertedBooks.flatMap(({ id: bookId }, i) =>
[...new Set(batchGenres[i])].map((genre) => {
const genreId = genreIds.get(genre);

if (genreId === undefined) {
throw new Error(`Genre ID not found for genre "${genre}"`);
}
return { bookId, genreId };
}),
);

if (bookToGenreRows.length > 0) {
await db.insert(bookToGenres).values(bookToGenreRows);
}
}

for await (const book of parser as AsyncIterable<BookRow>) {
if (typeof book.pages === "string") {
Expand All @@ -58,22 +100,29 @@ for await (const book of parser as AsyncIterable<BookRow>) {
booksToInsert.push({
isbn: book.isbn,
title: book.title,
series: book.series || null,
series: book.series?.trim() || null,
author: book.author,
description: book.description || null,
language: book.language || null,
bookFormat: book.bookFormat,
edition: book.edition || null,
description: book.description?.trim() || null,
language: book.language?.trim() || null,
bookFormat: book.bookFormat?.trim() || null,
edition: book.edition?.trim() || null,
pages: book.pages,
publisher: book.publisher || null,
publishDate: book.publishDate || null,
coverImgUrl: book.coverImg || null,
publisher: book.publisher?.trim() || null,
publishDate: book.publishDate?.trim() || null,
coverImgUrl: book.coverImg?.trim() || null,
});

if (book.genres == "[]") {
genresToInsert.push([]);
} else {
genresToInsert.push(book.genres.slice(2, -2).split("', '"));
}

try {
if (booksToInsert.length >= BATCH_SIZE) {
await db.insert(books).values(booksToInsert);
await insertBatch(booksToInsert, genresToInsert);
booksToInsert = [];
genresToInsert = [];
inserted += BATCH_SIZE;
}
} catch (error) {
Expand All @@ -91,7 +140,7 @@ for await (const book of parser as AsyncIterable<BookRow>) {
}

// Insert last batch if there are any remaining books to insert
await db.insert(books).values(booksToInsert);
await insertBatch(booksToInsert, genresToInsert);

console.clear();
console.log("Seeding complete!");
Expand Down
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/bookFormats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, isNotNull } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { books } from "~/db/schema";

export const bookFormats: NonNullable<QueryResolvers["bookFormats"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each format's book count, and then alphabetically
const rows = await db
.select({ bookFormat: books.bookFormat })
.from(books)
.where(isNotNull(books.bookFormat))
.groupBy(books.bookFormat)
.orderBy(desc(count()), asc(books.bookFormat));

return rows.flatMap((row) => (row.bookFormat ? [row.bookFormat] : []));
};
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/genres.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, eq } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { bookGenres, bookToGenres } from "~/db/schema";

export const genres: NonNullable<QueryResolvers["genres"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each genre's book count, and then alphabetically
const rows = await db
.select({ genre: bookGenres.genre })
.from(bookGenres)
.leftJoin(bookToGenres, eq(bookToGenres.genreId, bookGenres.id))
.groupBy(bookGenres.id)
.orderBy(desc(count(bookToGenres.bookId)), asc(bookGenres.genre));

return rows.map((row) => row.genre);
};
20 changes: 20 additions & 0 deletions backend/src/schema/books/resolvers/Query/languages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { asc, count, desc, isNotNull } from "drizzle-orm";
import type { QueryResolvers } from "~/schema/types.generated";
import { db } from "~/db";
import { books } from "~/db/schema";

export const languages: NonNullable<QueryResolvers["languages"]> = async (
_parent,
_args,
_ctx,
) => {
// Order by each language's book count, and then alphabetically
const rows = await db
.select({ language: books.language })
.from(books)
.where(isNotNull(books.language))
.groupBy(books.language)
.orderBy(desc(count()), asc(books.language));

return rows.flatMap((row) => (row.language ? [row.language] : []));
};
42 changes: 36 additions & 6 deletions backend/src/schema/books/resolvers/Query/searchBooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import { and, asc, ilike, type SQL } from "drizzle-orm";
import {
and,
asc,
eq,
exists,
gte,
ilike,
lte,
or,
type SQL,
} from "drizzle-orm";
import { db } from "~/db";
import { books } from "~/db/schema/books";
import { bookGenres, books, bookToGenres } from "~/db/schema";
import type { QueryResolvers } from "~/schema/types.generated";

export const searchBooks: NonNullable<QueryResolvers["searchBooks"]> = async (
Expand All @@ -13,12 +23,32 @@ export const searchBooks: NonNullable<QueryResolvers["searchBooks"]> = async (
if (args.title) conditions.push(ilike(books.title, `%${args.title}%`));
if (args.series) conditions.push(ilike(books.series, `%${args.series}%`));
if (args.author) conditions.push(ilike(books.author, `%${args.author}%`));
if (args.language)
conditions.push(ilike(books.language, `%${args.language}%`));
if (args.bookFormat)
conditions.push(ilike(books.bookFormat, `%${args.bookFormat}%`));
if (args.language) conditions.push(eq(books.language, args.language));
if (args.bookFormat) conditions.push(eq(books.bookFormat, args.bookFormat));
if (args.publisher)
conditions.push(ilike(books.publisher, `%${args.publisher}%`));
if (args.minPages != null) conditions.push(gte(books.pages, args.minPages));
if (args.maxPages != null) conditions.push(lte(books.pages, args.maxPages));

const genreConditions: SQL[] = [];

for (const genre of args.genres ?? []) {
genreConditions.push(
exists(
db
.select()
.from(bookToGenres)
.innerJoin(bookGenres, eq(bookGenres.id, bookToGenres.genreId))
.where(
and(eq(bookToGenres.bookId, books.id), eq(bookGenres.genre, genre)),
),
),
);
}

if (genreConditions.length > 0) {
conditions.push(or(...genreConditions) as SQL);
}

// Here we build the filter once with Drizzle's SQL builder, so the same filter
// can be used both for fetching one page and for counting all matches.
Expand Down
11 changes: 9 additions & 2 deletions backend/src/schema/books/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ type Book {
author: String!
description: String
language: String
bookFormat: String!
bookFormat: String
edition: String
pages: Int!
publisher: String
Expand All @@ -16,17 +16,24 @@ type Book {
}

extend type Query {
book(id: Int!): Book
genres: [String!]!
languages: [String!]!
bookFormats: [String!]!

searchBooks(
title: String
series: String
author: String
language: String
bookFormat: String
publisher: String
genres: [String!]
minPages: Int
maxPages: Int
limit: Int = 12
offset: Int = 0
): BookSearchResult!
book(id: Int!): Book
}

type BookSearchResult {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/schema/resolvers.generated.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/* This file was automatically generated. DO NOT UPDATE MANUALLY. */
import type { Resolvers } from './types.generated';
import { book as Query_book } from './books/resolvers/Query/book';
import { bookFormats as Query_bookFormats } from './books/resolvers/Query/bookFormats';
import { favoriteBookIds as Query_favoriteBookIds } from './favorites/resolvers/Query/favoriteBookIds';
import { favoriteBooks as Query_favoriteBooks } from './favorites/resolvers/Query/favoriteBooks';
import { favoriteGenres as Query_favoriteGenres } from './favorites/resolvers/Query/favoriteGenres';
import { genres as Query_genres } from './books/resolvers/Query/genres';
import { languages as Query_languages } from './books/resolvers/Query/languages';
import { searchBooks as Query_searchBooks } from './books/resolvers/Query/searchBooks';
import { addFavoriteBook as Mutation_addFavoriteBook } from './favorites/resolvers/Mutation/addFavoriteBook';
import { removeFavoriteBook as Mutation_removeFavoriteBook } from './favorites/resolvers/Mutation/removeFavoriteBook';
import { Book } from './books/resolvers/Book';
import { BookSearchResult } from './books/resolvers/BookSearchResult';
export const resolvers: Resolvers = {
Query: { book: Query_book,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,searchBooks: Query_searchBooks },
Query: { book: Query_book,bookFormats: Query_bookFormats,favoriteBookIds: Query_favoriteBookIds,favoriteBooks: Query_favoriteBooks,favoriteGenres: Query_favoriteGenres,genres: Query_genres,languages: Query_languages,searchBooks: Query_searchBooks },
Mutation: { addFavoriteBook: Mutation_addFavoriteBook,removeFavoriteBook: Mutation_removeFavoriteBook },

Book: Book,
Expand Down
7 changes: 5 additions & 2 deletions backend/src/schema/schema.generated.graphqls
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
type Book {
author: String!
bookFormat: String!
bookFormat: String
coverImgUrl: String
description: String
edition: String
Expand All @@ -27,6 +27,7 @@ type Mutation {

type Query {
book(id: Int!): Book
bookFormats: [String!]!

"""
ID of every book the logged-in user that has been added to their favorites.
Expand All @@ -38,5 +39,7 @@ type Query {

"""The logged-in user's most common genres among their favorite books."""
favoriteGenres(limit: Int = 3): [String!]!
searchBooks(author: String, bookFormat: String, language: String, limit: Int = 12, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult!
genres: [String!]!
languages: [String!]!
searchBooks(author: String, bookFormat: String, genres: [String!], language: String, limit: Int = 12, maxPages: Int, minPages: Int, offset: Int = 0, publisher: String, series: String, title: String): BookSearchResult!
}
Loading