diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index df76816..518c524 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,21 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ # Generated operation types are committed, so a checkout builds without
+ # running the generator. That only holds if the committed output matches
+ # the schema and the operations, so regenerate and fail on any diff. This
+ # runs before typecheck on purpose: a stale tree should fail with "run
+ # pnpm codegen", not with a type error in generated code.
+ - name: Check generated GraphQL types are up to date
+ run: |
+ set -euo pipefail
+ pnpm codegen
+ if [ -n "$(git status --porcelain -- client/src/generated)" ]; then
+ echo "::error::client/src/generated is stale. Run 'pnpm codegen' and commit the result."
+ git --no-pager diff -- client/src/generated | head -60
+ exit 1
+ fi
+
- name: Check formatting
run: pnpm format:check
diff --git a/.prettierignore b/.prettierignore
index 882f834..4d925db 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -3,3 +3,4 @@ coverage
node_modules
pnpm-lock.yaml
*.min.js
+client/src/generated
diff --git a/README.md b/README.md
index 71f69a2..bfc9f6c 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,7 @@ CI also builds the project and checks the client bundle size.
| `pnpm format` / `pnpm format:check` | Format or check formatting |
| `pnpm typecheck` | Run TypeScript checks |
| `pnpm test` | Run unit tests |
+| `pnpm codegen` | Regenerate GraphQL types after editing an operation |
| `pnpm --filter @t19/server dev` | Start the GraphQL API |
| `pnpm --filter @t19/server db:migrate` | Apply database migrations |
| `pnpm --filter @t19/server db:seed` | Reset to the sample catalog |
@@ -86,6 +87,23 @@ CI also builds the project and checks the client bundle size.
Tests cover frontend components and backend logic. Additional PostgreSQL tests cover search, sorting, rating updates and filters. These run when `TEST_DATABASE_URL` is configured and require a separate test database with migrations applied. The
[VM setup guide](docs/VM-setup.md) includes API smoke tests.
+### GraphQL types
+
+The client's operation types are generated from the repository's `schema.graphql`
+with [GraphQL Code Generator](https://the-guild.dev/graphql/codegen), using the
+client preset. There are no hand-written result types: `client/src/api/*.ts`
+declares each operation with `graphql()` and derives its types from the generated
+output, so a field renamed in the contract breaks `pnpm typecheck` instead of
+returning `undefined` at runtime.
+
+The generated files are committed under `client/src/generated/`, so a fresh
+checkout builds without running the generator. After changing an operation or the
+schema, run `pnpm codegen` and commit the result. `pnpm build` runs the generator
+first, so a schema change the operations no longer match fails the build, and CI
+regenerates and fails on any difference. Generated files are excluded from ESLint
+and Prettier: their formatting is the generator's, and a formatting pass would
+only create noise in the stale-file check.
+
## Database seed data
`db:seed` loads 16 curated albums for development. For scalability testing, `db:seed:large` generates 100,000 deterministic synthetic albums in batches of 1,000. You can change the size with `ALBUM_COUNT`:
@@ -243,9 +261,9 @@ The two `AlbumDetailPage` rows represent the separate chunks for the album detai
**Album details and reviews:** Album pages display tracks, average ratings and reviews. Users can write, edit and delete their own reviews.
-**Personal crate:** Users can add or remove albums from their crate on both search results and album detail pages. User identity is simulated using a locally stored ID rather than a login system.
+**Personal crate:** Users can add or remove albums from their crate on both search results and album detail pages, and a crate page lists everything they have saved, newest first, with paging. The API returns only the requesting user's items. User identity is simulated using a locally stored ID rather than a login system.
-**Planned for M4:** A page for viewing all albums saved in the user's crate, the supporting `myCrate` query, and Playwright end-to-end tests. Browsing albums without entering a search term is also planned.
+**Planned for M4:** Playwright end-to-end tests, and browsing albums without entering a search term.
### Backend search behavior
diff --git a/client/codegen.ts b/client/codegen.ts
new file mode 100644
index 0000000..30a4f33
--- /dev/null
+++ b/client/codegen.ts
@@ -0,0 +1,40 @@
+import type { CodegenConfig } from '@graphql-codegen/cli';
+
+/**
+ * Types for every GraphQL operation come from the schema the server actually
+ * serves, not from interfaces written by hand next to each operation.
+ *
+ * The schema is the repository's root `schema.graphql`: the same file the
+ * server reads at startup and the client tests validate against. A field that
+ * is renamed in the contract therefore breaks `pnpm typecheck` instead of
+ * failing at runtime. We shipped one schema-shape bug this way before (#30).
+ *
+ * The documents come from the operation files themselves (`src/api/*.ts`), so
+ * an operation and its generated types live in the same place. Generated output
+ * is committed under `src/generated/`, and CI runs this command and fails if
+ * the committed output is stale - see `.github/workflows/ci.yml`.
+ */
+const config: CodegenConfig = {
+ schema: '../schema.graphql',
+ // Test files are excluded on purpose: a fixture operation would otherwise end
+ // up in the generated document map and in the production bundle.
+ documents: [
+ 'src/**/*.{ts,tsx}',
+ '!src/**/*.test.{ts,tsx}',
+ '!src/generated/**',
+ '!src/test/**',
+ ],
+ // The rest of the client imports with an explicit `.js` extension.
+ importExtension: '.js',
+ generates: {
+ './src/generated/': {
+ preset: 'client',
+ config: {
+ // The schema's DateTime scalar is an ISO string over the wire.
+ scalars: { DateTime: 'string' },
+ },
+ },
+ },
+};
+
+export default config;
diff --git a/client/package.json b/client/package.json
index dbdb1a7..2e63502 100644
--- a/client/package.json
+++ b/client/package.json
@@ -5,11 +5,12 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "pnpm codegen && tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --noEmit",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "codegen": "graphql-codegen --config codegen.ts"
},
"dependencies": {
"@apollo/client": "^4.3.3",
@@ -19,6 +20,9 @@
"rxjs": "^7.8.2"
},
"devDependencies": {
+ "@graphql-codegen/cli": "^7.4.6",
+ "@graphql-codegen/client-preset": "^6.2.3",
+ "@graphql-typed-document-node/core": "^3.2.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
diff --git a/client/src/App.tsx b/client/src/App.tsx
index e9cd43f..b420f3e 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -15,6 +15,7 @@ export default function App() {
: window.location.pathname;
const detailMatch = /^\/album\/(\d+)$/.exec(pathname);
const detailId = detailMatch?.[1];
+ const isCrate = pathname === '/crate';
return (
<>
@@ -26,9 +27,12 @@ export default function App() {
Crate●
-
+
Discover
+
+ Your crate
+
{detailId ? (
@@ -41,6 +45,16 @@ export default function App() {
>
+ ) : isCrate ? (
+
+
Loading your crate…
+
+ }
+ >
+
+
) : (
)}
@@ -99,3 +113,4 @@ function DiscoveryPage() {
}
const AlbumDetailPage = lazy(() => import('./pages/AlbumDetailPage.js'));
+const CratePage = lazy(() => import('./pages/CratePage.js'));
diff --git a/client/src/api/albumDetail.ts b/client/src/api/albumDetail.ts
index ba6cc5a..8aa6308 100644
--- a/client/src/api/albumDetail.ts
+++ b/client/src/api/albumDetail.ts
@@ -1,46 +1,13 @@
-import { gql } from '@apollo/client';
-import type { TypedDocumentNode } from '@apollo/client';
+import { graphql } from '../generated/gql.js';
+import type {
+ AlbumDetailQuery,
+ AlbumDetailQueryVariables,
+} from '../generated/graphql.js';
-export type AlbumDetail = {
- id: string;
- title: string;
- releaseYear: number | null;
- artists: { id: string; name: string }[];
- genres: { id: string; name: string }[];
- tracks: {
- id: string;
- title: string;
- trackNumber: number;
- durationSeconds: number | null;
- }[];
- averageRating: number | null;
- reviewCount: number;
- isInCrate: boolean;
- reviews: {
- edges: {
- cursor: string;
- node: {
- id: string;
- rating: number;
- comment: string;
- createdAt: string;
- user: { id: string; displayName: string };
- };
- }[];
- pageInfo: { hasNextPage: boolean; endCursor: string | null };
- totalCount: number;
- };
- myReview: { id: string; rating: number; comment: string } | null;
-};
+export type AlbumDetail = NonNullable;
+export type AlbumDetailVariables = AlbumDetailQueryVariables;
-type AlbumDetailData = { album: AlbumDetail | null };
-type AlbumDetailVariables = {
- id: string;
- reviewFirst: number;
- reviewAfter?: string | null;
-};
-
-export const ALBUM_DETAIL: TypedDocumentNode = gql`
+export const ALBUM_DETAIL = graphql(`
query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {
album(id: $id) {
id
@@ -90,4 +57,4 @@ export const ALBUM_DETAIL: TypedDocumentNode & {
edges: { cursor: string; node: Reference | StoreObject }[];
};
+// The crate is one list per user, so no arguments belong in its cache key; only
+// the paging argument decides whether this is another page or a refresh.
+type CachedCrate = {
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
+ edges: { cursor: string; node: Reference | StoreObject }[];
+ totalCount: number;
+};
+
// InMemoryCache stores server results and shares album records by their IDs.
export const cache = new InMemoryCache({
typePolicies: {
@@ -49,6 +57,39 @@ export const cache = new InMemoryCache({
return { ...incoming, edges };
},
},
+
+ myCrate: {
+ // One crate per user, so there is nothing to key on: `after` is paging,
+ // not a different list. The user id never reaches the cache key.
+ keyArgs: false,
+
+ merge(
+ existing: CachedCrate | undefined,
+ incoming: CachedCrate,
+ { args, readField },
+ ): CachedCrate {
+ // A reload of the first page replaces whatever was accumulated.
+ if (!args?.after) return incoming;
+ if (!existing || args.after !== existing.pageInfo.endCursor) {
+ return existing ?? incoming;
+ }
+
+ const ids = new Set(
+ existing.edges.map((edge) => readField('id', edge.node)),
+ );
+ const edges = [...existing.edges];
+
+ for (const edge of incoming.edges) {
+ const id = readField('id', edge.node);
+ if (!ids.has(id)) {
+ ids.add(id);
+ edges.push(edge);
+ }
+ }
+
+ return { ...incoming, edges };
+ },
+ },
},
},
},
diff --git a/client/src/api/crate.ts b/client/src/api/crate.ts
index f9e0692..60656e8 100644
--- a/client/src/api/crate.ts
+++ b/client/src/api/crate.ts
@@ -1,18 +1,19 @@
-import { gql } from '@apollo/client';
-import type { TypedDocumentNode } from '@apollo/client';
+import { graphql } from '../generated/gql.js';
+import type {
+ AddToCrateMutation,
+ AddToCrateMutationVariables,
+ MyCrateQuery,
+ RemoveFromCrateMutation,
+} from '../generated/graphql.js';
-export type AddToCrateData = {
- addToCrate: { item: { id: string }; album: { id: string; isInCrate: boolean } };
-};
-export type RemoveFromCrateData = {
- removeFromCrate: {
- removedAlbumId: string;
- album: { id: string; isInCrate: boolean };
- };
-};
-export type CrateVariables = { albumId: string };
+export type AddToCrateData = AddToCrateMutation;
+export type RemoveFromCrateData = RemoveFromCrateMutation;
+export type CrateVariables = AddToCrateMutationVariables;
-export const ADD_TO_CRATE: TypedDocumentNode = gql`
+export type CratePage = MyCrateQuery['myCrate'];
+export type CrateItemSummary = CratePage['edges'][number]['node'];
+
+export const ADD_TO_CRATE = graphql(`
mutation AddToCrate($albumId: ID!) {
addToCrate(albumId: $albumId) {
item {
@@ -24,17 +25,53 @@ export const ADD_TO_CRATE: TypedDocumentNode = g
}
}
}
-`;
+`);
+
+export const REMOVE_FROM_CRATE = graphql(`
+ mutation RemoveFromCrate($albumId: ID!) {
+ removeFromCrate(albumId: $albumId) {
+ removedAlbumId
+ album {
+ id
+ isInCrate
+ }
+ }
+ }
+`);
-export const REMOVE_FROM_CRATE: TypedDocumentNode =
- gql`
- mutation RemoveFromCrate($albumId: ID!) {
- removeFromCrate(albumId: $albumId) {
- removedAlbumId
- album {
+// The page shows enough of each album to reuse the search result card, and
+// orders by when it was saved, which is the index the table was designed with.
+export const MY_CRATE = graphql(`
+ query MyCrate($first: Int!, $after: String) {
+ myCrate(first: $first, after: $after) {
+ edges {
+ cursor
+ node {
id
- isInCrate
+ addedAt
+ album {
+ id
+ title
+ releaseYear
+ averageRating
+ reviewCount
+ isInCrate
+ artists {
+ id
+ name
+ }
+ genres {
+ id
+ name
+ }
+ }
}
}
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ totalCount
}
- `;
+ }
+`);
diff --git a/client/src/api/reviews.ts b/client/src/api/reviews.ts
index 6782bff..10a2f64 100644
--- a/client/src/api/reviews.ts
+++ b/client/src/api/reviews.ts
@@ -1,20 +1,17 @@
-import { gql } from '@apollo/client';
-import type { TypedDocumentNode } from '@apollo/client';
+import { graphql } from '../generated/gql.js';
+import type {
+ DeleteReviewMutation,
+ DeleteReviewMutationVariables,
+ UpsertReviewMutation,
+ UpsertReviewMutationVariables,
+} from '../generated/graphql.js';
-type ReviewPayload = {
- review: { id: string; rating: number; comment: string };
- album: { id: string };
-};
+export type UpsertReviewData = UpsertReviewMutation;
+export type UpsertReviewVariables = UpsertReviewMutationVariables;
+export type DeleteReviewData = DeleteReviewMutation;
+export type DeleteReviewVariables = DeleteReviewMutationVariables;
-type DeleteReviewPayload = {
- deletedReviewId: string;
- album: { id: string };
-};
-
-export const UPSERT_REVIEW: TypedDocumentNode<
- { upsertReview: ReviewPayload },
- { input: { albumId: string; rating: number; comment: string } }
-> = gql`
+export const UPSERT_REVIEW = graphql(`
mutation UpsertReview($input: UpsertReviewInput!) {
upsertReview(input: $input) {
review {
@@ -27,12 +24,9 @@ export const UPSERT_REVIEW: TypedDocumentNode<
}
}
}
-`;
+`);
-export const DELETE_REVIEW: TypedDocumentNode<
- { deleteReview: DeleteReviewPayload },
- { id: string }
-> = gql`
+export const DELETE_REVIEW = graphql(`
mutation DeleteReview($id: ID!) {
deleteReview(id: $id) {
deletedReviewId
@@ -41,4 +35,4 @@ export const DELETE_REVIEW: TypedDocumentNode<
}
}
}
-`;
+`);
diff --git a/client/src/api/searchAlbums.test.ts b/client/src/api/searchAlbums.test.ts
new file mode 100644
index 0000000..db624b2
--- /dev/null
+++ b/client/src/api/searchAlbums.test.ts
@@ -0,0 +1,24 @@
+import schemaSource from '../../../schema.graphql?raw';
+import { buildSchema, isEnumType } from 'graphql';
+import { describe, expect, it } from 'vitest';
+
+import { ALBUM_SORTS } from './searchAlbums.js';
+
+describe('generated schema types', () => {
+ // `ALBUM_SORTS` is the runtime list the URL parser accepts, so it cannot come
+ // from the generated types alone: a value missing from the array would still
+ // typecheck. This compares it with the schema the server actually serves.
+ it('lists exactly the AlbumSort values from the schema', () => {
+ const albumSort = buildSchema(schemaSource).getType('AlbumSort');
+
+ expect(isEnumType(albumSort)).toBe(true);
+ if (!isEnumType(albumSort)) return;
+
+ expect([...ALBUM_SORTS].sort()).toEqual(
+ albumSort
+ .getValues()
+ .map((value) => value.name)
+ .sort(),
+ );
+ });
+});
diff --git a/client/src/api/searchAlbums.ts b/client/src/api/searchAlbums.ts
index 29775ad..cb95e21 100644
--- a/client/src/api/searchAlbums.ts
+++ b/client/src/api/searchAlbums.ts
@@ -1,8 +1,16 @@
-import { gql } from '@apollo/client';
-import type { TypedDocumentNode } from '@apollo/client';
+import { graphql } from '../generated/gql.js';
+import type {
+ AlbumSearchInput,
+ AlbumSort,
+ SearchAlbumsQuery,
+ SearchAlbumsQueryVariables,
+} from '../generated/graphql.js';
// The runtime list and the type come from one place, so URL parsing in
-// useSearchState cannot drift away from what the schema accepts.
+// useSearchState cannot drift away from what the schema accepts. The entries are
+// checked against the generated `AlbumSort` union here, and
+// `searchAlbums.test.ts` checks that same list against the schema enum at run
+// time - the type alone would not catch a value missing from the array.
export const ALBUM_SORTS = [
'RELEVANCE',
'TITLE_ASC',
@@ -11,100 +19,59 @@ export const ALBUM_SORTS = [
'RELEASE_YEAR_DESC',
'AVERAGE_RATING_ASC',
'AVERAGE_RATING_DESC',
-] as const;
+] as const satisfies readonly AlbumSort[];
-export type AlbumSort = (typeof ALBUM_SORTS)[number];
+export type { AlbumSearchInput, AlbumSort };
-export interface AlbumSearchInput {
- query: string;
- genreIds?: string[] | null;
- decades?: number[] | null;
- hasReviews?: boolean | null;
- sort: AlbumSort;
-}
+// Everything below is derived from the operation, so a renamed field breaks the
+// build here instead of producing a component that renders `undefined`.
+export type AlbumConnection = SearchAlbumsQuery['searchAlbums'];
+export type AlbumSummary = AlbumConnection['edges'][number]['node'];
+export type GenreFacet = AlbumConnection['genreFacets'][number];
+export type DecadeFacet = AlbumConnection['decadeFacets'][number];
+export type SearchAlbumsData = SearchAlbumsQuery;
+export type SearchAlbumsVariables = SearchAlbumsQueryVariables;
-export interface AlbumSummary {
- id: string;
- title: string;
- releaseYear: number | null;
- artists: { id: string; name: string }[];
- genres: { id: string; name: string }[];
- averageRating: number | null;
- reviewCount: number;
- isInCrate: boolean;
-}
-
-export interface AlbumConnection {
- edges: { cursor: string; node: AlbumSummary }[];
- pageInfo: { hasNextPage: boolean; endCursor: string | null };
- totalCount: number;
- genreFacets: GenreFacet[];
- decadeFacets: DecadeFacet[];
- reviewedCount: number;
-}
-
-export interface GenreFacet {
- genre: { id: string; name: string };
- count: number;
-}
-
-export interface DecadeFacet {
- decade: number;
- count: number;
-}
-
-export interface SearchAlbumsData {
- searchAlbums: AlbumConnection;
-}
-
-export interface SearchAlbumsVariables {
- input: AlbumSearchInput;
- first: number;
- after?: string | null;
-}
-
-// gql parses the operation; TypedDocumentNode describes its variables and selected result fields.
// Only the search-page projection is requested; details belong to the album page.
-export const SEARCH_ALBUMS: TypedDocumentNode =
- gql`
- query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {
- searchAlbums(input: $input, first: $first, after: $after) {
- edges {
- cursor
- node {
+export const SEARCH_ALBUMS = graphql(`
+ query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {
+ searchAlbums(input: $input, first: $first, after: $after) {
+ edges {
+ cursor
+ node {
+ id
+ title
+ releaseYear
+ artists {
id
- title
- releaseYear
- artists {
- id
- name
- }
- genres {
- id
- name
- }
- averageRating
- reviewCount
- isInCrate
+ name
}
- }
- totalCount
- genreFacets {
- genre {
+ genres {
id
name
}
- count
- }
- decadeFacets {
- decade
- count
+ averageRating
+ reviewCount
+ isInCrate
}
- reviewedCount
- pageInfo {
- hasNextPage
- endCursor
+ }
+ totalCount
+ genreFacets {
+ genre {
+ id
+ name
}
+ count
+ }
+ decadeFacets {
+ decade
+ count
+ }
+ reviewedCount
+ pageInfo {
+ hasNextPage
+ endCursor
}
}
- `;
+ }
+`);
diff --git a/client/src/components/CrateButton.tsx b/client/src/components/CrateButton.tsx
index f7dab73..b259dfa 100644
--- a/client/src/components/CrateButton.tsx
+++ b/client/src/components/CrateButton.tsx
@@ -15,6 +15,9 @@ export default function CrateButton({ albumId, isInCrate }: Props) {
if (!savedAlbum) return;
const cacheId = cache.identify({ __typename: 'Album', id: savedAlbum.id });
if (cacheId) cache.modify({ id: cacheId, fields: { isInCrate: () => true } });
+ // The crate page caches its own list. Drop it so the next visit refetches
+ // instead of showing a list that no longer matches the album's state.
+ cache.evict({ id: 'ROOT_QUERY', fieldName: 'myCrate' });
},
});
const [removeFromCrate, removeState] = useMutation(REMOVE_FROM_CRATE, {
@@ -23,6 +26,7 @@ export default function CrateButton({ albumId, isInCrate }: Props) {
if (!savedAlbum) return;
const cacheId = cache.identify({ __typename: 'Album', id: savedAlbum.id });
if (cacheId) cache.modify({ id: cacheId, fields: { isInCrate: () => false } });
+ cache.evict({ id: 'ROOT_QUERY', fieldName: 'myCrate' });
},
});
const busy = addState.loading || removeState.loading;
diff --git a/client/src/generated/fragment-masking.ts b/client/src/generated/fragment-masking.ts
new file mode 100644
index 0000000..89cbc3c
--- /dev/null
+++ b/client/src/generated/fragment-masking.ts
@@ -0,0 +1,87 @@
+/* eslint-disable */
+import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core';
+import { FragmentDefinitionNode } from 'graphql';
+import { Incremental } from './graphql.js';
+
+
+export type FragmentType> = TDocumentType extends DocumentTypeDecoration<
+ infer TType,
+ any
+>
+ ? [TType] extends [{ ' $fragmentName'?: infer TKey }]
+ ? TKey extends string
+ ? { ' $fragmentRefs'?: { [key in TKey]: TType } }
+ : never
+ : never
+ : never;
+
+// return non-nullable if `fragmentType` is non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType>
+): TType;
+// return nullable if `fragmentType` is undefined
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | undefined
+): TType | undefined;
+// return nullable if `fragmentType` is nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | null
+): TType | null;
+// return nullable if `fragmentType` is nullable or undefined
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | null | undefined
+): TType | null | undefined;
+// return array of non-nullable if `fragmentType` is array of non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: Array>>
+): Array;
+// return array of nullable if `fragmentType` is array of nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: Array>> | null | undefined
+): Array | null | undefined;
+// return readonly array of non-nullable if `fragmentType` is array of non-nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: ReadonlyArray>>
+): ReadonlyArray;
+// return readonly array of nullable if `fragmentType` is array of nullable
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: ReadonlyArray>> | null | undefined
+): ReadonlyArray | null | undefined;
+export function useFragment(
+ _documentNode: DocumentTypeDecoration,
+ fragmentType: FragmentType> | Array>> | ReadonlyArray>> | null | undefined
+): TType | Array | ReadonlyArray | null | undefined {
+ return fragmentType as any;
+}
+
+
+export function makeFragmentData<
+ F extends DocumentTypeDecoration,
+ FT extends ResultOf
+>(data: FT, _fragment: F): FragmentType {
+ return data as FragmentType;
+}
+export function isFragmentReady(
+ queryNode: DocumentTypeDecoration,
+ fragmentNode: TypedDocumentNode,
+ data: FragmentType, any>> | null | undefined
+): data is FragmentType {
+ const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__
+ ?.deferredFields;
+
+ if (!deferredFields) return true;
+
+ const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined;
+ const fragName = fragDef?.name?.value;
+
+ const fields = (fragName && deferredFields[fragName]) || [];
+ return fields.length > 0 && fields.every(field => data && field in data);
+}
diff --git a/client/src/generated/gql.ts b/client/src/generated/gql.ts
new file mode 100644
index 0000000..a38e7f0
--- /dev/null
+++ b/client/src/generated/gql.ts
@@ -0,0 +1,82 @@
+/* eslint-disable */
+import * as types from './graphql.js';
+import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+
+/**
+ * Map of all GraphQL operations in the project.
+ *
+ * This map has several performance disadvantages:
+ * 1. It is not tree-shakeable, so it will include all operations in the project.
+ * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle.
+ * 3. It does not support dead code elimination, so it will add unused operations.
+ *
+ * Therefore it is highly recommended to use the babel or swc plugin for production.
+ * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size
+ */
+type Documents = {
+ "\n query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {\n album(id: $id) {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n tracks {\n id\n title\n trackNumber\n durationSeconds\n }\n averageRating\n reviewCount\n isInCrate\n myReview {\n id\n rating\n comment\n }\n reviews(first: $reviewFirst, after: $reviewAfter) {\n edges {\n cursor\n node {\n id\n rating\n comment\n createdAt\n user {\n id\n displayName\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n": typeof types.AlbumDetailDocument,
+ "\n mutation AddToCrate($albumId: ID!) {\n addToCrate(albumId: $albumId) {\n item {\n id\n }\n album {\n id\n isInCrate\n }\n }\n }\n": typeof types.AddToCrateDocument,
+ "\n mutation RemoveFromCrate($albumId: ID!) {\n removeFromCrate(albumId: $albumId) {\n removedAlbumId\n album {\n id\n isInCrate\n }\n }\n }\n": typeof types.RemoveFromCrateDocument,
+ "\n query MyCrate($first: Int!, $after: String) {\n myCrate(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n addedAt\n album {\n id\n title\n releaseYear\n averageRating\n reviewCount\n isInCrate\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n }\n": typeof types.MyCrateDocument,
+ "\n mutation UpsertReview($input: UpsertReviewInput!) {\n upsertReview(input: $input) {\n review {\n id\n rating\n comment\n }\n album {\n id\n }\n }\n }\n": typeof types.UpsertReviewDocument,
+ "\n mutation DeleteReview($id: ID!) {\n deleteReview(id: $id) {\n deletedReviewId\n album {\n id\n }\n }\n }\n": typeof types.DeleteReviewDocument,
+ "\n query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {\n searchAlbums(input: $input, first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n averageRating\n reviewCount\n isInCrate\n }\n }\n totalCount\n genreFacets {\n genre {\n id\n name\n }\n count\n }\n decadeFacets {\n decade\n count\n }\n reviewedCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": typeof types.SearchAlbumsDocument,
+};
+const documents: Documents = {
+ "\n query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {\n album(id: $id) {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n tracks {\n id\n title\n trackNumber\n durationSeconds\n }\n averageRating\n reviewCount\n isInCrate\n myReview {\n id\n rating\n comment\n }\n reviews(first: $reviewFirst, after: $reviewAfter) {\n edges {\n cursor\n node {\n id\n rating\n comment\n createdAt\n user {\n id\n displayName\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n": types.AlbumDetailDocument,
+ "\n mutation AddToCrate($albumId: ID!) {\n addToCrate(albumId: $albumId) {\n item {\n id\n }\n album {\n id\n isInCrate\n }\n }\n }\n": types.AddToCrateDocument,
+ "\n mutation RemoveFromCrate($albumId: ID!) {\n removeFromCrate(albumId: $albumId) {\n removedAlbumId\n album {\n id\n isInCrate\n }\n }\n }\n": types.RemoveFromCrateDocument,
+ "\n query MyCrate($first: Int!, $after: String) {\n myCrate(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n addedAt\n album {\n id\n title\n releaseYear\n averageRating\n reviewCount\n isInCrate\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n }\n": types.MyCrateDocument,
+ "\n mutation UpsertReview($input: UpsertReviewInput!) {\n upsertReview(input: $input) {\n review {\n id\n rating\n comment\n }\n album {\n id\n }\n }\n }\n": types.UpsertReviewDocument,
+ "\n mutation DeleteReview($id: ID!) {\n deleteReview(id: $id) {\n deletedReviewId\n album {\n id\n }\n }\n }\n": types.DeleteReviewDocument,
+ "\n query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {\n searchAlbums(input: $input, first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n averageRating\n reviewCount\n isInCrate\n }\n }\n totalCount\n genreFacets {\n genre {\n id\n name\n }\n count\n }\n decadeFacets {\n decade\n count\n }\n reviewedCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n": types.SearchAlbumsDocument,
+};
+
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ *
+ *
+ * @example
+ * ```ts
+ * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`);
+ * ```
+ *
+ * The query argument is unknown!
+ * Please regenerate the types.
+ */
+export function graphql(source: string): unknown;
+
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {\n album(id: $id) {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n tracks {\n id\n title\n trackNumber\n durationSeconds\n }\n averageRating\n reviewCount\n isInCrate\n myReview {\n id\n rating\n comment\n }\n reviews(first: $reviewFirst, after: $reviewAfter) {\n edges {\n cursor\n node {\n id\n rating\n comment\n createdAt\n user {\n id\n displayName\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n"): (typeof documents)["\n query AlbumDetail($id: ID!, $reviewFirst: Int!, $reviewAfter: String) {\n album(id: $id) {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n tracks {\n id\n title\n trackNumber\n durationSeconds\n }\n averageRating\n reviewCount\n isInCrate\n myReview {\n id\n rating\n comment\n }\n reviews(first: $reviewFirst, after: $reviewAfter) {\n edges {\n cursor\n node {\n id\n rating\n comment\n createdAt\n user {\n id\n displayName\n }\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation AddToCrate($albumId: ID!) {\n addToCrate(albumId: $albumId) {\n item {\n id\n }\n album {\n id\n isInCrate\n }\n }\n }\n"): (typeof documents)["\n mutation AddToCrate($albumId: ID!) {\n addToCrate(albumId: $albumId) {\n item {\n id\n }\n album {\n id\n isInCrate\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation RemoveFromCrate($albumId: ID!) {\n removeFromCrate(albumId: $albumId) {\n removedAlbumId\n album {\n id\n isInCrate\n }\n }\n }\n"): (typeof documents)["\n mutation RemoveFromCrate($albumId: ID!) {\n removeFromCrate(albumId: $albumId) {\n removedAlbumId\n album {\n id\n isInCrate\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query MyCrate($first: Int!, $after: String) {\n myCrate(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n addedAt\n album {\n id\n title\n releaseYear\n averageRating\n reviewCount\n isInCrate\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n }\n"): (typeof documents)["\n query MyCrate($first: Int!, $after: String) {\n myCrate(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n addedAt\n album {\n id\n title\n releaseYear\n averageRating\n reviewCount\n isInCrate\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n totalCount\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation UpsertReview($input: UpsertReviewInput!) {\n upsertReview(input: $input) {\n review {\n id\n rating\n comment\n }\n album {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertReview($input: UpsertReviewInput!) {\n upsertReview(input: $input) {\n review {\n id\n rating\n comment\n }\n album {\n id\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation DeleteReview($id: ID!) {\n deleteReview(id: $id) {\n deletedReviewId\n album {\n id\n }\n }\n }\n"): (typeof documents)["\n mutation DeleteReview($id: ID!) {\n deleteReview(id: $id) {\n deletedReviewId\n album {\n id\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {\n searchAlbums(input: $input, first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n averageRating\n reviewCount\n isInCrate\n }\n }\n totalCount\n genreFacets {\n genre {\n id\n name\n }\n count\n }\n decadeFacets {\n decade\n count\n }\n reviewedCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query SearchAlbums($input: AlbumSearchInput!, $first: Int!, $after: String) {\n searchAlbums(input: $input, first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n title\n releaseYear\n artists {\n id\n name\n }\n genres {\n id\n name\n }\n averageRating\n reviewCount\n isInCrate\n }\n }\n totalCount\n genreFacets {\n genre {\n id\n name\n }\n count\n }\n decadeFacets {\n decade\n count\n }\n reviewedCount\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n"];
+
+export function graphql(source: string) {
+ return (documents as any)[source] ?? {};
+}
+
+export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never;
\ No newline at end of file
diff --git a/client/src/generated/graphql.ts b/client/src/generated/graphql.ts
new file mode 100644
index 0000000..0dc165e
--- /dev/null
+++ b/client/src/generated/graphql.ts
@@ -0,0 +1,91 @@
+/* eslint-disable */
+/** Internal type. DO NOT USE DIRECTLY. */
+type Exact = { [K in keyof T]: T[K] };
+/** Internal type. DO NOT USE DIRECTLY. */
+export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
+import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+export type AlbumSearchInput = {
+ decades?: Array | null | undefined;
+ genreIds?: Array | null | undefined;
+ hasReviews?: boolean | null | undefined;
+ query: string;
+ sort: AlbumSort;
+};
+
+export type AlbumSort =
+ | 'AVERAGE_RATING_ASC'
+ | 'AVERAGE_RATING_DESC'
+ | 'RELEASE_YEAR_ASC'
+ | 'RELEASE_YEAR_DESC'
+ | 'RELEVANCE'
+ | 'TITLE_ASC'
+ | 'TITLE_DESC';
+
+export type UpsertReviewInput = {
+ albumId: string | number;
+ comment: string;
+ rating: number;
+};
+
+export type AlbumDetailQueryVariables = Exact<{
+ id: string | number;
+ reviewFirst: number;
+ reviewAfter?: string | null | undefined;
+}>;
+
+
+export type AlbumDetailQuery = { album: { id: string, title: string, releaseYear: number | null, averageRating: number | null, reviewCount: number, isInCrate: boolean, artists: Array<{ id: string, name: string }>, genres: Array<{ id: string, name: string }>, tracks: Array<{ id: string, title: string, trackNumber: number, durationSeconds: number | null }>, myReview: { id: string, rating: number, comment: string } | null, reviews: { totalCount: number, edges: Array<{ cursor: string, node: { id: string, rating: number, comment: string, createdAt: string, user: { id: string, displayName: string } } }>, pageInfo: { hasNextPage: boolean, endCursor: string | null } } } | null };
+
+export type AddToCrateMutationVariables = Exact<{
+ albumId: string | number;
+}>;
+
+
+export type AddToCrateMutation = { addToCrate: { item: { id: string }, album: { id: string, isInCrate: boolean } } };
+
+export type RemoveFromCrateMutationVariables = Exact<{
+ albumId: string | number;
+}>;
+
+
+export type RemoveFromCrateMutation = { removeFromCrate: { removedAlbumId: string, album: { id: string, isInCrate: boolean } } };
+
+export type MyCrateQueryVariables = Exact<{
+ first: number;
+ after?: string | null | undefined;
+}>;
+
+
+export type MyCrateQuery = { myCrate: { totalCount: number, edges: Array<{ cursor: string, node: { id: string, addedAt: string, album: { id: string, title: string, releaseYear: number | null, averageRating: number | null, reviewCount: number, isInCrate: boolean, artists: Array<{ id: string, name: string }>, genres: Array<{ id: string, name: string }> } } }>, pageInfo: { hasNextPage: boolean, endCursor: string | null } } };
+
+export type UpsertReviewMutationVariables = Exact<{
+ input: UpsertReviewInput;
+}>;
+
+
+export type UpsertReviewMutation = { upsertReview: { review: { id: string, rating: number, comment: string }, album: { id: string } } };
+
+export type DeleteReviewMutationVariables = Exact<{
+ id: string | number;
+}>;
+
+
+export type DeleteReviewMutation = { deleteReview: { deletedReviewId: string, album: { id: string } } };
+
+export type SearchAlbumsQueryVariables = Exact<{
+ input: AlbumSearchInput;
+ first: number;
+ after?: string | null | undefined;
+}>;
+
+
+export type SearchAlbumsQuery = { searchAlbums: { totalCount: number, reviewedCount: number, edges: Array<{ cursor: string, node: { id: string, title: string, releaseYear: number | null, averageRating: number | null, reviewCount: number, isInCrate: boolean, artists: Array<{ id: string, name: string }>, genres: Array<{ id: string, name: string }> } }>, genreFacets: Array<{ count: number, genre: { id: string, name: string } }>, decadeFacets: Array<{ decade: number, count: number }>, pageInfo: { hasNextPage: boolean, endCursor: string | null } } };
+
+
+export const AlbumDetailDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"AlbumDetail"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"reviewFirst"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"reviewAfter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"album"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"releaseYear"}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"genres"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tracks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"trackNumber"}},{"kind":"Field","name":{"kind":"Name","value":"durationSeconds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"averageRating"}},{"kind":"Field","name":{"kind":"Name","value":"reviewCount"}},{"kind":"Field","name":{"kind":"Name","value":"isInCrate"}},{"kind":"Field","name":{"kind":"Name","value":"myReview"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reviews"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"reviewFirst"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"reviewAfter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
+export const AddToCrateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToCrate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"albumId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToCrate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"albumId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"albumId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"item"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"album"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isInCrate"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const RemoveFromCrateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromCrate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"albumId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromCrate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"albumId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"albumId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removedAlbumId"}},{"kind":"Field","name":{"kind":"Name","value":"album"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isInCrate"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const MyCrateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCrate"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCrate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"addedAt"}},{"kind":"Field","name":{"kind":"Name","value":"album"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"releaseYear"}},{"kind":"Field","name":{"kind":"Name","value":"averageRating"}},{"kind":"Field","name":{"kind":"Name","value":"reviewCount"}},{"kind":"Field","name":{"kind":"Name","value":"isInCrate"}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"genres"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}}]}}]}}]} as unknown as DocumentNode;
+export const UpsertReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertReviewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertReview"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"review"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"album"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const DeleteReviewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReview"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReview"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletedReviewId"}},{"kind":"Field","name":{"kind":"Name","value":"album"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const SearchAlbumsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SearchAlbums"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AlbumSearchInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"searchAlbums"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"releaseYear"}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"genres"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"averageRating"}},{"kind":"Field","name":{"kind":"Name","value":"reviewCount"}},{"kind":"Field","name":{"kind":"Name","value":"isInCrate"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"genreFacets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"genre"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"decadeFacets"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"decade"}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","name":{"kind":"Name","value":"reviewedCount"}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode;
\ No newline at end of file
diff --git a/client/src/generated/index.ts b/client/src/generated/index.ts
new file mode 100644
index 0000000..3117fef
--- /dev/null
+++ b/client/src/generated/index.ts
@@ -0,0 +1,3 @@
+/* eslint-disable */
+export * from "./fragment-masking.js";
+export * from "./gql.js";
\ No newline at end of file
diff --git a/client/src/pages/CratePage.css b/client/src/pages/CratePage.css
new file mode 100644
index 0000000..534a60e
--- /dev/null
+++ b/client/src/pages/CratePage.css
@@ -0,0 +1,25 @@
+/* The crate page reuses the search page's album grid; only the intro spacing
+ is its own. Kept separate so the grid stays one definition. */
+.crate-page {
+ max-width: 72rem;
+ margin: 0 auto;
+ padding: 2rem 1.25rem 4rem;
+}
+
+.crate-page h1 {
+ margin: 0.25rem 0 1rem;
+}
+
+.crate-page > .album-grid {
+ margin-top: 1.5rem;
+}
+
+.crate-page .empty-state {
+ margin-top: 1.5rem;
+}
+
+@media (max-width: 40rem) {
+ .crate-page {
+ padding: 1.25rem 1rem 3rem;
+ }
+}
diff --git a/client/src/pages/CratePage.test.tsx b/client/src/pages/CratePage.test.tsx
new file mode 100644
index 0000000..facd432
--- /dev/null
+++ b/client/src/pages/CratePage.test.tsx
@@ -0,0 +1,86 @@
+import { MockedProvider } from '@apollo/client/testing/react';
+import { render, screen } from '@testing-library/react';
+import axe from 'axe-core';
+import { describe, expect, it } from 'vitest';
+
+import { MY_CRATE } from '../api/crate.js';
+import CratePage from './CratePage.js';
+
+const album = {
+ __typename: 'Album' as const,
+ id: '1',
+ title: 'Abbey Road',
+ releaseYear: 1969,
+ averageRating: 4.8,
+ reviewCount: 1240,
+ isInCrate: true,
+ artists: [{ __typename: 'Artist' as const, id: '1', name: 'The Beatles' }],
+ genres: [{ __typename: 'Genre' as const, id: '1', name: 'Rock' }],
+};
+
+const item = {
+ __typename: 'CrateItem' as const,
+ id: '10',
+ addedAt: '2026-11-01T12:00:00.000Z',
+ album,
+};
+
+function mockCrate(items: (typeof item)[]) {
+ return {
+ request: { query: MY_CRATE, variables: { first: 20, after: null } },
+ result: {
+ data: {
+ myCrate: {
+ edges: items.map((node, index) => ({ cursor: `cursor-${index}`, node })),
+ pageInfo: { hasNextPage: false, endCursor: null },
+ totalCount: items.length,
+ },
+ },
+ },
+ };
+}
+
+function renderCrate(items: (typeof item)[]) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('CratePage', () => {
+ it('lists the saved albums and how many there are', async () => {
+ renderCrate([item]);
+
+ expect(
+ await screen.findByRole('heading', { name: 'Abbey Road' }),
+ ).toBeInTheDocument();
+ expect(screen.getByText('Showing 1 of 1 albums in your crate.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Remove from crate' })).toBeInTheDocument();
+ });
+
+ it('offers a way back to the catalog when the crate is empty', async () => {
+ renderCrate([]);
+
+ expect(await screen.findByText('Nothing saved yet')).toBeInTheDocument();
+ expect(screen.getByText('Your crate is empty.')).toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Discover albums' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
+ });
+
+ it('has no accessibility violations', async () => {
+ const { container } = renderCrate([item]);
+ await screen.findByRole('heading', { name: 'Abbey Road' });
+
+ const results = await axe.run(container, {
+ // jsdom paints nothing, so contrast cannot be measured here. The manual
+ // check in #28 covers it in a real browser.
+ rules: { 'color-contrast': { enabled: false } },
+ });
+
+ // A tree axe could not examine would also be "clean"; require that it
+ // actually checked something.
+ expect(results.passes.length).toBeGreaterThan(0);
+ expect(results.violations.map((violation) => violation.id)).toEqual([]);
+ });
+});
diff --git a/client/src/pages/CratePage.tsx b/client/src/pages/CratePage.tsx
new file mode 100644
index 0000000..ebcbf87
--- /dev/null
+++ b/client/src/pages/CratePage.tsx
@@ -0,0 +1,110 @@
+import './CratePage.css';
+
+import { useState } from 'react';
+import { useQuery } from '@apollo/client/react';
+
+import { MY_CRATE } from '../api/crate.js';
+import AlbumCard from '../components/AlbumCard.js';
+
+const PAGE_SIZE = 20;
+
+export default function CratePage() {
+ const discoveryHref = import.meta.env.BASE_URL;
+ const [loadingMore, setLoadingMore] = useState(false);
+ const { data, loading, error, fetchMore, refetch } = useQuery(MY_CRATE, {
+ variables: { first: PAGE_SIZE, after: null },
+ });
+
+ const crate = data?.myCrate;
+ const albums = crate?.edges.map((edge) => edge.node.album) ?? [];
+
+ async function loadMore() {
+ const after = crate?.pageInfo.endCursor;
+ if (!after) return;
+
+ setLoadingMore(true);
+ try {
+ await fetchMore({ variables: { first: PAGE_SIZE, after } });
+ } finally {
+ setLoadingMore(false);
+ }
+ }
+
+ return (
+
+
+
Saved for later
+
Your crate
+
+
+ {loading && !crate
+ ? 'Loading your crate…'
+ : loadingMore
+ ? 'Loading more albums…'
+ : error
+ ? 'Could not load your crate.'
+ : crate && crate.totalCount > 0
+ ? `Showing ${albums.length} of ${crate.totalCount} albums in your crate.`
+ : crate
+ ? 'Your crate is empty.'
+ : ''}
+
+ )}
+
+ {albums.length > 0 && (
+
+ {/* An h2 here: the album cards start at h3, and jumping from the
+ page's h1 to an h3 is a heading-order violation. */}
+