From 7acf20b279cc8d90058af663d078fd80dabc3f96 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:20:56 +0300 Subject: [PATCH 1/7] feat(music+books): add backend modules, infrastructure, and DB schema New music (MusicBrainz) and books (Google Books) domains with full CRUD: entity definitions, Drizzle migration, archive/detail/log/ interaction repositories and services, controllers, and routes. Registers both routers in the Express app. Adds --module-music and --module-book CSS vars for consistent theming. --- apps/api/bun.lock | 21 + apps/api/drizzle/0031_cynical_vermin.sql | 87 + apps/api/drizzle/meta/0031_snapshot.json | 2945 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/package.json | 1 + apps/api/src/index.ts | 4 + .../src/infrastructure/database/entities.ts | 4 +- .../src/infrastructure/googlebooks/books.ts | 72 + .../src/infrastructure/googlebooks/client.ts | 22 + .../src/infrastructure/musicbrainz/albums.ts | 51 + .../src/infrastructure/musicbrainz/client.ts | 11 + .../api/src/modules/books/books.controller.ts | 168 + apps/api/src/modules/books/books.entity.ts | 71 + apps/api/src/modules/books/books.routes.ts | 20 + apps/api/src/modules/books/books.service.ts | 124 + .../books/constants/books.constants.ts | 6 + apps/api/src/modules/books/dto/books.dto.ts | 121 + .../repositories/books-archive.repository.ts | 106 + .../repositories/books-cache.repository.ts | 52 + .../books-interactions.repository.ts | 167 + .../books/services/books-archive.service.ts | 58 + .../books/services/books-cache.service.ts | 37 + .../books/services/books-detail.service.ts | 163 + .../books/services/books-seed.service.ts | 59 + .../src/modules/books/types/books.types.ts | 126 + .../music/constants/music.constants.ts | 6 + apps/api/src/modules/music/dto/music.dto.ts | 121 + .../api/src/modules/music/music.controller.ts | 168 + apps/api/src/modules/music/music.entity.ts | 68 + apps/api/src/modules/music/music.routes.ts | 20 + apps/api/src/modules/music/music.service.ts | 123 + .../repositories/music-archive.repository.ts | 113 + .../repositories/music-cache.repository.ts | 59 + .../music-interactions.repository.ts | 176 + .../music/services/music-archive.service.ts | 57 + .../music/services/music-cache.service.ts | 34 + .../music/services/music-detail.service.ts | 160 + .../music/services/music-seed.service.ts | 106 + .../src/modules/music/types/music.types.ts | 123 + apps/web/src/index.css | 6 + 40 files changed, 5842 insertions(+), 1 deletion(-) create mode 100644 apps/api/drizzle/0031_cynical_vermin.sql create mode 100644 apps/api/drizzle/meta/0031_snapshot.json create mode 100644 apps/api/src/infrastructure/googlebooks/books.ts create mode 100644 apps/api/src/infrastructure/googlebooks/client.ts create mode 100644 apps/api/src/infrastructure/musicbrainz/albums.ts create mode 100644 apps/api/src/infrastructure/musicbrainz/client.ts create mode 100644 apps/api/src/modules/books/books.controller.ts create mode 100644 apps/api/src/modules/books/books.entity.ts create mode 100644 apps/api/src/modules/books/books.routes.ts create mode 100644 apps/api/src/modules/books/books.service.ts create mode 100644 apps/api/src/modules/books/constants/books.constants.ts create mode 100644 apps/api/src/modules/books/dto/books.dto.ts create mode 100644 apps/api/src/modules/books/repositories/books-archive.repository.ts create mode 100644 apps/api/src/modules/books/repositories/books-cache.repository.ts create mode 100644 apps/api/src/modules/books/repositories/books-interactions.repository.ts create mode 100644 apps/api/src/modules/books/services/books-archive.service.ts create mode 100644 apps/api/src/modules/books/services/books-cache.service.ts create mode 100644 apps/api/src/modules/books/services/books-detail.service.ts create mode 100644 apps/api/src/modules/books/services/books-seed.service.ts create mode 100644 apps/api/src/modules/books/types/books.types.ts create mode 100644 apps/api/src/modules/music/constants/music.constants.ts create mode 100644 apps/api/src/modules/music/dto/music.dto.ts create mode 100644 apps/api/src/modules/music/music.controller.ts create mode 100644 apps/api/src/modules/music/music.entity.ts create mode 100644 apps/api/src/modules/music/music.routes.ts create mode 100644 apps/api/src/modules/music/music.service.ts create mode 100644 apps/api/src/modules/music/repositories/music-archive.repository.ts create mode 100644 apps/api/src/modules/music/repositories/music-cache.repository.ts create mode 100644 apps/api/src/modules/music/repositories/music-interactions.repository.ts create mode 100644 apps/api/src/modules/music/services/music-archive.service.ts create mode 100644 apps/api/src/modules/music/services/music-cache.service.ts create mode 100644 apps/api/src/modules/music/services/music-detail.service.ts create mode 100644 apps/api/src/modules/music/services/music-seed.service.ts create mode 100644 apps/api/src/modules/music/types/music.types.ts diff --git a/apps/api/bun.lock b/apps/api/bun.lock index 751bc48..7bc1cb5 100644 --- a/apps/api/bun.lock +++ b/apps/api/bun.lock @@ -15,6 +15,7 @@ "drizzle-orm": "^0.45.1", "express": "^5.2.1", "express-rate-limit": "^8.3.1", + "musicbrainz-api": "^1.2.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "zod": "^4.3.6", @@ -438,6 +439,8 @@ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -452,6 +455,10 @@ "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="], + + "jsontoxml": ["jsontoxml@1.0.1", "", {}, "sha512-dtKGq0K8EWQBRqcAaePSgKR4Hyjfsz/LkurHSV3Cxk4H+h2fWDeaN2jzABz+ZmOJylgXS7FGeWmbZ6jgYUMdJQ=="], + "kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -468,6 +475,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "musicbrainz-api": ["musicbrainz-api@1.2.1", "", { "dependencies": { "debug": "^4.3.4", "http-status-codes": "^2.1.4", "json-stringify-safe": "^5.0.1", "jsontoxml": "^1.0.1", "rate-limit-threshold": "^0.2.0", "spark-md5": "^3.0.2", "tough-cookie": "^5.0.0", "uuid": "^14.0.0" } }, "sha512-5ra5nkiv6be1lcIz7Skz7lFtlEK/1dBb4JXz5T6m42faermxLR39buqIxSBXrB2K2oY0qyNVMu8f1AdSZubUdw=="], + "nanostores": ["nanostores@1.1.1", "", {}, "sha512-EYJqS25r2iBeTtGQCHidXl1VfZ1jXM7Q04zXJOrMlxVVmD0ptxJaNux92n1mJ7c5lN3zTq12MhH/8x59nP+qmg=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], @@ -524,6 +533,8 @@ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "rate-limit-threshold": ["rate-limit-threshold@0.2.1", "", {}, "sha512-WWn+uXLg0KMO/M9TaA4JzIAwvQ0aE87PM0pO4TVvCeHb3kXe82rOXLuUfIS9OZdxbYIca7quInuReAr27LMktQ=="], + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], @@ -562,6 +573,8 @@ "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + "spark-md5": ["spark-md5@3.0.2", "", {}, "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="], + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -572,8 +585,14 @@ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="], + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -584,6 +603,8 @@ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/apps/api/drizzle/0031_cynical_vermin.sql b/apps/api/drizzle/0031_cynical_vermin.sql new file mode 100644 index 0000000..86df300 --- /dev/null +++ b/apps/api/drizzle/0031_cynical_vermin.sql @@ -0,0 +1,87 @@ +CREATE TABLE "album" ( + "id" serial PRIMARY KEY NOT NULL, + "mbid" text NOT NULL, + "title" text NOT NULL, + "artist_name" text NOT NULL, + "artist_mbid" text, + "cover_art_url" text, + "primary_type" text, + "secondary_types" jsonb, + "first_release_date" text, + "first_release_year" integer, + "genres" jsonb, + "disambiguation" text, + "cached_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "album_mbid_unique" UNIQUE("mbid") +); +--> statement-breakpoint +CREATE TABLE "music_diary_entry" ( + "id" uuid PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "album_id" integer NOT NULL, + "listened_date" text NOT NULL, + "rating" integer, + "relisten" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "music_interaction" ( + "user_id" text NOT NULL, + "album_id" integer NOT NULL, + "liked" boolean DEFAULT false NOT NULL, + "want_to_listen" boolean DEFAULT false NOT NULL, + "rating" integer, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "music_interactions_unique" UNIQUE("user_id","album_id") +); +--> statement-breakpoint +CREATE TABLE "book_diary_entry" ( + "id" uuid PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "book_id" integer NOT NULL, + "read_date" text NOT NULL, + "rating" integer, + "reread" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "book_interaction" ( + "user_id" text NOT NULL, + "book_id" integer NOT NULL, + "liked" boolean DEFAULT false NOT NULL, + "want_to_read" boolean DEFAULT false NOT NULL, + "rating" integer, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "book_interactions_unique" UNIQUE("user_id","book_id") +); +--> statement-breakpoint +CREATE TABLE "book" ( + "id" serial PRIMARY KEY NOT NULL, + "google_volume_id" text NOT NULL, + "title" text NOT NULL, + "subtitle" text, + "authors" jsonb NOT NULL, + "publisher" text, + "published_date" text, + "published_year" integer, + "page_count" integer, + "language" text, + "categories" jsonb, + "description" text, + "cover_image_url" text, + "isbn_13" text, + "google_books_url" text, + "cached_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "book_google_volume_id_unique" UNIQUE("google_volume_id") +); +--> statement-breakpoint +ALTER TABLE "music_diary_entry" ADD CONSTRAINT "music_diary_entry_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_diary_entry" ADD CONSTRAINT "music_diary_entry_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_interaction" ADD CONSTRAINT "music_interaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "music_interaction" ADD CONSTRAINT "music_interaction_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "book_diary_entry" ADD CONSTRAINT "book_diary_entry_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "book_diary_entry" ADD CONSTRAINT "book_diary_entry_book_id_book_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."book"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "book_interaction" ADD CONSTRAINT "book_interaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "book_interaction" ADD CONSTRAINT "book_interaction_book_id_book_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."book"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0031_snapshot.json b/apps/api/drizzle/meta/0031_snapshot.json new file mode 100644 index 0000000..855f549 --- /dev/null +++ b/apps/api/drizzle/meta/0031_snapshot.json @@ -0,0 +1,2945 @@ +{ + "id": "368812f1-2dff-426e-91a1-57619c6248fb", + "prevId": "863488f0-51ac-407d-a07a-7ca3619d2c9e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.movie": { + "name": "movie", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backdrop_path": { + "name": "backdrop_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "release_year": { + "name": "release_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "director": { + "name": "director", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime": { + "name": "runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "movie_tmdb_id_unique": { + "name": "movie_tmdb_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tmdb_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serial_diary_entry": { + "name": "serial_diary_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "watched_date": { + "name": "watched_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rewatch": { + "name": "rewatch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "serial_diary_entry_user_id_user_id_fk": { + "name": "serial_diary_entry_user_id_user_id_fk", + "tableFrom": "serial_diary_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "serial_diary_entry_series_id_tv_series_id_fk": { + "name": "serial_diary_entry_series_id_tv_series_id_fk", + "tableFrom": "serial_diary_entry", + "tableTo": "tv_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.serial_interaction": { + "name": "serial_interaction", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "liked": { + "name": "liked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watchlisted": { + "name": "watchlisted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "serial_interaction_user_id_user_id_fk": { + "name": "serial_interaction_user_id_user_id_fk", + "tableFrom": "serial_interaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "serial_interaction_series_id_tv_series_id_fk": { + "name": "serial_interaction_series_id_tv_series_id_fk", + "tableFrom": "serial_interaction", + "tableTo": "tv_series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "serial_interactions_unique": { + "name": "serial_interactions_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "series_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tv_series": { + "name": "tv_series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_title": { + "name": "original_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backdrop_path": { + "name": "backdrop_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_air_date": { + "name": "first_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "first_air_year": { + "name": "first_air_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_air_date": { + "name": "last_air_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "creator": { + "name": "creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "episode_runtime": { + "name": "episode_runtime", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_seasons": { + "name": "number_of_seasons", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_of_episodes": { + "name": "number_of_episodes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language_code": { + "name": "language_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tv_series_tmdb_id_unique": { + "name": "tv_series_tmdb_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tmdb_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.album": { + "name": "album", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mbid": { + "name": "mbid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_name": { + "name": "artist_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "artist_mbid": { + "name": "artist_mbid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_art_url": { + "name": "cover_art_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "primary_type": { + "name": "primary_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secondary_types": { + "name": "secondary_types", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_release_date": { + "name": "first_release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_release_year": { + "name": "first_release_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "disambiguation": { + "name": "disambiguation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "album_mbid_unique": { + "name": "album_mbid_unique", + "nullsNotDistinct": false, + "columns": [ + "mbid" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_diary_entry": { + "name": "music_diary_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "listened_date": { + "name": "listened_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "relisten": { + "name": "relisten", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "music_diary_entry_user_id_user_id_fk": { + "name": "music_diary_entry_user_id_user_id_fk", + "tableFrom": "music_diary_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "music_diary_entry_album_id_album_id_fk": { + "name": "music_diary_entry_album_id_album_id_fk", + "tableFrom": "music_diary_entry", + "tableTo": "album", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.music_interaction": { + "name": "music_interaction", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "album_id": { + "name": "album_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "liked": { + "name": "liked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "want_to_listen": { + "name": "want_to_listen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "music_interaction_user_id_user_id_fk": { + "name": "music_interaction_user_id_user_id_fk", + "tableFrom": "music_interaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "music_interaction_album_id_album_id_fk": { + "name": "music_interaction_album_id_album_id_fk", + "tableFrom": "music_interaction", + "tableTo": "album", + "columnsFrom": [ + "album_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "music_interactions_unique": { + "name": "music_interactions_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "album_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_diary_entry": { + "name": "book_diary_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "read_date": { + "name": "read_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reread": { + "name": "reread", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "book_diary_entry_user_id_user_id_fk": { + "name": "book_diary_entry_user_id_user_id_fk", + "tableFrom": "book_diary_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_diary_entry_book_id_book_id_fk": { + "name": "book_diary_entry_book_id_book_id_fk", + "tableFrom": "book_diary_entry", + "tableTo": "book", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book_interaction": { + "name": "book_interaction", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "liked": { + "name": "liked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "want_to_read": { + "name": "want_to_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "book_interaction_user_id_user_id_fk": { + "name": "book_interaction_user_id_user_id_fk", + "tableFrom": "book_interaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_interaction_book_id_book_id_fk": { + "name": "book_interaction_book_id_book_id_fk", + "tableFrom": "book_interaction", + "tableTo": "book", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "book_interactions_unique": { + "name": "book_interactions_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "book_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.book": { + "name": "book", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "google_volume_id": { + "name": "google_volume_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authors": { + "name": "authors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_date": { + "name": "published_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_year": { + "name": "published_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image_url": { + "name": "cover_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isbn_13": { + "name": "isbn_13", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "google_books_url": { + "name": "google_books_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "book_google_volume_id_unique": { + "name": "book_google_volume_id_unique", + "nullsNotDistinct": false, + "columns": [ + "google_volume_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.person": { + "name": "person", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "tmdb_person_id": { + "name": "tmdb_person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "known_for_department": { + "name": "known_for_department", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "route_role_hints": { + "name": "route_role_hints", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "profile_path": { + "name": "profile_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "popularity": { + "name": "popularity", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "cached_at": { + "name": "cached_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "person_tmdb_person_id_unique": { + "name": "person_tmdb_person_id_unique", + "nullsNotDistinct": false, + "columns": [ + "tmdb_person_id" + ] + }, + "person_slug_unique": { + "name": "person_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.person_slug_alias": { + "name": "person_slug_alias", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "person_id": { + "name": "person_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "person_slug_alias_person_id_person_id_fk": { + "name": "person_slug_alias_person_id_person_id_fk", + "tableFrom": "person_slug_alias", + "tableTo": "person", + "columnsFrom": [ + "person_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "person_slug_alias_slug_unique": { + "name": "person_slug_alias_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile_top_pick": { + "name": "profile_top_pick", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_source": { + "name": "media_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'tmdb'" + }, + "media_source_id": { + "name": "media_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "poster_path": { + "name": "poster_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_year": { + "name": "release_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_top_pick_user_id_user_id_fk": { + "name": "profile_top_pick_user_id_user_id_fk", + "tableFrom": "profile_top_pick", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profile_top_pick_user_category_slot_unique": { + "name": "profile_top_pick_user_category_slot_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "category_id", + "slot" + ] + }, + "profile_top_pick_user_category_media_unique": { + "name": "profile_top_pick_user_category_media_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "category_id", + "media_source", + "media_source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profile": { + "name": "profile", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favorite_genres": { + "name": "favorite_genres", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'rose-pine'" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "profile_user_id_user_id_fk": { + "name": "profile_user_id_user_id_fk", + "tableFrom": "profile", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.diary_entry": { + "name": "diary_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "movie_id": { + "name": "movie_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "watched_date": { + "name": "watched_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rewatch": { + "name": "rewatch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "diary_entry_user_id_user_id_fk": { + "name": "diary_entry_user_id_user_id_fk", + "tableFrom": "diary_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "diary_entry_movie_id_movie_id_fk": { + "name": "diary_entry_movie_id_movie_id_fk", + "tableFrom": "diary_entry", + "tableTo": "movie", + "columnsFrom": [ + "movie_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_like": { + "name": "comment_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_like_user_id_user_id_fk": { + "name": "comment_like_user_id_user_id_fk", + "tableFrom": "comment_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_like_comment_id_comment_id_fk": { + "name": "comment_like_comment_id_comment_id_fk", + "tableFrom": "comment_like", + "tableTo": "comment", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "comment_likes_unique": { + "name": "comment_likes_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "comment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment": { + "name": "comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "comment_user_id_user_id_fk": { + "name": "comment_user_id_user_id_fk", + "tableFrom": "comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_review_id_review_id_fk": { + "name": "comment_review_id_review_id_fk", + "tableFrom": "comment", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review_like": { + "name": "review_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "review_like_user_id_user_id_fk": { + "name": "review_like_user_id_user_id_fk", + "tableFrom": "review_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_like_review_id_review_id_fk": { + "name": "review_like_review_id_review_id_fk", + "tableFrom": "review_like", + "tableTo": "review", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "review_likes_unique": { + "name": "review_likes_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "review_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.review": { + "name": "review", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'movie'" + }, + "media_source": { + "name": "media_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'tmdb'" + }, + "media_source_id": { + "name": "media_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "movie_id": { + "name": "movie_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diary_entry_id": { + "name": "diary_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contains_spoilers": { + "name": "contains_spoilers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "review_user_id_user_id_fk": { + "name": "review_user_id_user_id_fk", + "tableFrom": "review", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_movie_id_movie_id_fk": { + "name": "review_movie_id_movie_id_fk", + "tableFrom": "review", + "tableTo": "movie", + "columnsFrom": [ + "movie_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "reviews_user_media_unique": { + "name": "reviews_user_media_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "media_type", + "media_source", + "media_source_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.movie_interaction": { + "name": "movie_interaction", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "movie_id": { + "name": "movie_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "liked": { + "name": "liked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "watchlisted": { + "name": "watchlisted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "movie_interaction_user_id_user_id_fk": { + "name": "movie_interaction_user_id_user_id_fk", + "tableFrom": "movie_interaction", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "movie_interaction_movie_id_movie_id_fk": { + "name": "movie_interaction_movie_id_movie_id_fk", + "tableFrom": "movie_interaction", + "tableTo": "movie", + "columnsFrom": [ + "movie_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "movie_interactions_unique": { + "name": "movie_interactions_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "movie_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_comment": { + "name": "post_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_comment_user_id_user_id_fk": { + "name": "post_comment_user_id_user_id_fk", + "tableFrom": "post_comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_comment_post_id_post_id_fk": { + "name": "post_comment_post_id_post_id_fk", + "tableFrom": "post_comment", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post_like": { + "name": "post_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_like_user_id_user_id_fk": { + "name": "post_like_user_id_user_id_fk", + "tableFrom": "post_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "post_like_post_id_post_id_fk": { + "name": "post_like_post_id_post_id_fk", + "tableFrom": "post_like", + "tableTo": "post", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "post_likes_unique": { + "name": "post_likes_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "post_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.post": { + "name": "post", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "post_media_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "post_user_id_user_id_fk": { + "name": "post_user_id_user_id_fk", + "tableFrom": "post", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.activity": { + "name": "activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "activity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "activity_user_id_user_id_fk": { + "name": "activity_user_id_user_id_fk", + "tableFrom": "activity", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.follow": { + "name": "follow", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "follow_follower_id_user_id_fk": { + "name": "follow_follower_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follow_following_id_user_id_fk": { + "name": "follow_following_id_user_id_fk", + "tableFrom": "follow", + "tableTo": "user", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "follows_unique": { + "name": "follows_unique", + "nullsNotDistinct": false, + "columns": [ + "follower_id", + "following_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_entry": { + "name": "list_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "movie_id": { + "name": "movie_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tv_series_id": { + "name": "tv_series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "le_cinema_unique": { + "name": "le_cinema_unique", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "movie_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "movie_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "le_serial_unique": { + "name": "le_serial_unique", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tv_series_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "tv_series_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "list_entry_list_id_list_id_fk": { + "name": "list_entry_list_id_list_id_fk", + "tableFrom": "list_entry", + "tableTo": "list", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_entry_movie_id_movie_id_fk": { + "name": "list_entry_movie_id_movie_id_fk", + "tableFrom": "list_entry", + "tableTo": "movie", + "columnsFrom": [ + "movie_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_entry_tv_series_id_tv_series_id_fk": { + "name": "list_entry_tv_series_id_tv_series_id_fk", + "tableFrom": "list_entry", + "tableTo": "tv_series", + "columnsFrom": [ + "tv_series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list_like": { + "name": "list_like", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "list_like_user_id_user_id_fk": { + "name": "list_like_user_id_user_id_fk", + "tableFrom": "list_like", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_like_list_id_list_id_fk": { + "name": "list_like_list_id_list_id_fk", + "tableFrom": "list_like", + "tableTo": "list", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "list_likes_unique": { + "name": "list_likes_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "list_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.list": { + "name": "list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_ranked": { + "name": "is_ranked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "derived_type": { + "name": "derived_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "list_user_id_user_id_fk": { + "name": "list_user_id_user_id_fk", + "tableFrom": "list", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.post_media_type": { + "name": "post_media_type", + "schema": "public", + "values": [ + "movie", + "tv" + ] + }, + "public.activity_type": { + "name": "activity_type", + "schema": "public", + "values": [ + "diary_entry", + "review", + "liked_movie", + "watchlisted_movie", + "followed_user", + "created_list", + "liked_review", + "commented", + "post" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 2ca9336..cda8505 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1776884695675, "tag": "0030_loving_logan", "breakpoints": true + }, + { + "idx": 31, + "version": "7", + "when": 1778496336558, + "tag": "0031_cynical_vermin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index 1f5608e..b9b0fbf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -38,6 +38,7 @@ "drizzle-orm": "^0.45.1", "express": "^5.2.1", "express-rate-limit": "^8.3.1", + "musicbrainz-api": "^1.2.1", "pino": "^10.3.1", "pino-http": "^11.0.0", "zod": "^4.3.6" diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 78641c1..0de7926 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -19,6 +19,8 @@ import { import listsRouter from "./modules/lists/lists.routes"; import moviesRouter from "./modules/movies/movies.routes"; import serialsRouter from "./modules/serials/serials.routes"; +import musicRouter from "./modules/music/music.routes"; +import booksRouter from "./modules/books/books.routes"; import peopleRouter from "./modules/people/people.routes"; import diaryRouter from "./modules/diary/diary.routes"; import usersRouter from "./modules/users/users.routes"; @@ -115,6 +117,8 @@ export const createApp = () => { app.use("/api/movies", moviesRouter); app.use("/api/serials", serialsRouter); + app.use("/api/music", musicRouter); + app.use("/api/books", booksRouter); app.use("/api/people", peopleRouter); app.use("/api/diary", diaryRouter); app.use("/api/users", usersRouter); diff --git a/apps/api/src/infrastructure/database/entities.ts b/apps/api/src/infrastructure/database/entities.ts index 523f6d8..e9e5133 100644 --- a/apps/api/src/infrastructure/database/entities.ts +++ b/apps/api/src/infrastructure/database/entities.ts @@ -2,9 +2,11 @@ export * from "./auth.entity"; // Modules — order matters for FK references: -// movies/serials → diary/reviews/interactions/lists → social/users +// movies/serials/music/books → diary/reviews/interactions/lists → social/users export * from "../../modules/movies/movies.entity"; export * from "../../modules/serials/serials.entity"; +export * from "../../modules/music/music.entity"; +export * from "../../modules/books/books.entity"; export * from "../../modules/people/people.entity"; export * from "../../modules/users/users.entity"; export * from "../../modules/diary/diary.entity"; diff --git a/apps/api/src/infrastructure/googlebooks/books.ts b/apps/api/src/infrastructure/googlebooks/books.ts new file mode 100644 index 0000000..8c14aa3 --- /dev/null +++ b/apps/api/src/infrastructure/googlebooks/books.ts @@ -0,0 +1,72 @@ +import { fetchGB } from "./client"; + +export type GoogleBooksVolumeInfo = { + title: string; + subtitle?: string; + authors?: string[]; + publisher?: string; + publishedDate?: string; + description?: string; + pageCount?: number; + printedPageCount?: number; + language?: string; + categories?: string[]; + averageRating?: number; + ratingsCount?: number; + imageLinks?: { + smallThumbnail?: string; + thumbnail?: string; + small?: string; + medium?: string; + large?: string; + extraLarge?: string; + }; + industryIdentifiers?: Array<{ type: string; identifier: string }>; + infoLink?: string; + previewLink?: string; + canonicalVolumeLink?: string; + maturityRating?: string; + printType?: string; +}; + +export type GoogleBooksVolume = { + id: string; + volumeInfo: GoogleBooksVolumeInfo; +}; + +type SearchResponse = { + totalItems?: number; + items?: GoogleBooksVolume[]; +}; + +export const searchBooks = async (query: string): Promise => { + const data = (await fetchGB("/volumes", { + q: query, + printType: "books", + maxResults: "20", + })) as SearchResponse; + return data.items ?? []; +}; + +export const getBookDetail = async (volumeId: string): Promise => { + return (await fetchGB(`/volumes/${volumeId}`)) as GoogleBooksVolume; +}; + +export const extractIsbn13 = (vi: GoogleBooksVolumeInfo): string | null => { + const isbn = vi.industryIdentifiers?.find((id) => id.type === "ISBN_13"); + return isbn?.identifier ?? null; +}; + +export const parseCoverUrl = (vi: GoogleBooksVolumeInfo): string | null => { + const raw = vi.imageLinks?.thumbnail ?? vi.imageLinks?.smallThumbnail ?? null; + return raw ? raw.replace(/^http:\/\//, "https://") : null; +}; + +export const parsePublishedYear = (vi: GoogleBooksVolumeInfo): number | null => { + if (!vi.publishedDate) return null; + const year = Number.parseInt(vi.publishedDate.slice(0, 4), 10); + return Number.isNaN(year) ? null : year; +}; + +export const stripHtml = (html: string): string => + html.replace(/<[^>]*>/g, "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").trim(); diff --git a/apps/api/src/infrastructure/googlebooks/client.ts b/apps/api/src/infrastructure/googlebooks/client.ts new file mode 100644 index 0000000..61c6a4a --- /dev/null +++ b/apps/api/src/infrastructure/googlebooks/client.ts @@ -0,0 +1,22 @@ +const GB_BASE = "https://www.googleapis.com/books/v1"; + +const getApiKey = () => process.env.GOOGLE_BOOKS_API_KEY ?? ""; + +export async function fetchGB(path: string, params: Record = {}): Promise { + const url = new URL(`${GB_BASE}${path}`); + const key = getApiKey(); + if (key) url.searchParams.set("key", key); + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + + const response = await fetch(url.toString(), { + headers: { Accept: "application/json" }, + }); + + if (!response.ok) { + throw new Error(`Google Books API error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} diff --git a/apps/api/src/infrastructure/musicbrainz/albums.ts b/apps/api/src/infrastructure/musicbrainz/albums.ts new file mode 100644 index 0000000..e2bcce1 --- /dev/null +++ b/apps/api/src/infrastructure/musicbrainz/albums.ts @@ -0,0 +1,51 @@ +import type { IReleaseGroup } from "musicbrainz-api"; +import { mbApi, caaApi } from "./client"; + +export type { IReleaseGroup }; + +export type MBGenreTag = { name: string; count: number }; + +export const searchAlbums = async (query: string): Promise => { + const result = await mbApi.search("release-group", { + query, + limit: 20, + } as Parameters[1]); + return (result as { "release-groups": IReleaseGroup[] })["release-groups"] ?? []; +}; + +export const getReleaseGroupDetail = async (mbid: string): Promise => { + return mbApi.lookup("release-group", mbid, ["artists", "releases", "tags", "ratings"]); +}; + +export const getCoverArtUrl = async (mbid: string): Promise => { + try { + const info = await caaApi.getReleaseGroupCovers(mbid); + if (!info?.images?.length) return null; + const front = info.images.find((img) => img.front) ?? info.images[0]; + return front?.thumbnails?.["500"] ?? front?.thumbnails?.large ?? front?.image ?? null; + } catch { + return null; + } +}; + +export const extractTopGenres = (rg: IReleaseGroup, maxCount = 5): MBGenreTag[] => { + const tags = (rg.tags ?? []) as { name: string; count: number }[]; + const skipTags = new Set(["seen live", "soundcloud", "bandcamp", "free", "download"]); + return tags + .filter((t) => !skipTags.has(t.name.toLowerCase())) + .sort((a, b) => b.count - a.count) + .slice(0, maxCount) + .map((t) => ({ name: t.name, count: t.count })); +}; + +export const buildArtistName = (rg: IReleaseGroup): string => { + const credits = rg["artist-credit"] ?? []; + return credits.map((c) => c.name + (c.joinphrase ?? "")).join("") || "Unknown Artist"; +}; + +export const parseFirstReleaseYear = (rg: IReleaseGroup): number | null => { + const raw = rg["first-release-date"]; + if (!raw) return null; + const year = Number.parseInt(raw.slice(0, 4), 10); + return Number.isNaN(year) ? null : year; +}; diff --git a/apps/api/src/infrastructure/musicbrainz/client.ts b/apps/api/src/infrastructure/musicbrainz/client.ts new file mode 100644 index 0000000..a79cfc8 --- /dev/null +++ b/apps/api/src/infrastructure/musicbrainz/client.ts @@ -0,0 +1,11 @@ +import { MusicBrainzApi, CoverArtArchiveApi } from "musicbrainz-api"; + +const contactInfo = process.env.MUSICBRAINZ_USER_AGENT ?? "contact@interis.app"; + +export const mbApi = new MusicBrainzApi({ + appName: "Interis", + appVersion: "1.0", + appContactInfo: contactInfo, +}); + +export const caaApi = new CoverArtArchiveApi(); diff --git a/apps/api/src/modules/books/books.controller.ts b/apps/api/src/modules/books/books.controller.ts new file mode 100644 index 0000000..b1addc8 --- /dev/null +++ b/apps/api/src/modules/books/books.controller.ts @@ -0,0 +1,168 @@ +import type { Request, Response } from "express"; +import { resolveViewerUserIdFromHeaders } from "../../commons/auth/session-resolver.helper"; +import { sendBadRequest, sendValidationError } from "../../commons/http/validation-response.helper"; +import { BooksService } from "./books.service"; +import { + SearchBooksQuerySchema, + BooksArchiveQuerySchema, + BookDetailQuerySchema, + CreateBookLogSchema, + UpdateBookLogSchema, + UpdateBookInteractionSchema, + normalizeBooksArchiveQuery, + normalizeBookDetailQuery, + parseVolumeIdParam, + type SearchBooksQuery, + type BookDetailQuery, +} from "./dto/books.dto"; + +export class BooksController { + static async search( + req: Request<{}, {}, {}, SearchBooksQuery>, + res: Response, + ): Promise { + const parsed = SearchBooksQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const results = await BooksService.search(parsed.data.query, parsed.data.language); + res.status(200).json(results); + } + + static async getByVolumeId(req: Request<{ volumeId: string }>, res: Response): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const book = await BooksService.findOrCreate(volumeId); + if (!book) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.status(200).json(book); + } + + static async getDetailByVolumeId( + req: Request<{ volumeId: string }, {}, {}, BookDetailQuery>, + res: Response, + ): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const viewerUserId = await resolveViewerUserIdFromHeaders(req.headers); + const detail = await BooksService.getDetail({ + volumeId, + viewerUserId, + reviewsSort: normalizeBookDetailQuery(req.query).reviewsSort, + }); + if (!detail) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.status(200).json(detail); + } + + static async getArchive( + req: Request<{}, {}, {}, Record>, + res: Response, + ): Promise { + const viewerUserId = await resolveViewerUserIdFromHeaders(req.headers); + const archive = await BooksService.getArchive({ + ...normalizeBooksArchiveQuery(req.query), + viewerUserId, + }); + res.setHeader("Cache-Control", "no-store"); + res.status(200).json(archive); + } + + static async getLogsByVolumeId(req: Request<{ volumeId: string }>, res: Response): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const logs = await BooksService.getLogsByVolumeId(volumeId); + if (logs === null) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.status(200).json(logs); + } + + static async getInteraction(req: Request<{ volumeId: string }>, res: Response): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const interaction = await BooksService.getInteraction(req.user.id, volumeId); + res.status(200).json(interaction); + } + + static async updateInteraction(req: Request<{ volumeId: string }>, res: Response): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const parsed = UpdateBookInteractionSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const result = await BooksService.updateInteraction(req.user.id, volumeId, parsed.data); + res.status(200).json(result); + } + + static async createLog(req: Request<{ volumeId: string }>, res: Response): Promise { + const volumeId = parseVolumeIdParam(req.params.volumeId); + if (!volumeId) { + sendBadRequest(res, "Invalid book ID"); + return; + } + const parsed = CreateBookLogSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const result = await BooksService.createLog(req.user.id, volumeId, parsed.data); + if (!result) { + res.status(404).json({ error: "Book not found" }); + return; + } + res.status(201).json(result); + } + + static async getMyLogs(req: Request, res: Response): Promise { + const logs = await BooksService.getMyLogs(req.user.id); + res.status(200).json(logs); + } + + static async updateLog(req: Request<{ id: string }>, res: Response): Promise { + const parsed = UpdateBookLogSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const updated = await BooksService.updateLog(req.params.id, req.user.id, parsed.data); + if (!updated) { + res.status(404).json({ error: "Log not found" }); + return; + } + res.status(200).json(updated); + } + + static async deleteLog(req: Request<{ id: string }>, res: Response): Promise { + const deleted = await BooksService.deleteLog(req.params.id, req.user.id); + if (!deleted) { + res.status(404).json({ error: "Log not found" }); + return; + } + res.status(200).json({ success: true }); + } +} diff --git a/apps/api/src/modules/books/books.entity.ts b/apps/api/src/modules/books/books.entity.ts new file mode 100644 index 0000000..d4a2313 --- /dev/null +++ b/apps/api/src/modules/books/books.entity.ts @@ -0,0 +1,71 @@ +import { + pgTable, + serial, + text, + integer, + boolean, + timestamp, + jsonb, + uuid, + unique, +} from "drizzle-orm/pg-core"; +import { user } from "../../infrastructure/database/auth.entity"; + +export const books = pgTable("book", { + id: serial("id").primaryKey(), + googleVolumeId: text("google_volume_id").notNull().unique(), + title: text("title").notNull(), + subtitle: text("subtitle"), + authors: jsonb("authors").$type().notNull().$default(() => []), + publisher: text("publisher"), + publishedDate: text("published_date"), + publishedYear: integer("published_year"), + pageCount: integer("page_count"), + language: text("language"), + categories: jsonb("categories").$type(), + description: text("description"), + coverImageUrl: text("cover_image_url"), + isbn13: text("isbn_13"), + googleBooksUrl: text("google_books_url"), + cachedAt: timestamp("cached_at").defaultNow().notNull(), +}); + +export const bookDiaryEntries = pgTable("book_diary_entry", { + id: uuid("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + readDate: text("read_date").notNull(), + rating: integer("rating"), + reread: boolean("reread").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); + +export const bookInteractions = pgTable( + "book_interaction", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + bookId: integer("book_id") + .notNull() + .references(() => books.id, { onDelete: "cascade" }), + liked: boolean("liked").default(false).notNull(), + wantToRead: boolean("want_to_read").default(false).notNull(), + rating: integer("rating"), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [unique("book_interactions_unique").on(table.userId, table.bookId)], +); diff --git a/apps/api/src/modules/books/books.routes.ts b/apps/api/src/modules/books/books.routes.ts new file mode 100644 index 0000000..d7889d4 --- /dev/null +++ b/apps/api/src/modules/books/books.routes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import { asyncHandler } from "../../commons/utils/asyncHandler"; +import { requireAuth } from "../../commons/middlewares/requireAuth"; +import { BooksController } from "./books.controller"; + +const router = Router(); + +router.get("/search", asyncHandler(BooksController.search)); +router.get("/archive", asyncHandler(BooksController.getArchive)); +router.get("/logs", requireAuth, asyncHandler(BooksController.getMyLogs)); +router.put("/logs/:id", requireAuth, asyncHandler(BooksController.updateLog)); +router.delete("/logs/:id", requireAuth, asyncHandler(BooksController.deleteLog)); +router.get("/:volumeId/detail", asyncHandler(BooksController.getDetailByVolumeId)); +router.get("/:volumeId/logs", asyncHandler(BooksController.getLogsByVolumeId)); +router.get("/:volumeId/interaction", requireAuth, asyncHandler(BooksController.getInteraction)); +router.put("/:volumeId/interaction", requireAuth, asyncHandler(BooksController.updateInteraction)); +router.post("/:volumeId/log", requireAuth, asyncHandler(BooksController.createLog)); +router.get("/:volumeId", asyncHandler(BooksController.getByVolumeId)); + +export default router; diff --git a/apps/api/src/modules/books/books.service.ts b/apps/api/src/modules/books/books.service.ts new file mode 100644 index 0000000..ac88c1c --- /dev/null +++ b/apps/api/src/modules/books/books.service.ts @@ -0,0 +1,124 @@ +import { searchBooks } from "../../infrastructure/googlebooks/books"; +import type { GoogleBooksVolume } from "../../infrastructure/googlebooks/books"; +import { db } from "../../infrastructure/database/db"; +import { activities } from "../social/social.entity"; +import type { + BooksArchiveSort, + BookDetailReviewSort, + UpdateBookInteractionDto, + UpdateBookLogDto, + CreateBookLogDto, + NormalizedBooksArchiveQuery, +} from "./dto/books.dto"; +import { BooksCacheService } from "./services/books-cache.service"; +import { BooksArchiveService } from "./services/books-archive.service"; +import { BooksDetailService } from "./services/books-detail.service"; +import { BooksInteractionsRepository } from "./repositories/books-interactions.repository"; + +const RATING_FACTOR = 2; + +export class BooksService { + static async search(query: string, language?: string): Promise { + const q = language ? `${query} langRestrict=${language}` : query; + return searchBooks(q); + } + + static async findOrCreate(volumeId: string) { + return BooksCacheService.findOrCreate(volumeId); + } + + static async getDetail(input: { + volumeId: string; + viewerUserId?: string | null; + reviewsSort: BookDetailReviewSort; + }) { + return BooksDetailService.getDetail(input); + } + + static async getArchive(input: NormalizedBooksArchiveQuery & { viewerUserId?: string | null }) { + return BooksArchiveService.getArchive(input); + } + + static async getLogsByVolumeId(volumeId: string) { + return BooksDetailService.getLogsByVolumeId(volumeId); + } + + static async getInteraction(userId: string, volumeId: string) { + const book = await BooksCacheService.findOrCreate(volumeId); + if (!book) return null; + const row = await BooksInteractionsRepository.getInteraction(userId, book.id); + if (!row) return { liked: false, wantToRead: false, ratingOutOfTen: null, ratingOutOfFive: null }; + return { + liked: row.liked, + wantToRead: row.wantToRead, + ratingOutOfTen: row.rating, + ratingOutOfFive: row.rating !== null ? row.rating / RATING_FACTOR : null, + }; + } + + static async updateInteraction(userId: string, volumeId: string, input: UpdateBookInteractionDto) { + const book = await BooksCacheService.findOrCreate(volumeId); + if (!book) return null; + const ratingOutOfTen = + input.ratingOutOfFive !== undefined + ? input.ratingOutOfFive === null ? null : Math.round(input.ratingOutOfFive * RATING_FACTOR) + : undefined; + return BooksInteractionsRepository.upsertInteraction(userId, book.id, { + liked: input.liked, + wantToRead: input.wantToRead, + rating: ratingOutOfTen, + }); + } + + static async createLog(userId: string, volumeId: string, input: CreateBookLogDto) { + const book = await BooksCacheService.findOrCreate(volumeId); + if (!book) return null; + const rating = input.ratingOutOfFive !== undefined + ? Math.round(input.ratingOutOfFive * RATING_FACTOR) + : null; + const entry = await BooksInteractionsRepository.createLog(userId, book.id, { + readDate: input.readDate, + rating, + reread: input.reread ?? false, + }); + if (entry) { + await db.insert(activities).values({ + userId, + type: "diary_entry", + entityId: entry.id, + metadata: JSON.stringify({ + mediaType: "book", + volumeId: book.googleVolumeId, + title: book.title, + authors: book.authors ?? [], + coverArtUrl: book.coverImageUrl ?? null, + releaseYear: book.publishedYear ?? null, + rating: entry.rating ?? null, + reread: entry.reread, + hasReview: false, + }), + }).catch(() => undefined); + } + return { entry, book }; + } + + static async getMyLogs(userId: string) { + return BooksInteractionsRepository.getMyLogs(userId); + } + + static async updateLog(id: string, userId: string, input: UpdateBookLogDto) { + const ratingOutOfTen = + input.ratingOutOfFive !== undefined + ? input.ratingOutOfFive === null ? null : Math.round(input.ratingOutOfFive * RATING_FACTOR) + : undefined; + return BooksInteractionsRepository.updateLog(id, userId, { + readDate: input.readDate, + rating: ratingOutOfTen, + reread: input.reread, + }); + } + + static async deleteLog(id: string, userId: string) { + return BooksInteractionsRepository.deleteLog(id, userId); + } +} diff --git a/apps/api/src/modules/books/constants/books.constants.ts b/apps/api/src/modules/books/constants/books.constants.ts new file mode 100644 index 0000000..1077277 --- /dev/null +++ b/apps/api/src/modules/books/constants/books.constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_ARCHIVE_PAGE = 1; +export const DEFAULT_ARCHIVE_LIMIT = 30; +export const MAX_ARCHIVE_LIMIT = 30; +export const DEFAULT_ARCHIVE_SORT = "logs_desc"; +export const DEFAULT_DETAIL_REVIEWS_SORT = "popular"; +export const CACHE_TTL_DAYS = 7; diff --git a/apps/api/src/modules/books/dto/books.dto.ts b/apps/api/src/modules/books/dto/books.dto.ts new file mode 100644 index 0000000..1c7d9e1 --- /dev/null +++ b/apps/api/src/modules/books/dto/books.dto.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { isoDateSchema } from "../../../commons/validation/common.schemas"; +import { + DEFAULT_ARCHIVE_LIMIT, + DEFAULT_ARCHIVE_PAGE, + DEFAULT_ARCHIVE_SORT, + MAX_ARCHIVE_LIMIT, +} from "../constants/books.constants"; + +export const booksArchiveSortValues = [ + "logs_desc", + "published_desc", + "published_asc", + "rating_desc", + "title_asc", +] as const; +export type BooksArchiveSort = (typeof booksArchiveSortValues)[number]; + +export const bookDetailReviewSortValues = ["popular", "recent"] as const; +export type BookDetailReviewSort = (typeof bookDetailReviewSortValues)[number]; + +export const SearchBooksQuerySchema = z.object({ + query: z.string().trim().min(1), + language: z.string().optional(), +}); +export type SearchBooksQuery = z.input; + +export const BookParamsSchema = z.object({ + volumeId: z.string().min(1), +}); +export type BookParams = z.input; + +export const BookDetailQuerySchema = z.object({ + reviewsSort: z.enum(bookDetailReviewSortValues).optional(), +}); +export type BookDetailQuery = z.input; + +const optionalText = z.string().optional().transform((v) => v?.trim() || undefined); + +const archiveSortSchema = optionalText.transform((v): BooksArchiveSort => { + return (booksArchiveSortValues as readonly string[]).includes(v ?? "") + ? (v as BooksArchiveSort) + : DEFAULT_ARCHIVE_SORT; +}); + +const archivePageSchema = z.string().optional().transform((v) => { + if (!v) return DEFAULT_ARCHIVE_PAGE; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? Math.max(1, n) : DEFAULT_ARCHIVE_PAGE; +}); + +const archiveLimitSchema = z.string().optional().transform((v) => { + if (!v) return DEFAULT_ARCHIVE_LIMIT; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? Math.max(1, Math.min(MAX_ARCHIVE_LIMIT, n)) : DEFAULT_ARCHIVE_LIMIT; +}); + +export const BooksArchiveQuerySchema = z.object({ + genre: optionalText, + language: optionalText, + sort: archiveSortSchema, + page: archivePageSchema, + limit: archiveLimitSchema, +}); + +export const CreateBookLogSchema = z.object({ + readDate: isoDateSchema, + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).optional(), + reread: z.boolean().optional(), +}); + +export const UpdateBookLogSchema = z.object({ + readDate: isoDateSchema.optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), + reread: z.boolean().optional(), +}); + +export const UpdateBookInteractionSchema = z.object({ + liked: z.boolean().optional(), + wantToRead: z.boolean().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), +}); + +export type CreateBookLogDto = z.infer; +export type UpdateBookLogDto = z.infer; +export type UpdateBookInteractionDto = z.infer; + +export type NormalizedBooksArchiveQuery = { + genre: string | null; + language: string | null; + sort: BooksArchiveSort; + page: number; + limit: number; +}; + +export const normalizeBooksArchiveQuery = ( + query: z.input, +): NormalizedBooksArchiveQuery => { + const parsed = BooksArchiveQuerySchema.safeParse(query); + if (!parsed.success) { + return { genre: null, language: null, sort: DEFAULT_ARCHIVE_SORT, page: DEFAULT_ARCHIVE_PAGE, limit: DEFAULT_ARCHIVE_LIMIT }; + } + return { + genre: parsed.data.genre?.trim() || null, + language: parsed.data.language?.trim() || null, + sort: parsed.data.sort, + page: parsed.data.page, + limit: parsed.data.limit, + }; +}; + +export const normalizeBookDetailQuery = ( + query: BookDetailQuery, +): { reviewsSort: BookDetailReviewSort } => ({ + reviewsSort: query.reviewsSort ?? "popular", +}); + +export const parseVolumeIdParam = (raw: unknown): string | null => { + if (typeof raw !== "string" || !raw.trim()) return null; + return raw.trim(); +}; diff --git a/apps/api/src/modules/books/repositories/books-archive.repository.ts b/apps/api/src/modules/books/repositories/books-archive.repository.ts new file mode 100644 index 0000000..a1939bb --- /dev/null +++ b/apps/api/src/modules/books/repositories/books-archive.repository.ts @@ -0,0 +1,106 @@ +import { SQL, asc, desc, eq, ilike, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { books, bookDiaryEntries, bookInteractions } from "../books.entity"; +import type { BooksArchiveSort } from "../dto/books.dto"; + +export class BooksArchiveRepository { + static async getArchiveRows(input: { + genre: string | null; + language: string | null; + sort: BooksArchiveSort; + page: number; + limit: number; + }) { + const orderBy = { + logs_desc: desc(sql`count(${bookDiaryEntries.id})`), + published_desc: desc(books.publishedYear), + published_asc: asc(books.publishedYear), + rating_desc: desc(sql`avg(${bookDiaryEntries.rating})`), + title_asc: asc(books.title), + }[input.sort] ?? desc(sql`count(${bookDiaryEntries.id})`); + + const conditions: SQL[] = []; + + if (input.genre) { + conditions.push( + sql`EXISTS ( + SELECT 1 FROM jsonb_array_elements_text(${books.categories}) c + WHERE lower(c) = lower(${input.genre}) + )`, + ); + } + + if (input.language) { + conditions.push(ilike(books.language, input.language)); + } + + const whereClause = + conditions.length > 0 + ? conditions.reduce((acc, cond) => sql`${acc} AND ${cond}`) + : undefined; + + const offset = (input.page - 1) * input.limit; + + const baseQuery = db + .select({ + googleVolumeId: books.googleVolumeId, + title: books.title, + authors: books.authors, + coverImageUrl: books.coverImageUrl, + publishedYear: books.publishedYear, + language: books.language, + categories: books.categories, + logCount: sql`count(${bookDiaryEntries.id})::int`.as("logCount"), + avgRatingOutOfTen: sql`avg(${bookDiaryEntries.rating})::double precision`.as("avgRatingOutOfTen"), + }) + .from(books) + .leftJoin(bookDiaryEntries, eq(bookDiaryEntries.bookId, books.id)); + + const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery) + .groupBy(books.id) + .orderBy(orderBy) + .limit(input.limit) + .offset(offset); + + return rows; + } + + static async getTotalCount(): Promise { + const [row] = await db.select({ count: sql`count(*)::int` }).from(books); + return row?.count ?? 0; + } + + static async getTopGenres(limit = 50): Promise<{ name: string; count: number }[]> { + const rows = await db.select({ categories: books.categories }).from(books); + const genreMap = new Map(); + for (const row of rows) { + for (const g of row.categories ?? []) { + genreMap.set(g, (genreMap.get(g) ?? 0) + 1); + } + } + return [...genreMap.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + } + + static async getViewerLoggedVolumeIds(userId: string) { + const rows = await db + .select({ googleVolumeId: books.googleVolumeId }) + .from(bookDiaryEntries) + .innerJoin(books, eq(books.id, bookDiaryEntries.bookId)) + .where(eq(bookDiaryEntries.userId, userId)) + .groupBy(books.googleVolumeId); + return rows.map((r) => r.googleVolumeId); + } + + static async getViewerWantToReadVolumeIds(userId: string) { + const rows = await db + .select({ googleVolumeId: books.googleVolumeId }) + .from(bookInteractions) + .innerJoin(books, eq(books.id, bookInteractions.bookId)) + .where(sql`${bookInteractions.userId} = ${userId} AND ${bookInteractions.wantToRead} = true`) + .groupBy(books.googleVolumeId); + return rows.map((r) => r.googleVolumeId); + } +} diff --git a/apps/api/src/modules/books/repositories/books-cache.repository.ts b/apps/api/src/modules/books/repositories/books-cache.repository.ts new file mode 100644 index 0000000..14a8227 --- /dev/null +++ b/apps/api/src/modules/books/repositories/books-cache.repository.ts @@ -0,0 +1,52 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { books } from "../books.entity"; + +export class BooksCacheRepository { + static async findByVolumeId(googleVolumeId: string) { + const [row] = await db.select().from(books).where(eq(books.googleVolumeId, googleVolumeId)).limit(1); + return row ?? null; + } + + static async upsert(input: { + googleVolumeId: string; + title: string; + subtitle: string | null; + authors: string[]; + publisher: string | null; + publishedDate: string | null; + publishedYear: number | null; + pageCount: number | null; + language: string | null; + categories: string[]; + description: string | null; + coverImageUrl: string | null; + isbn13: string | null; + googleBooksUrl: string | null; + }) { + const [inserted] = await db + .insert(books) + .values(input) + .onConflictDoUpdate({ + target: books.googleVolumeId, + set: { + title: input.title, + subtitle: input.subtitle, + authors: input.authors, + publisher: input.publisher, + publishedDate: input.publishedDate, + publishedYear: input.publishedYear, + pageCount: input.pageCount, + language: input.language, + categories: input.categories, + description: input.description, + coverImageUrl: input.coverImageUrl, + isbn13: input.isbn13, + googleBooksUrl: input.googleBooksUrl, + cachedAt: new Date(), + }, + }) + .returning(); + return inserted ?? null; + } +} diff --git a/apps/api/src/modules/books/repositories/books-interactions.repository.ts b/apps/api/src/modules/books/repositories/books-interactions.repository.ts new file mode 100644 index 0000000..5e5782d --- /dev/null +++ b/apps/api/src/modules/books/repositories/books-interactions.repository.ts @@ -0,0 +1,167 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { user } from "../../../infrastructure/database/auth.entity"; +import { profiles } from "../../users/users.entity"; +import { reviews } from "../../reviews/reviews.entity"; +import { books, bookDiaryEntries, bookInteractions } from "../books.entity"; + +export class BooksInteractionsRepository { + static async getInteraction(userId: string, bookId: number) { + const [row] = await db + .select() + .from(bookInteractions) + .where(and(eq(bookInteractions.userId, userId), eq(bookInteractions.bookId, bookId))) + .limit(1); + return row ?? null; + } + + static async upsertInteraction( + userId: string, + bookId: number, + input: { liked?: boolean; wantToRead?: boolean; rating?: number | null }, + ) { + const [row] = await db + .insert(bookInteractions) + .values({ + userId, + bookId, + liked: input.liked ?? false, + wantToRead: input.wantToRead ?? false, + rating: input.rating ?? null, + }) + .onConflictDoUpdate({ + target: [bookInteractions.userId, bookInteractions.bookId], + set: { + ...(input.liked !== undefined && { liked: input.liked }), + ...(input.wantToRead !== undefined && { wantToRead: input.wantToRead }), + ...(input.rating !== undefined && { rating: input.rating }), + updatedAt: new Date(), + }, + }) + .returning(); + return row ?? null; + } + + static async createLog(userId: string, bookId: number, input: { + readDate: string; + rating: number | null; + reread: boolean; + }) { + const [row] = await db + .insert(bookDiaryEntries) + .values({ userId, bookId, ...input }) + .returning(); + return row ?? null; + } + + static async updateLog(id: string, userId: string, input: { + readDate?: string; + rating?: number | null; + reread?: boolean; + }) { + const [row] = await db + .update(bookDiaryEntries) + .set({ + ...(input.readDate !== undefined && { readDate: input.readDate }), + ...(input.rating !== undefined && { rating: input.rating }), + ...(input.reread !== undefined && { reread: input.reread }), + }) + .where(and(eq(bookDiaryEntries.id, id), eq(bookDiaryEntries.userId, userId))) + .returning(); + return row ?? null; + } + + static async deleteLog(id: string, userId: string) { + const [row] = await db + .delete(bookDiaryEntries) + .where(and(eq(bookDiaryEntries.id, id), eq(bookDiaryEntries.userId, userId))) + .returning({ id: bookDiaryEntries.id }); + return row ?? null; + } + + static async getMyLogs(userId: string) { + return db + .select({ + id: bookDiaryEntries.id, + readDate: bookDiaryEntries.readDate, + rating: bookDiaryEntries.rating, + reread: bookDiaryEntries.reread, + bookId: bookDiaryEntries.bookId, + createdAt: bookDiaryEntries.createdAt, + updatedAt: bookDiaryEntries.updatedAt, + bookGoogleVolumeId: books.googleVolumeId, + bookTitle: books.title, + bookAuthors: books.authors, + bookCoverImageUrl: books.coverImageUrl, + bookPublishedYear: books.publishedYear, + reviewId: reviews.id, + reviewContent: reviews.content, + }) + .from(bookDiaryEntries) + .innerJoin(books, eq(books.id, bookDiaryEntries.bookId)) + .leftJoin( + reviews, + and( + eq(reviews.userId, bookDiaryEntries.userId), + eq(reviews.mediaType, "book"), + eq(reviews.mediaSourceId, books.googleVolumeId), + ), + ) + .where(eq(bookDiaryEntries.userId, userId)) + .orderBy(desc(bookDiaryEntries.readDate), desc(bookDiaryEntries.createdAt)); + } + + static async getLogsByBookId(bookId: number) { + return db + .select({ + diaryEntryId: bookDiaryEntries.id, + readDate: bookDiaryEntries.readDate, + rating: bookDiaryEntries.rating, + reread: bookDiaryEntries.reread, + createdAt: bookDiaryEntries.createdAt, + username: user.username, + userDisplayName: user.name, + avatarUrl: profiles.avatarUrl, + reviewContent: reviews.content, + reviewContainsSpoilers: reviews.containsSpoilers, + reviewUpdatedAt: reviews.updatedAt, + }) + .from(bookDiaryEntries) + .innerJoin(user, eq(user.id, bookDiaryEntries.userId)) + .innerJoin(profiles, eq(profiles.userId, bookDiaryEntries.userId)) + .leftJoin( + reviews, + and( + eq(reviews.userId, bookDiaryEntries.userId), + eq(reviews.mediaType, "book"), + eq(reviews.mediaSourceId, books.googleVolumeId), + ), + ) + .innerJoin(books, eq(books.id, bookDiaryEntries.bookId)) + .where(eq(bookDiaryEntries.bookId, bookId)) + .orderBy(desc(bookDiaryEntries.createdAt)); + } + + static async getLogCount(bookId: number): Promise { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(bookDiaryEntries) + .where(eq(bookDiaryEntries.bookId, bookId)); + return row?.count ?? 0; + } + + static async getViewerLog(userId: string, bookId: number) { + const [row] = await db + .select({ + id: bookDiaryEntries.id, + readDate: bookDiaryEntries.readDate, + reread: bookDiaryEntries.reread, + rating: bookDiaryEntries.rating, + }) + .from(bookDiaryEntries) + .where(and(eq(bookDiaryEntries.userId, userId), eq(bookDiaryEntries.bookId, bookId))) + .orderBy(desc(bookDiaryEntries.readDate)) + .limit(1); + return row ?? null; + } +} diff --git a/apps/api/src/modules/books/services/books-archive.service.ts b/apps/api/src/modules/books/services/books-archive.service.ts new file mode 100644 index 0000000..c99730e --- /dev/null +++ b/apps/api/src/modules/books/services/books-archive.service.ts @@ -0,0 +1,58 @@ +import { BooksArchiveRepository } from "../repositories/books-archive.repository"; +import { BooksSeedService } from "./books-seed.service"; +import type { NormalizedBooksArchiveQuery } from "../dto/books.dto"; +import type { BooksArchiveResponse, BooksArchiveItem } from "../types/books.types"; + +export class BooksArchiveService { + static async getArchive(input: NormalizedBooksArchiveQuery & { viewerUserId?: string | null }): Promise { + await BooksSeedService.seedIfEmpty().catch(() => undefined); + + const [rows, totalCount, availableGenres] = await Promise.all([ + BooksArchiveRepository.getArchiveRows(input), + BooksArchiveRepository.getTotalCount(), + BooksArchiveRepository.getTopGenres(), + ]); + + const volumeIds = rows.map((r) => r.googleVolumeId); + const [loggedIds, wantToReadIds] = input.viewerUserId + ? await Promise.all([ + BooksArchiveRepository.getViewerLoggedVolumeIds(input.viewerUserId), + BooksArchiveRepository.getViewerWantToReadVolumeIds(input.viewerUserId), + ]) + : [[], []]; + + const loggedSet = new Set(loggedIds); + const wantToReadSet = new Set(wantToReadIds); + + const items: BooksArchiveItem[] = rows.map((r) => ({ + googleVolumeId: r.googleVolumeId, + title: r.title, + authors: (r.authors ?? []) as string[], + coverImageUrl: r.coverImageUrl, + publishedYear: r.publishedYear, + language: r.language, + categories: (r.categories ?? []) as string[], + logCount: r.logCount, + avgRatingOutOfFive: + r.avgRatingOutOfTen !== null ? Math.round((r.avgRatingOutOfTen / 2) * 10) / 10 : null, + viewerHasLogged: loggedSet.has(r.googleVolumeId), + viewerWantToRead: wantToReadSet.has(r.googleVolumeId), + })); + + const hasMore = items.length === input.limit; + + return { + totalCount, + filteredCount: totalCount, + selectedGenre: input.genre, + selectedLanguage: input.language, + selectedSort: input.sort, + availableGenres: availableGenres.slice(0, 30), + page: input.page, + limit: input.limit, + hasMore, + nextPage: hasMore ? input.page + 1 : null, + items, + }; + } +} diff --git a/apps/api/src/modules/books/services/books-cache.service.ts b/apps/api/src/modules/books/services/books-cache.service.ts new file mode 100644 index 0000000..03fd065 --- /dev/null +++ b/apps/api/src/modules/books/services/books-cache.service.ts @@ -0,0 +1,37 @@ +import { + getBookDetail, + extractIsbn13, + parseCoverUrl, + parsePublishedYear, + stripHtml, +} from "../../../infrastructure/googlebooks/books"; +import { BooksCacheRepository } from "../repositories/books-cache.repository"; + +export class BooksCacheService { + static async findOrCreate(googleVolumeId: string) { + const existing = await BooksCacheRepository.findByVolumeId(googleVolumeId); + if (existing) { + return existing; + } + + const volume = await getBookDetail(googleVolumeId); + const vi = volume.volumeInfo; + + return BooksCacheRepository.upsert({ + googleVolumeId: volume.id, + title: vi.title, + subtitle: vi.subtitle ?? null, + authors: vi.authors ?? [], + publisher: vi.publisher ?? null, + publishedDate: vi.publishedDate ?? null, + publishedYear: parsePublishedYear(vi), + pageCount: vi.pageCount ?? vi.printedPageCount ?? null, + language: vi.language ?? null, + categories: vi.categories ?? [], + description: vi.description ? stripHtml(vi.description) : null, + coverImageUrl: parseCoverUrl(vi), + isbn13: extractIsbn13(vi), + googleBooksUrl: vi.infoLink ?? vi.canonicalVolumeLink ?? null, + }); + } +} diff --git a/apps/api/src/modules/books/services/books-detail.service.ts b/apps/api/src/modules/books/services/books-detail.service.ts new file mode 100644 index 0000000..af19afa --- /dev/null +++ b/apps/api/src/modules/books/services/books-detail.service.ts @@ -0,0 +1,163 @@ +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { user } from "../../../infrastructure/database/auth.entity"; +import { profiles } from "../../users/users.entity"; +import { reviews, reviewLikes } from "../../reviews/reviews.entity"; +import { books } from "../books.entity"; +import { BooksCacheService } from "./books-cache.service"; +import { BooksInteractionsRepository } from "../repositories/books-interactions.repository"; +import type { BookDetailReviewSort } from "../dto/books.dto"; +import type { + BookDetailResponse, + BookDetailReviewItem, + BookDetailUserLog, + BookInteraction, +} from "../types/books.types"; + +export class BooksDetailService { + static async getDetail(input: { + volumeId: string; + viewerUserId?: string | null; + reviewsSort: BookDetailReviewSort; + }): Promise { + const book = await BooksCacheService.findOrCreate(input.volumeId); + if (!book) return null; + + const reviewRows = await db + .select({ + id: reviews.id, + userId: reviews.userId, + content: reviews.content, + containsSpoilers: reviews.containsSpoilers, + createdAt: reviews.createdAt, + updatedAt: reviews.updatedAt, + authorUsername: user.username, + authorDisplayUsername: user.displayUsername, + authorImage: user.image, + authorAvatarUrl: profiles.avatarUrl, + }) + .from(reviews) + .innerJoin(user, eq(user.id, reviews.userId)) + .leftJoin(profiles, eq(profiles.userId, reviews.userId)) + .where(and(eq(reviews.mediaType, "book"), eq(reviews.mediaSourceId, input.volumeId))) + .orderBy(desc(reviews.createdAt)); + + const reviewIds = reviewRows.map((r) => r.id); + + const [likeCounts, viewerLikedRows, logsCount, viewerLogRow, viewerInteractionRow] = + await Promise.all([ + reviewIds.length > 0 + ? db + .select({ + reviewId: reviewLikes.reviewId, + likeCount: sql`count(*)::int`.as("likeCount"), + }) + .from(reviewLikes) + .where(inArray(reviewLikes.reviewId, reviewIds)) + .groupBy(reviewLikes.reviewId) + : Promise.resolve([]), + input.viewerUserId && reviewIds.length > 0 + ? db + .select({ reviewId: reviewLikes.reviewId }) + .from(reviewLikes) + .where( + and( + eq(reviewLikes.userId, input.viewerUserId), + inArray(reviewLikes.reviewId, reviewIds), + ), + ) + : Promise.resolve([]), + BooksInteractionsRepository.getLogCount(book.id), + input.viewerUserId + ? BooksInteractionsRepository.getViewerLog(input.viewerUserId, book.id) + : Promise.resolve(null), + input.viewerUserId + ? BooksInteractionsRepository.getInteraction(input.viewerUserId, book.id) + : Promise.resolve(null), + ]); + + const likeCountMap = new Map(likeCounts.map((r) => [r.reviewId, r.likeCount])); + const viewerLikedSet = new Set(viewerLikedRows.map((r) => r.reviewId)); + + const reviewItems: BookDetailReviewItem[] = reviewRows.map((r) => ({ + id: r.id, + content: r.content, + containsSpoilers: r.containsSpoilers, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + readDate: null, + ratingOutOfTen: null, + ratingOutOfFive: null, + likeCount: likeCountMap.get(r.id) ?? 0, + viewerHasLiked: viewerLikedSet.has(r.id), + author: { + id: r.userId, + username: r.authorUsername, + displayUsername: r.authorDisplayUsername ?? null, + image: r.authorImage ?? null, + avatarUrl: r.authorAvatarUrl ?? null, + }, + })); + + const sortedReviews = + input.reviewsSort === "popular" + ? [...reviewItems].sort((a, b) => b.likeCount - a.likeCount) + : reviewItems; + + const userLog: BookDetailUserLog | null = viewerLogRow + ? { + diaryEntryId: viewerLogRow.id, + readDate: viewerLogRow.readDate, + reread: viewerLogRow.reread, + ratingOutOfTen: viewerLogRow.rating, + ratingOutOfFive: viewerLogRow.rating !== null ? viewerLogRow.rating / 2 : null, + } + : null; + + const interaction: BookInteraction | null = viewerInteractionRow + ? { + liked: viewerInteractionRow.liked, + wantToRead: viewerInteractionRow.wantToRead, + ratingOutOfTen: viewerInteractionRow.rating, + ratingOutOfFive: + viewerInteractionRow.rating !== null ? viewerInteractionRow.rating / 2 : null, + } + : null; + + return { + book: { + id: book.id, + googleVolumeId: book.googleVolumeId, + title: book.title, + subtitle: book.subtitle, + authors: (book.authors ?? []) as string[], + publisher: book.publisher, + publishedDate: book.publishedDate, + publishedYear: book.publishedYear, + pageCount: book.pageCount, + language: book.language, + categories: (book.categories ?? []) as string[], + description: book.description, + coverImageUrl: book.coverImageUrl, + isbn13: book.isbn13, + googleBooksUrl: book.googleBooksUrl, + }, + logsCount, + reviewCount: reviewRows.length, + userLog, + interaction, + reviewsSort: input.reviewsSort, + reviews: sortedReviews, + }; + } + + static async getLogsByVolumeId(volumeId: string) { + const [book] = await db + .select({ id: books.id }) + .from(books) + .where(eq(books.googleVolumeId, volumeId)) + .limit(1); + if (!book) return null; + return BooksInteractionsRepository.getLogsByBookId(book.id); + } +} diff --git a/apps/api/src/modules/books/services/books-seed.service.ts b/apps/api/src/modules/books/services/books-seed.service.ts new file mode 100644 index 0000000..ddb5849 --- /dev/null +++ b/apps/api/src/modules/books/services/books-seed.service.ts @@ -0,0 +1,59 @@ +import { searchBooks, parseCoverUrl, parsePublishedYear } from "../../../infrastructure/googlebooks/books"; +import { BooksCacheRepository } from "../repositories/books-cache.repository"; +import { BooksArchiveRepository } from "../repositories/books-archive.repository"; + +const SEED_QUERIES = [ + "fiction", + "thriller", + "science", + "biography", + "history", + "fantasy", + "romance", +]; + +const SEED_THRESHOLD = 20; + +export class BooksSeedService { + static async seedIfEmpty(): Promise { + const count = await BooksArchiveRepository.getTotalCount(); + if (count >= SEED_THRESHOLD) return; + + const pages = await Promise.allSettled( + SEED_QUERIES.map((q) => searchBooks(q)), + ); + + const seen = new Set(); + + const volumes = pages.flatMap((result) => { + if (result.status === "rejected") return []; + return result.value.filter((v) => { + if (seen.has(v.id)) return false; + seen.add(v.id); + return true; + }); + }); + + await Promise.allSettled( + volumes.map((v) => { + const vi = v.volumeInfo; + return BooksCacheRepository.upsert({ + googleVolumeId: v.id, + title: vi.title, + subtitle: vi.subtitle ?? null, + authors: vi.authors ?? [], + publisher: vi.publisher ?? null, + publishedDate: vi.publishedDate ?? null, + publishedYear: parsePublishedYear(vi), + pageCount: vi.pageCount ?? null, + language: vi.language ?? null, + categories: vi.categories ?? [], + description: null, + coverImageUrl: parseCoverUrl(vi), + isbn13: null, + googleBooksUrl: null, + }); + }), + ); + } +} diff --git a/apps/api/src/modules/books/types/books.types.ts b/apps/api/src/modules/books/types/books.types.ts new file mode 100644 index 0000000..f6e6976 --- /dev/null +++ b/apps/api/src/modules/books/types/books.types.ts @@ -0,0 +1,126 @@ +import type { BooksArchiveSort, BookDetailReviewSort } from "../dto/books.dto"; + +export type BookDetail = { + id: number; + googleVolumeId: string; + title: string; + subtitle: string | null; + authors: string[]; + publisher: string | null; + publishedDate: string | null; + publishedYear: number | null; + pageCount: number | null; + language: string | null; + categories: string[]; + description: string | null; + coverImageUrl: string | null; + isbn13: string | null; + googleBooksUrl: string | null; +}; + +export type BooksArchiveItem = { + googleVolumeId: string; + title: string; + authors: string[]; + coverImageUrl: string | null; + publishedYear: number | null; + language: string | null; + categories: string[]; + logCount: number; + avgRatingOutOfFive: number | null; + viewerHasLogged: boolean; + viewerWantToRead: boolean; +}; + +export type BookGenreOption = { name: string; count: number }; +export type BookLanguageOption = { code: string; count: number }; + +export type BooksArchiveResponse = { + totalCount: number; + filteredCount: number; + selectedGenre: string | null; + selectedLanguage: string | null; + selectedSort: BooksArchiveSort; + availableGenres: BookGenreOption[]; + page: number; + limit: number; + hasMore: boolean; + nextPage: number | null; + items: BooksArchiveItem[]; +}; + +export type BookLogItem = { + diaryEntryId: string; + readDate: string; + rating: number | null; + reread: boolean; + createdAt: Date; + username: string; + userDisplayName: string | null; + avatarUrl: string | null; + reviewContent: string | null; + reviewContainsSpoilers: boolean | null; + reviewUpdatedAt: Date | null; +}; + +export type BookInteraction = { + liked: boolean; + wantToRead: boolean; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; +}; + +export type BookDetailUserLog = { + diaryEntryId: string | null; + readDate: string | null; + reread: boolean; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; +}; + +export type BookDetailReviewItem = { + id: string; + content: string; + containsSpoilers: boolean; + createdAt: Date; + updatedAt: Date; + readDate: string | null; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; + likeCount: number; + viewerHasLiked: boolean; + author: { + id: string; + username: string; + displayUsername: string | null; + image: string | null; + avatarUrl: string | null; + }; +}; + +export type BookDetailResponse = { + book: BookDetail; + logsCount: number; + reviewCount: number; + userLog: BookDetailUserLog | null; + interaction: BookInteraction | null; + reviewsSort: BookDetailReviewSort; + reviews: BookDetailReviewItem[]; +}; + +export type MyBookLogEntry = { + id: string; + readDate: string; + rating: number | null; + reread: boolean; + bookId: number; + createdAt: Date; + updatedAt: Date; + bookGoogleVolumeId: string; + bookTitle: string; + bookAuthors: string[]; + bookCoverImageUrl: string | null; + bookPublishedYear: number | null; + reviewId: string | null; + reviewContent: string | null; +}; diff --git a/apps/api/src/modules/music/constants/music.constants.ts b/apps/api/src/modules/music/constants/music.constants.ts new file mode 100644 index 0000000..1077277 --- /dev/null +++ b/apps/api/src/modules/music/constants/music.constants.ts @@ -0,0 +1,6 @@ +export const DEFAULT_ARCHIVE_PAGE = 1; +export const DEFAULT_ARCHIVE_LIMIT = 30; +export const MAX_ARCHIVE_LIMIT = 30; +export const DEFAULT_ARCHIVE_SORT = "logs_desc"; +export const DEFAULT_DETAIL_REVIEWS_SORT = "popular"; +export const CACHE_TTL_DAYS = 7; diff --git a/apps/api/src/modules/music/dto/music.dto.ts b/apps/api/src/modules/music/dto/music.dto.ts new file mode 100644 index 0000000..204f48a --- /dev/null +++ b/apps/api/src/modules/music/dto/music.dto.ts @@ -0,0 +1,121 @@ +import { z } from "zod"; +import { isoDateSchema } from "../../../commons/validation/common.schemas"; +import { + DEFAULT_ARCHIVE_LIMIT, + DEFAULT_ARCHIVE_PAGE, + DEFAULT_ARCHIVE_SORT, + MAX_ARCHIVE_LIMIT, +} from "../constants/music.constants"; + +export const musicArchiveSortValues = [ + "logs_desc", + "release_desc", + "release_asc", + "rating_desc", + "title_asc", +] as const; +export type MusicArchiveSort = (typeof musicArchiveSortValues)[number]; + +export const musicDetailReviewSortValues = ["popular", "recent"] as const; +export type MusicDetailReviewSort = (typeof musicDetailReviewSortValues)[number]; + +export const SearchMusicQuerySchema = z.object({ + query: z.string().trim().min(1), +}); +export type SearchMusicQuery = z.input; + +export const MusicParamsSchema = z.object({ + mbid: z.string().uuid(), +}); +export type MusicParams = z.input; + +export const MusicDetailQuerySchema = z.object({ + reviewsSort: z.enum(musicDetailReviewSortValues).optional(), +}); +export type MusicDetailQuery = z.input; + +const optionalText = z.string().optional().transform((v) => v?.trim() || undefined); + +const archiveSortSchema = optionalText.transform((v): MusicArchiveSort => { + return (musicArchiveSortValues as readonly string[]).includes(v ?? "") + ? (v as MusicArchiveSort) + : DEFAULT_ARCHIVE_SORT; +}); + +const archivePageSchema = z.string().optional().transform((v) => { + if (!v) return DEFAULT_ARCHIVE_PAGE; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? Math.max(1, n) : DEFAULT_ARCHIVE_PAGE; +}); + +const archiveLimitSchema = z.string().optional().transform((v) => { + if (!v) return DEFAULT_ARCHIVE_LIMIT; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? Math.max(1, Math.min(MAX_ARCHIVE_LIMIT, n)) : DEFAULT_ARCHIVE_LIMIT; +}); + +export const MusicArchiveQuerySchema = z.object({ + genre: optionalText, + sort: archiveSortSchema, + type: optionalText, + page: archivePageSchema, + limit: archiveLimitSchema, +}); + +export const CreateMusicLogSchema = z.object({ + listenedDate: isoDateSchema, + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).optional(), + relisten: z.boolean().optional(), +}); + +export const UpdateMusicLogSchema = z.object({ + listenedDate: isoDateSchema.optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), + relisten: z.boolean().optional(), +}); + +export const UpdateMusicInteractionSchema = z.object({ + liked: z.boolean().optional(), + wantToListen: z.boolean().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), +}); + +export type CreateMusicLogDto = z.infer; +export type UpdateMusicLogDto = z.infer; +export type UpdateMusicInteractionDto = z.infer; + +export type NormalizedMusicArchiveQuery = { + genre: string | null; + type: string | null; + sort: MusicArchiveSort; + page: number; + limit: number; +}; + +export const normalizeMusicArchiveQuery = ( + query: z.input, +): NormalizedMusicArchiveQuery => { + const parsed = MusicArchiveQuerySchema.safeParse(query); + if (!parsed.success) { + return { genre: null, type: null, sort: DEFAULT_ARCHIVE_SORT, page: DEFAULT_ARCHIVE_PAGE, limit: DEFAULT_ARCHIVE_LIMIT }; + } + return { + genre: parsed.data.genre?.trim() || null, + type: parsed.data.type?.trim() || null, + sort: parsed.data.sort, + page: parsed.data.page, + limit: parsed.data.limit, + }; +}; + +export const normalizeMusicDetailQuery = ( + query: MusicDetailQuery, +): { reviewsSort: MusicDetailReviewSort } => ({ + reviewsSort: query.reviewsSort ?? "popular", +}); + +export const parseMbidParam = (raw: unknown): string | null => { + if (typeof raw !== "string" || !raw.trim()) return null; + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + return uuidRegex.test(raw.trim()) ? raw.trim() : null; +}; diff --git a/apps/api/src/modules/music/music.controller.ts b/apps/api/src/modules/music/music.controller.ts new file mode 100644 index 0000000..976f4ef --- /dev/null +++ b/apps/api/src/modules/music/music.controller.ts @@ -0,0 +1,168 @@ +import type { Request, Response } from "express"; +import { resolveViewerUserIdFromHeaders } from "../../commons/auth/session-resolver.helper"; +import { sendBadRequest, sendValidationError } from "../../commons/http/validation-response.helper"; +import { MusicService } from "./music.service"; +import { + SearchMusicQuerySchema, + MusicArchiveQuerySchema, + MusicDetailQuerySchema, + CreateMusicLogSchema, + UpdateMusicLogSchema, + UpdateMusicInteractionSchema, + normalizeMusicArchiveQuery, + normalizeMusicDetailQuery, + parseMbidParam, + type SearchMusicQuery, + type MusicDetailQuery, +} from "./dto/music.dto"; + +export class MusicController { + static async search( + req: Request<{}, {}, {}, SearchMusicQuery>, + res: Response, + ): Promise { + const parsed = SearchMusicQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const results = await MusicService.search(parsed.data.query); + res.status(200).json(results); + } + + static async getByMbid(req: Request<{ mbid: string }>, res: Response): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const album = await MusicService.findOrCreate(mbid); + if (!album) { + res.status(404).json({ error: "Album not found" }); + return; + } + res.status(200).json(album); + } + + static async getDetailByMbid( + req: Request<{ mbid: string }, {}, {}, MusicDetailQuery>, + res: Response, + ): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const viewerUserId = await resolveViewerUserIdFromHeaders(req.headers); + const detail = await MusicService.getDetail({ + mbid, + viewerUserId, + reviewsSort: normalizeMusicDetailQuery(req.query).reviewsSort, + }); + if (!detail) { + res.status(404).json({ error: "Album not found" }); + return; + } + res.setHeader("Cache-Control", "no-store"); + res.status(200).json(detail); + } + + static async getArchive( + req: Request<{}, {}, {}, Record>, + res: Response, + ): Promise { + const viewerUserId = await resolveViewerUserIdFromHeaders(req.headers); + const archive = await MusicService.getArchive({ + ...normalizeMusicArchiveQuery(req.query), + viewerUserId, + }); + res.setHeader("Cache-Control", "no-store"); + res.status(200).json(archive); + } + + static async getLogsByMbid(req: Request<{ mbid: string }>, res: Response): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const logs = await MusicService.getLogsByMbid(mbid); + if (logs === null) { + res.status(404).json({ error: "Album not found" }); + return; + } + res.status(200).json(logs); + } + + static async getInteraction(req: Request<{ mbid: string }>, res: Response): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const interaction = await MusicService.getInteraction(req.user.id, mbid); + res.status(200).json(interaction); + } + + static async updateInteraction(req: Request<{ mbid: string }>, res: Response): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const parsed = UpdateMusicInteractionSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const result = await MusicService.updateInteraction(req.user.id, mbid, parsed.data); + res.status(200).json(result); + } + + static async createLog(req: Request<{ mbid: string }>, res: Response): Promise { + const mbid = parseMbidParam(req.params.mbid); + if (!mbid) { + sendBadRequest(res, "Invalid album ID"); + return; + } + const parsed = CreateMusicLogSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const result = await MusicService.createLog(req.user.id, mbid, parsed.data); + if (!result) { + res.status(404).json({ error: "Album not found" }); + return; + } + res.status(201).json(result); + } + + static async getMyLogs(_req: Request, res: Response): Promise { + const logs = await MusicService.getMyLogs(_req.user.id); + res.status(200).json(logs); + } + + static async updateLog(req: Request<{ id: string }>, res: Response): Promise { + const parsed = UpdateMusicLogSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError(res, parsed.error); + return; + } + const updated = await MusicService.updateLog(req.params.id, req.user.id, parsed.data); + if (!updated) { + res.status(404).json({ error: "Log not found" }); + return; + } + res.status(200).json(updated); + } + + static async deleteLog(req: Request<{ id: string }>, res: Response): Promise { + const deleted = await MusicService.deleteLog(req.params.id, req.user.id); + if (!deleted) { + res.status(404).json({ error: "Log not found" }); + return; + } + res.status(200).json({ success: true }); + } +} diff --git a/apps/api/src/modules/music/music.entity.ts b/apps/api/src/modules/music/music.entity.ts new file mode 100644 index 0000000..9d7631a --- /dev/null +++ b/apps/api/src/modules/music/music.entity.ts @@ -0,0 +1,68 @@ +import { + pgTable, + serial, + text, + integer, + boolean, + timestamp, + jsonb, + uuid, + unique, +} from "drizzle-orm/pg-core"; +import { user } from "../../infrastructure/database/auth.entity"; + +export const albums = pgTable("album", { + id: serial("id").primaryKey(), + mbid: text("mbid").notNull().unique(), + title: text("title").notNull(), + artistName: text("artist_name").notNull(), + artistMbid: text("artist_mbid"), + coverArtUrl: text("cover_art_url"), + primaryType: text("primary_type"), + secondaryTypes: jsonb("secondary_types").$type(), + firstReleaseDate: text("first_release_date"), + firstReleaseYear: integer("first_release_year"), + genres: jsonb("genres").$type<{ name: string; count: number }[]>(), + disambiguation: text("disambiguation"), + cachedAt: timestamp("cached_at").defaultNow().notNull(), +}); + +export const musicDiaryEntries = pgTable("music_diary_entry", { + id: uuid("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + albumId: integer("album_id") + .notNull() + .references(() => albums.id, { onDelete: "cascade" }), + listenedDate: text("listened_date").notNull(), + rating: integer("rating"), + relisten: boolean("relisten").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); + +export const musicInteractions = pgTable( + "music_interaction", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + albumId: integer("album_id") + .notNull() + .references(() => albums.id, { onDelete: "cascade" }), + liked: boolean("liked").default(false).notNull(), + wantToListen: boolean("want_to_listen").default(false).notNull(), + rating: integer("rating"), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), + }, + (table) => [unique("music_interactions_unique").on(table.userId, table.albumId)], +); diff --git a/apps/api/src/modules/music/music.routes.ts b/apps/api/src/modules/music/music.routes.ts new file mode 100644 index 0000000..96dd5d0 --- /dev/null +++ b/apps/api/src/modules/music/music.routes.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import { asyncHandler } from "../../commons/utils/asyncHandler"; +import { requireAuth } from "../../commons/middlewares/requireAuth"; +import { MusicController } from "./music.controller"; + +const router = Router(); + +router.get("/search", asyncHandler(MusicController.search)); +router.get("/archive", asyncHandler(MusicController.getArchive)); +router.get("/logs", requireAuth, asyncHandler(MusicController.getMyLogs)); +router.put("/logs/:id", requireAuth, asyncHandler(MusicController.updateLog)); +router.delete("/logs/:id", requireAuth, asyncHandler(MusicController.deleteLog)); +router.get("/:mbid/detail", asyncHandler(MusicController.getDetailByMbid)); +router.get("/:mbid/logs", asyncHandler(MusicController.getLogsByMbid)); +router.get("/:mbid/interaction", requireAuth, asyncHandler(MusicController.getInteraction)); +router.put("/:mbid/interaction", requireAuth, asyncHandler(MusicController.updateInteraction)); +router.post("/:mbid/log", requireAuth, asyncHandler(MusicController.createLog)); +router.get("/:mbid", asyncHandler(MusicController.getByMbid)); + +export default router; diff --git a/apps/api/src/modules/music/music.service.ts b/apps/api/src/modules/music/music.service.ts new file mode 100644 index 0000000..9fa1149 --- /dev/null +++ b/apps/api/src/modules/music/music.service.ts @@ -0,0 +1,123 @@ +import { searchAlbums } from "../../infrastructure/musicbrainz/albums"; +import type { IReleaseGroup } from "../../infrastructure/musicbrainz/albums"; +import { db } from "../../infrastructure/database/db"; +import { activities } from "../social/social.entity"; +import type { + MusicArchiveSort, + MusicDetailReviewSort, + UpdateMusicInteractionDto, + UpdateMusicLogDto, + CreateMusicLogDto, +} from "./dto/music.dto"; +import { MusicCacheService } from "./services/music-cache.service"; +import { MusicArchiveService } from "./services/music-archive.service"; +import { MusicDetailService } from "./services/music-detail.service"; +import { MusicInteractionsRepository } from "./repositories/music-interactions.repository"; +import type { NormalizedMusicArchiveQuery } from "./dto/music.dto"; + +const RATING_FACTOR = 2; + +export class MusicService { + static async search(query: string): Promise { + return searchAlbums(query); + } + + static async findOrCreate(mbid: string) { + return MusicCacheService.findOrCreate(mbid); + } + + static async getDetail(input: { + mbid: string; + viewerUserId?: string | null; + reviewsSort: MusicDetailReviewSort; + }) { + return MusicDetailService.getDetail(input); + } + + static async getArchive(input: NormalizedMusicArchiveQuery & { viewerUserId?: string | null }) { + return MusicArchiveService.getArchive(input); + } + + static async getLogsByMbid(mbid: string) { + return MusicDetailService.getLogsByMbid(mbid); + } + + static async getInteraction(userId: string, mbid: string) { + const album = await MusicCacheService.findOrCreate(mbid); + if (!album) return null; + const row = await MusicInteractionsRepository.getInteraction(userId, album.id); + if (!row) return { liked: false, wantToListen: false, ratingOutOfTen: null, ratingOutOfFive: null }; + return { + liked: row.liked, + wantToListen: row.wantToListen, + ratingOutOfTen: row.rating, + ratingOutOfFive: row.rating !== null ? row.rating / RATING_FACTOR : null, + }; + } + + static async updateInteraction(userId: string, mbid: string, input: UpdateMusicInteractionDto) { + const album = await MusicCacheService.findOrCreate(mbid); + if (!album) return null; + const ratingOutOfTen = + input.ratingOutOfFive !== undefined + ? input.ratingOutOfFive === null ? null : Math.round(input.ratingOutOfFive * RATING_FACTOR) + : undefined; + return MusicInteractionsRepository.upsertInteraction(userId, album.id, { + liked: input.liked, + wantToListen: input.wantToListen, + rating: ratingOutOfTen, + }); + } + + static async createLog(userId: string, mbid: string, input: CreateMusicLogDto) { + const album = await MusicCacheService.findOrCreate(mbid); + if (!album) return null; + const rating = input.ratingOutOfFive !== undefined + ? Math.round(input.ratingOutOfFive * RATING_FACTOR) + : null; + const entry = await MusicInteractionsRepository.createLog(userId, album.id, { + listenedDate: input.listenedDate, + rating, + relisten: input.relisten ?? false, + }); + if (entry) { + await db.insert(activities).values({ + userId, + type: "diary_entry", + entityId: entry.id, + metadata: JSON.stringify({ + mediaType: "album", + mbid: album.mbid, + title: album.title, + artistName: album.artistName, + coverArtUrl: album.coverArtUrl ?? null, + releaseYear: album.firstReleaseYear ?? null, + rating: entry.rating ?? null, + relisten: entry.relisten, + hasReview: false, + }), + }).catch(() => undefined); + } + return { entry, album }; + } + + static async getMyLogs(userId: string) { + return MusicInteractionsRepository.getMyLogs(userId); + } + + static async updateLog(id: string, userId: string, input: UpdateMusicLogDto) { + const ratingOutOfTen = + input.ratingOutOfFive !== undefined + ? input.ratingOutOfFive === null ? null : Math.round(input.ratingOutOfFive * RATING_FACTOR) + : undefined; + return MusicInteractionsRepository.updateLog(id, userId, { + listenedDate: input.listenedDate, + rating: ratingOutOfTen, + relisten: input.relisten, + }); + } + + static async deleteLog(id: string, userId: string) { + return MusicInteractionsRepository.deleteLog(id, userId); + } +} diff --git a/apps/api/src/modules/music/repositories/music-archive.repository.ts b/apps/api/src/modules/music/repositories/music-archive.repository.ts new file mode 100644 index 0000000..f58f3eb --- /dev/null +++ b/apps/api/src/modules/music/repositories/music-archive.repository.ts @@ -0,0 +1,113 @@ +import { SQL, asc, desc, eq, ilike, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { albums, musicDiaryEntries, musicInteractions } from "../music.entity"; +import type { MusicArchiveSort } from "../dto/music.dto"; + +export class MusicArchiveRepository { + static async getArchiveRows(input: { + genre: string | null; + type: string | null; + sort: MusicArchiveSort; + page: number; + limit: number; + viewerUserId?: string | null; + }) { + const orderBy = { + logs_desc: desc(sql`count(${musicDiaryEntries.id})`), + release_desc: desc(albums.firstReleaseYear), + release_asc: asc(albums.firstReleaseYear), + rating_desc: desc(sql`avg(${musicDiaryEntries.rating})`), + title_asc: asc(albums.title), + }[input.sort] ?? desc(sql`count(${musicDiaryEntries.id})`); + + const conditions: SQL[] = []; + + if (input.genre) { + conditions.push( + sql`EXISTS ( + SELECT 1 FROM jsonb_array_elements(${albums.genres}) g + WHERE lower(g->>'name') = lower(${input.genre}) + )`, + ); + } + + if (input.type) { + conditions.push(ilike(albums.primaryType, input.type)); + } + + const whereClause = + conditions.length > 0 + ? conditions.reduce((acc, cond) => sql`${acc} AND ${cond}`) + : undefined; + + const offset = (input.page - 1) * input.limit; + + const baseQuery = db + .select({ + mbid: albums.mbid, + title: albums.title, + artistName: albums.artistName, + coverArtUrl: albums.coverArtUrl, + primaryType: albums.primaryType, + firstReleaseYear: albums.firstReleaseYear, + genres: albums.genres, + logCount: sql`count(${musicDiaryEntries.id})::int`.as("logCount"), + avgRatingOutOfTen: sql`avg(${musicDiaryEntries.rating})::double precision`.as("avgRatingOutOfTen"), + }) + .from(albums) + .leftJoin(musicDiaryEntries, eq(musicDiaryEntries.albumId, albums.id)); + + const rows = await (whereClause ? baseQuery.where(whereClause) : baseQuery) + .groupBy(albums.id) + .orderBy(orderBy) + .limit(input.limit) + .offset(offset); + + return rows; + } + + static async getTotalCount(): Promise { + const [row] = await db.select({ count: sql`count(*)::int` }).from(albums); + return row?.count ?? 0; + } + + static async getTopGenres(limit = 50): Promise<{ name: string; count: number }[]> { + const rows = await db.select({ genres: albums.genres }).from(albums); + const genreMap = new Map(); + for (const row of rows) { + for (const g of row.genres ?? []) { + genreMap.set(g.name, (genreMap.get(g.name) ?? 0) + 1); + } + } + return [...genreMap.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, limit); + } + + static async getViewerLoggedMbids(userId: string, mbids: string[]) { + if (mbids.length === 0) return []; + const rows = await db + .select({ mbid: albums.mbid }) + .from(musicDiaryEntries) + .innerJoin(albums, eq(albums.id, musicDiaryEntries.albumId)) + .where(eq(musicDiaryEntries.userId, userId)) + .groupBy(albums.mbid); + const set = new Set(rows.map((r) => r.mbid)); + return mbids.filter((m) => set.has(m)); + } + + static async getViewerWantToListenMbids(userId: string, mbids: string[]) { + if (mbids.length === 0) return []; + const rows = await db + .select({ mbid: albums.mbid }) + .from(musicInteractions) + .innerJoin(albums, eq(albums.id, musicInteractions.albumId)) + .where( + sql`${musicInteractions.userId} = ${userId} AND ${musicInteractions.wantToListen} = true`, + ) + .groupBy(albums.mbid); + const set = new Set(rows.map((r) => r.mbid)); + return mbids.filter((m) => set.has(m)); + } +} diff --git a/apps/api/src/modules/music/repositories/music-cache.repository.ts b/apps/api/src/modules/music/repositories/music-cache.repository.ts new file mode 100644 index 0000000..091788f --- /dev/null +++ b/apps/api/src/modules/music/repositories/music-cache.repository.ts @@ -0,0 +1,59 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { albums } from "../music.entity"; + +export class MusicCacheRepository { + static async findByMbid(mbid: string) { + const [row] = await db.select().from(albums).where(eq(albums.mbid, mbid)).limit(1); + return row ?? null; + } + + static async upsert(input: { + mbid: string; + title: string; + artistName: string; + artistMbid: string | null; + coverArtUrl: string | null; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + firstReleaseYear: number | null; + genres: { name: string; count: number }[]; + disambiguation: string | null; + }) { + const [inserted] = await db + .insert(albums) + .values({ + mbid: input.mbid, + title: input.title, + artistName: input.artistName, + artistMbid: input.artistMbid, + coverArtUrl: input.coverArtUrl, + primaryType: input.primaryType, + secondaryTypes: input.secondaryTypes, + firstReleaseDate: input.firstReleaseDate, + firstReleaseYear: input.firstReleaseYear, + genres: input.genres, + disambiguation: input.disambiguation, + }) + .onConflictDoUpdate({ + target: albums.mbid, + set: { + title: input.title, + artistName: input.artistName, + artistMbid: input.artistMbid, + coverArtUrl: input.coverArtUrl, + primaryType: input.primaryType, + secondaryTypes: input.secondaryTypes, + firstReleaseDate: input.firstReleaseDate, + firstReleaseYear: input.firstReleaseYear, + genres: input.genres, + disambiguation: input.disambiguation, + cachedAt: new Date(), + }, + }) + .returning(); + + return inserted ?? null; + } +} diff --git a/apps/api/src/modules/music/repositories/music-interactions.repository.ts b/apps/api/src/modules/music/repositories/music-interactions.repository.ts new file mode 100644 index 0000000..1073494 --- /dev/null +++ b/apps/api/src/modules/music/repositories/music-interactions.repository.ts @@ -0,0 +1,176 @@ +import { and, desc, eq, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { user } from "../../../infrastructure/database/auth.entity"; +import { profiles } from "../../users/users.entity"; +import { reviews } from "../../reviews/reviews.entity"; +import { albums, musicDiaryEntries, musicInteractions } from "../music.entity"; + +export class MusicInteractionsRepository { + static async getInteraction(userId: string, albumId: number) { + const [row] = await db + .select() + .from(musicInteractions) + .where(and(eq(musicInteractions.userId, userId), eq(musicInteractions.albumId, albumId))) + .limit(1); + return row ?? null; + } + + static async upsertInteraction( + userId: string, + albumId: number, + input: { liked?: boolean; wantToListen?: boolean; rating?: number | null }, + ) { + const [row] = await db + .insert(musicInteractions) + .values({ + userId, + albumId, + liked: input.liked ?? false, + wantToListen: input.wantToListen ?? false, + rating: input.rating ?? null, + }) + .onConflictDoUpdate({ + target: [musicInteractions.userId, musicInteractions.albumId], + set: { + ...(input.liked !== undefined && { liked: input.liked }), + ...(input.wantToListen !== undefined && { wantToListen: input.wantToListen }), + ...(input.rating !== undefined && { rating: input.rating }), + updatedAt: new Date(), + }, + }) + .returning(); + return row ?? null; + } + + static async createLog(userId: string, albumId: number, input: { + listenedDate: string; + rating: number | null; + relisten: boolean; + }) { + const [row] = await db + .insert(musicDiaryEntries) + .values({ userId, albumId, ...input }) + .returning(); + return row ?? null; + } + + static async findLogById(id: string, userId: string) { + const [row] = await db + .select() + .from(musicDiaryEntries) + .where(and(eq(musicDiaryEntries.id, id), eq(musicDiaryEntries.userId, userId))) + .limit(1); + return row ?? null; + } + + static async updateLog(id: string, userId: string, input: { + listenedDate?: string; + rating?: number | null; + relisten?: boolean; + }) { + const [row] = await db + .update(musicDiaryEntries) + .set({ + ...(input.listenedDate !== undefined && { listenedDate: input.listenedDate }), + ...(input.rating !== undefined && { rating: input.rating }), + ...(input.relisten !== undefined && { relisten: input.relisten }), + }) + .where(and(eq(musicDiaryEntries.id, id), eq(musicDiaryEntries.userId, userId))) + .returning(); + return row ?? null; + } + + static async deleteLog(id: string, userId: string) { + const [row] = await db + .delete(musicDiaryEntries) + .where(and(eq(musicDiaryEntries.id, id), eq(musicDiaryEntries.userId, userId))) + .returning({ id: musicDiaryEntries.id }); + return row ?? null; + } + + static async getMyLogs(userId: string) { + return db + .select({ + id: musicDiaryEntries.id, + listenedDate: musicDiaryEntries.listenedDate, + rating: musicDiaryEntries.rating, + relisten: musicDiaryEntries.relisten, + albumId: musicDiaryEntries.albumId, + createdAt: musicDiaryEntries.createdAt, + updatedAt: musicDiaryEntries.updatedAt, + albumMbid: albums.mbid, + albumTitle: albums.title, + albumArtistName: albums.artistName, + albumCoverArtUrl: albums.coverArtUrl, + albumFirstReleaseYear: albums.firstReleaseYear, + reviewId: reviews.id, + reviewContent: reviews.content, + }) + .from(musicDiaryEntries) + .innerJoin(albums, eq(albums.id, musicDiaryEntries.albumId)) + .leftJoin( + reviews, + and( + eq(reviews.userId, musicDiaryEntries.userId), + eq(reviews.mediaType, "album"), + eq(reviews.mediaSourceId, albums.mbid), + ), + ) + .where(eq(musicDiaryEntries.userId, userId)) + .orderBy(desc(musicDiaryEntries.listenedDate), desc(musicDiaryEntries.createdAt)); + } + + static async getLogsByAlbumId(albumId: number) { + return db + .select({ + diaryEntryId: musicDiaryEntries.id, + listenedDate: musicDiaryEntries.listenedDate, + rating: musicDiaryEntries.rating, + relisten: musicDiaryEntries.relisten, + createdAt: musicDiaryEntries.createdAt, + username: user.username, + userDisplayName: user.name, + avatarUrl: profiles.avatarUrl, + reviewContent: reviews.content, + reviewContainsSpoilers: reviews.containsSpoilers, + reviewUpdatedAt: reviews.updatedAt, + }) + .from(musicDiaryEntries) + .innerJoin(user, eq(user.id, musicDiaryEntries.userId)) + .innerJoin(profiles, eq(profiles.userId, musicDiaryEntries.userId)) + .leftJoin( + reviews, + and( + eq(reviews.userId, musicDiaryEntries.userId), + eq(reviews.mediaType, "album"), + eq(reviews.mediaSourceId, albums.mbid), + ), + ) + .innerJoin(albums, eq(albums.id, musicDiaryEntries.albumId)) + .where(eq(musicDiaryEntries.albumId, albumId)) + .orderBy(desc(musicDiaryEntries.createdAt)); + } + + static async getLogCount(albumId: number): Promise { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(musicDiaryEntries) + .where(eq(musicDiaryEntries.albumId, albumId)); + return row?.count ?? 0; + } + + static async getViewerLog(userId: string, albumId: number) { + const [row] = await db + .select({ + id: musicDiaryEntries.id, + listenedDate: musicDiaryEntries.listenedDate, + relisten: musicDiaryEntries.relisten, + rating: musicDiaryEntries.rating, + }) + .from(musicDiaryEntries) + .where(and(eq(musicDiaryEntries.userId, userId), eq(musicDiaryEntries.albumId, albumId))) + .orderBy(desc(musicDiaryEntries.listenedDate)) + .limit(1); + return row ?? null; + } +} diff --git a/apps/api/src/modules/music/services/music-archive.service.ts b/apps/api/src/modules/music/services/music-archive.service.ts new file mode 100644 index 0000000..a4b749c --- /dev/null +++ b/apps/api/src/modules/music/services/music-archive.service.ts @@ -0,0 +1,57 @@ +import { MusicArchiveRepository } from "../repositories/music-archive.repository"; +import { MusicSeedService } from "./music-seed.service"; +import type { NormalizedMusicArchiveQuery } from "../dto/music.dto"; +import type { MusicArchiveResponse, MusicArchiveItem } from "../types/music.types"; + +export class MusicArchiveService { + static async getArchive(input: NormalizedMusicArchiveQuery & { viewerUserId?: string | null }): Promise { + await MusicSeedService.seedIfEmpty().catch(() => undefined); + + const [rows, totalCount, availableGenres] = await Promise.all([ + MusicArchiveRepository.getArchiveRows(input), + MusicArchiveRepository.getTotalCount(), + MusicArchiveRepository.getTopGenres(), + ]); + + const mbids = rows.map((r) => r.mbid); + const [loggedMbids, wantToListenMbids] = input.viewerUserId + ? await Promise.all([ + MusicArchiveRepository.getViewerLoggedMbids(input.viewerUserId, mbids), + MusicArchiveRepository.getViewerWantToListenMbids(input.viewerUserId, mbids), + ]) + : [[], []]; + + const loggedSet = new Set(loggedMbids); + const wantToListenSet = new Set(wantToListenMbids); + + const items: MusicArchiveItem[] = rows.map((r) => ({ + mbid: r.mbid, + title: r.title, + artistName: r.artistName, + coverArtUrl: r.coverArtUrl, + primaryType: r.primaryType, + firstReleaseYear: r.firstReleaseYear, + genres: (r.genres ?? []) as { name: string; count: number }[], + logCount: r.logCount, + avgRatingOutOfFive: + r.avgRatingOutOfTen !== null ? Math.round((r.avgRatingOutOfTen / 2) * 10) / 10 : null, + viewerHasLogged: loggedSet.has(r.mbid), + viewerWantToListen: wantToListenSet.has(r.mbid), + })); + + const hasMore = items.length === input.limit; + + return { + totalCount, + filteredCount: totalCount, + selectedGenre: input.genre, + selectedSort: input.sort, + availableGenres: availableGenres.slice(0, 30), + page: input.page, + limit: input.limit, + hasMore, + nextPage: hasMore ? input.page + 1 : null, + items, + }; + } +} diff --git a/apps/api/src/modules/music/services/music-cache.service.ts b/apps/api/src/modules/music/services/music-cache.service.ts new file mode 100644 index 0000000..84b9f1d --- /dev/null +++ b/apps/api/src/modules/music/services/music-cache.service.ts @@ -0,0 +1,34 @@ +import { + getReleaseGroupDetail, + getCoverArtUrl, + extractTopGenres, + buildArtistName, + parseFirstReleaseYear, +} from "../../../infrastructure/musicbrainz/albums"; +import { MusicCacheRepository } from "../repositories/music-cache.repository"; + +export class MusicCacheService { + static async findOrCreate(mbid: string) { + const existing = await MusicCacheRepository.findByMbid(mbid); + if (existing) { + return existing; + } + + const rg = await getReleaseGroupDetail(mbid); + const coverArtUrl = await getCoverArtUrl(mbid).catch(() => null); + + return MusicCacheRepository.upsert({ + mbid: rg.id, + title: rg.title, + artistName: buildArtistName(rg), + artistMbid: rg["artist-credit"]?.[0]?.artist?.id ?? null, + coverArtUrl, + primaryType: rg["primary-type"] ?? null, + secondaryTypes: rg["secondary-types"] ?? [], + firstReleaseDate: rg["first-release-date"] ?? null, + firstReleaseYear: parseFirstReleaseYear(rg), + genres: extractTopGenres(rg), + disambiguation: rg.disambiguation ?? null, + }); + } +} diff --git a/apps/api/src/modules/music/services/music-detail.service.ts b/apps/api/src/modules/music/services/music-detail.service.ts new file mode 100644 index 0000000..3002b0e --- /dev/null +++ b/apps/api/src/modules/music/services/music-detail.service.ts @@ -0,0 +1,160 @@ +import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { user } from "../../../infrastructure/database/auth.entity"; +import { profiles } from "../../users/users.entity"; +import { reviews, reviewLikes } from "../../reviews/reviews.entity"; +import { albums, musicDiaryEntries } from "../music.entity"; +import { MusicCacheService } from "./music-cache.service"; +import { MusicInteractionsRepository } from "../repositories/music-interactions.repository"; +import type { MusicDetailReviewSort } from "../dto/music.dto"; +import type { + MusicDetailResponse, + MusicDetailReviewItem, + MusicDetailUserLog, + MusicInteraction, +} from "../types/music.types"; + +export class MusicDetailService { + static async getDetail(input: { + mbid: string; + viewerUserId?: string | null; + reviewsSort: MusicDetailReviewSort; + }): Promise { + const album = await MusicCacheService.findOrCreate(input.mbid); + if (!album) return null; + + const reviewRows = await db + .select({ + id: reviews.id, + userId: reviews.userId, + content: reviews.content, + containsSpoilers: reviews.containsSpoilers, + createdAt: reviews.createdAt, + updatedAt: reviews.updatedAt, + authorUsername: user.username, + authorDisplayUsername: user.displayUsername, + authorImage: user.image, + authorAvatarUrl: profiles.avatarUrl, + }) + .from(reviews) + .innerJoin(user, eq(user.id, reviews.userId)) + .leftJoin(profiles, eq(profiles.userId, reviews.userId)) + .where(and(eq(reviews.mediaType, "album"), eq(reviews.mediaSourceId, input.mbid))) + .orderBy(desc(reviews.createdAt)); + + const reviewIds = reviewRows.map((r) => r.id); + + const [likeCounts, viewerLikedRows, logsCount, viewerLogRow, viewerInteractionRow] = + await Promise.all([ + reviewIds.length > 0 + ? db + .select({ + reviewId: reviewLikes.reviewId, + likeCount: sql`count(*)::int`.as("likeCount"), + }) + .from(reviewLikes) + .where(inArray(reviewLikes.reviewId, reviewIds)) + .groupBy(reviewLikes.reviewId) + : Promise.resolve([]), + input.viewerUserId && reviewIds.length > 0 + ? db + .select({ reviewId: reviewLikes.reviewId }) + .from(reviewLikes) + .where( + and( + eq(reviewLikes.userId, input.viewerUserId), + inArray(reviewLikes.reviewId, reviewIds), + ), + ) + : Promise.resolve([]), + MusicInteractionsRepository.getLogCount(album.id), + input.viewerUserId + ? MusicInteractionsRepository.getViewerLog(input.viewerUserId, album.id) + : Promise.resolve(null), + input.viewerUserId + ? MusicInteractionsRepository.getInteraction(input.viewerUserId, album.id) + : Promise.resolve(null), + ]); + + const likeCountMap = new Map(likeCounts.map((r) => [r.reviewId, r.likeCount])); + const viewerLikedSet = new Set(viewerLikedRows.map((r) => r.reviewId)); + + const reviewItems: MusicDetailReviewItem[] = reviewRows.map((r) => ({ + id: r.id, + content: r.content, + containsSpoilers: r.containsSpoilers, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + listenedDate: null, + ratingOutOfTen: null, + ratingOutOfFive: null, + likeCount: likeCountMap.get(r.id) ?? 0, + viewerHasLiked: viewerLikedSet.has(r.id), + author: { + id: r.userId, + username: r.authorUsername, + displayUsername: r.authorDisplayUsername ?? null, + image: r.authorImage ?? null, + avatarUrl: r.authorAvatarUrl ?? null, + }, + })); + + const sortedReviews = + input.reviewsSort === "popular" + ? [...reviewItems].sort((a, b) => b.likeCount - a.likeCount) + : reviewItems; + + const userLog: MusicDetailUserLog | null = viewerLogRow + ? { + diaryEntryId: viewerLogRow.id, + listenedDate: viewerLogRow.listenedDate, + relisten: viewerLogRow.relisten, + ratingOutOfTen: viewerLogRow.rating, + ratingOutOfFive: viewerLogRow.rating !== null ? viewerLogRow.rating / 2 : null, + } + : null; + + const interaction: MusicInteraction | null = viewerInteractionRow + ? { + liked: viewerInteractionRow.liked, + wantToListen: viewerInteractionRow.wantToListen, + ratingOutOfTen: viewerInteractionRow.rating, + ratingOutOfFive: + viewerInteractionRow.rating !== null ? viewerInteractionRow.rating / 2 : null, + } + : null; + + return { + album: { + id: album.id, + mbid: album.mbid, + title: album.title, + artistName: album.artistName, + artistMbid: album.artistMbid, + coverArtUrl: album.coverArtUrl, + primaryType: album.primaryType, + secondaryTypes: (album.secondaryTypes ?? []) as string[], + firstReleaseDate: album.firstReleaseDate, + firstReleaseYear: album.firstReleaseYear, + genres: (album.genres ?? []) as { name: string; count: number }[], + disambiguation: album.disambiguation, + }, + logsCount, + reviewCount: reviewRows.length, + userLog, + interaction, + reviewsSort: input.reviewsSort, + reviews: sortedReviews, + }; + } + + static async getLogsByMbid(mbid: string) { + const [album] = await db + .select({ id: albums.id }) + .from(albums) + .where(eq(albums.mbid, mbid)) + .limit(1); + if (!album) return null; + return MusicInteractionsRepository.getLogsByAlbumId(album.id); + } +} diff --git a/apps/api/src/modules/music/services/music-seed.service.ts b/apps/api/src/modules/music/services/music-seed.service.ts new file mode 100644 index 0000000..59a609a --- /dev/null +++ b/apps/api/src/modules/music/services/music-seed.service.ts @@ -0,0 +1,106 @@ +import { + buildArtistName, + parseFirstReleaseYear, + extractTopGenres, + searchAlbums, +} from "../../../infrastructure/musicbrainz/albums"; +import { MusicCacheRepository } from "../repositories/music-cache.repository"; +import { MusicArchiveRepository } from "../repositories/music-archive.repository"; + +const SEED_THRESHOLD = 10; + +type StaticAlbum = { + mbid: string; + title: string; + artistName: string; + primaryType: string; + firstReleaseDate: string; + genres: { name: string; count: number }[]; +}; + +const STATIC_SEED: StaticAlbum[] = [ + { mbid: "b84ee12a-09ef-421b-82de-0441a926375b", title: "Abbey Road", artistName: "The Beatles", primaryType: "Album", firstReleaseDate: "1969-09-26", genres: [{ name: "rock", count: 10 }, { name: "pop", count: 8 }] }, + { mbid: "f5093c06-23e3-404f-aeaa-37d6da7de5a4", title: "The Dark Side of the Moon", artistName: "Pink Floyd", primaryType: "Album", firstReleaseDate: "1973-03-01", genres: [{ name: "progressive rock", count: 10 }, { name: "rock", count: 8 }] }, + { mbid: "fdda9d45-4745-4c28-b8ba-3f82c8ca6659", title: "Thriller", artistName: "Michael Jackson", primaryType: "Album", firstReleaseDate: "1982-11-30", genres: [{ name: "pop", count: 10 }, { name: "r&b", count: 8 }] }, + { mbid: "47d8c9d0-b05a-44ee-9b07-d47e3c41278c", title: "OK Computer", artistName: "Radiohead", primaryType: "Album", firstReleaseDate: "1997-05-21", genres: [{ name: "alternative rock", count: 10 }, { name: "art rock", count: 7 }] }, + { mbid: "5c017ae2-ec31-4b7b-aace-32af02a8dd38", title: "Led Zeppelin IV", artistName: "Led Zeppelin", primaryType: "Album", firstReleaseDate: "1971-11-08", genres: [{ name: "hard rock", count: 10 }, { name: "blues rock", count: 7 }] }, + { mbid: "9ab0cb55-5429-4b33-9b51-a1eb6a4d7291", title: "Rumours", artistName: "Fleetwood Mac", primaryType: "Album", firstReleaseDate: "1977-02-04", genres: [{ name: "soft rock", count: 10 }, { name: "pop rock", count: 8 }] }, + { mbid: "1b022e01-4da6-387b-8658-8678046e4cef", title: "The College Dropout", artistName: "Kanye West", primaryType: "Album", firstReleaseDate: "2004-02-10", genres: [{ name: "hip hop", count: 10 }, { name: "rap", count: 9 }] }, + { mbid: "a7df3ddf-5072-484f-9d48-a15e0fa0b84f", title: "Random Access Memories", artistName: "Daft Punk", primaryType: "Album", firstReleaseDate: "2013-05-17", genres: [{ name: "electronic", count: 10 }, { name: "funk", count: 7 }] }, + { mbid: "6e5f5b2e-bfb2-408b-a55f-0e9d2ea7df84", title: "Back to Black", artistName: "Amy Winehouse", primaryType: "Album", firstReleaseDate: "2006-10-27", genres: [{ name: "soul", count: 10 }, { name: "r&b", count: 8 }] }, + { mbid: "d6da44b5-70cf-3787-8046-9be1978ef35f", title: "Kind of Blue", artistName: "Miles Davis", primaryType: "Album", firstReleaseDate: "1959-08-17", genres: [{ name: "jazz", count: 10 }, { name: "modal jazz", count: 8 }] }, + { mbid: "d5e4ff07-a7d9-479a-b1b9-9f58f6ad44be", title: "Blue", artistName: "Joni Mitchell", primaryType: "Album", firstReleaseDate: "1971-06-22", genres: [{ name: "folk", count: 10 }, { name: "singer-songwriter", count: 8 }] }, + { mbid: "2b8d9de0-ef21-3267-a494-6e574f948c20", title: "The Bends", artistName: "Radiohead", primaryType: "Album", firstReleaseDate: "1995-03-13", genres: [{ name: "alternative rock", count: 10 }, { name: "britpop", count: 5 }] }, +]; + +export class MusicSeedService { + static async seedIfEmpty(): Promise { + const count = await MusicArchiveRepository.getTotalCount(); + if (count >= SEED_THRESHOLD) return; + + // Try MusicBrainz search first; fall back to static seed if unavailable + const fromMb = await MusicSeedService.seedFromMusicBrainz().catch(() => 0); + if (fromMb === 0) { + await MusicSeedService.seedFromStaticList(); + } + } + + private static async seedFromMusicBrainz(): Promise { + const pages = await Promise.allSettled([ + searchAlbums("type:Album"), + searchAlbums("type:Album tag:rock"), + ]); + + const seen = new Set(); + const toInsert = pages.flatMap((result) => { + if (result.status === "rejected") return []; + return result.value.filter((rg) => { + if (seen.has(rg.id)) return false; + seen.add(rg.id); + return true; + }); + }); + + if (toInsert.length === 0) return 0; + + await Promise.allSettled( + toInsert.map((rg) => + MusicCacheRepository.upsert({ + mbid: rg.id, + title: rg.title, + artistName: buildArtistName(rg), + artistMbid: rg["artist-credit"]?.[0]?.artist?.id ?? null, + coverArtUrl: null, + primaryType: rg["primary-type"] ?? null, + secondaryTypes: [], + firstReleaseDate: rg["first-release-date"] ?? null, + firstReleaseYear: parseFirstReleaseYear(rg), + genres: extractTopGenres(rg), + disambiguation: rg.disambiguation ?? null, + }), + ), + ); + + return toInsert.length; + } + + private static async seedFromStaticList(): Promise { + await Promise.allSettled( + STATIC_SEED.map((album) => + MusicCacheRepository.upsert({ + mbid: album.mbid, + title: album.title, + artistName: album.artistName, + artistMbid: null, + coverArtUrl: null, + primaryType: album.primaryType, + secondaryTypes: [], + firstReleaseDate: album.firstReleaseDate, + firstReleaseYear: Number.parseInt(album.firstReleaseDate.slice(0, 4), 10), + genres: album.genres, + disambiguation: null, + }), + ), + ); + } +} diff --git a/apps/api/src/modules/music/types/music.types.ts b/apps/api/src/modules/music/types/music.types.ts new file mode 100644 index 0000000..38f1d5e --- /dev/null +++ b/apps/api/src/modules/music/types/music.types.ts @@ -0,0 +1,123 @@ +import type { MusicArchiveSort, MusicDetailReviewSort } from "../dto/music.dto"; + +export type AlbumGenreTag = { name: string; count: number }; + +export type AlbumDetail = { + id: number; + mbid: string; + title: string; + artistName: string; + artistMbid: string | null; + coverArtUrl: string | null; + primaryType: string | null; + secondaryTypes: string[]; + firstReleaseDate: string | null; + firstReleaseYear: number | null; + genres: AlbumGenreTag[]; + disambiguation: string | null; +}; + +export type MusicArchiveItem = { + mbid: string; + title: string; + artistName: string; + coverArtUrl: string | null; + primaryType: string | null; + firstReleaseYear: number | null; + genres: AlbumGenreTag[]; + logCount: number; + avgRatingOutOfFive: number | null; + viewerHasLogged: boolean; + viewerWantToListen: boolean; +}; + +export type GenreOption = { name: string; count: number }; + +export type MusicArchiveResponse = { + totalCount: number; + filteredCount: number; + selectedGenre: string | null; + selectedSort: MusicArchiveSort; + availableGenres: GenreOption[]; + page: number; + limit: number; + hasMore: boolean; + nextPage: number | null; + items: MusicArchiveItem[]; +}; + +export type MusicLogItem = { + diaryEntryId: string; + listenedDate: string; + rating: number | null; + relisten: boolean; + createdAt: Date; + username: string; + userDisplayName: string | null; + avatarUrl: string | null; + reviewContent: string | null; + reviewContainsSpoilers: boolean | null; + reviewUpdatedAt: Date | null; +}; + +export type MusicInteraction = { + liked: boolean; + wantToListen: boolean; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; +}; + +export type MusicDetailUserLog = { + diaryEntryId: string | null; + listenedDate: string | null; + relisten: boolean; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; +}; + +export type MusicDetailReviewItem = { + id: string; + content: string; + containsSpoilers: boolean; + createdAt: Date; + updatedAt: Date; + listenedDate: string | null; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; + likeCount: number; + viewerHasLiked: boolean; + author: { + id: string; + username: string; + displayUsername: string | null; + image: string | null; + avatarUrl: string | null; + }; +}; + +export type MusicDetailResponse = { + album: AlbumDetail; + logsCount: number; + reviewCount: number; + userLog: MusicDetailUserLog | null; + interaction: MusicInteraction | null; + reviewsSort: MusicDetailReviewSort; + reviews: MusicDetailReviewItem[]; +}; + +export type MyMusicLogEntry = { + id: string; + listenedDate: string; + rating: number | null; + relisten: boolean; + albumId: number; + createdAt: Date; + updatedAt: Date; + albumMbid: string; + albumTitle: string; + albumArtistName: string; + albumCoverArtUrl: string | null; + albumFirstReleaseYear: number | null; + reviewId: string | null; + reviewContent: string | null; +}; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 5b86f33..dfe5868 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -145,6 +145,8 @@ --module-cinema: #00ff88; --module-serial: #00cfff; + --module-music: #c084fc; + --module-book: #fb923c; --module-neutral: #9ca3af; --profile-shell-bg: color-mix(in srgb, var(--background) 88%, black); @@ -291,6 +293,8 @@ --module-cinema: #00ff88; --module-serial: #00cfff; + --module-music: #c084fc; + --module-book: #fb923c; --module-neutral: #9ca3af; } @@ -408,6 +412,8 @@ --module-cinema: #8ec07c; --module-serial: #83a598; + --module-music: #d3869b; + --module-book: #fe8019; --module-neutral: #a89984; } From 1522fd2bb911a61d58c97add88c0585cc4265753 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:21:15 +0300 Subject: [PATCH 2/7] feat(music+books): add frontend modules, routes, and search integration Full UI for Music (MusicBrainz) and Books (Google Books): archive pages, detail pages, log modals, sidebar actions, and React Query hooks. Registers /music and /books route trees. Adds both domains to the global search dialog and navbar. --- .../layout/navbar/navbar.constants.ts | 16 +- apps/web/src/features/books/api.ts | 32 +++ apps/web/src/features/books/api/requests.ts | 160 +++++++++++++ apps/web/src/features/books/api/schemas.ts | 171 ++++++++++++++ apps/web/src/features/books/api/types.ts | 51 +++++ .../books/components/BookDetailPage.tsx | 132 +++++++++++ .../books/components/BooksArchivePage.tsx | 185 +++++++++++++++ .../books/components/LogBookModal.tsx | 188 +++++++++++++++ .../books-archive/ArchiveMenuRadioOption.tsx | 30 +++ .../books-archive/ArchiveMenuTrigger.tsx | 63 +++++ .../books-archive/ArchiveSkeletonGrid.tsx | 25 ++ .../books-archive/BooksArchiveControls.tsx | 139 +++++++++++ .../components/books-archive/GridBookCard.tsx | 103 +++++++++ .../components/books-archive/constants.ts | 41 ++++ .../books/components/books-archive/types.ts | 1 + .../books-detail/BookActionsSidebar.tsx | 198 ++++++++++++++++ .../books-detail/BookDetailTopBar.tsx | 38 +++ .../books-detail/BookDetailsMainSection.tsx | 216 ++++++++++++++++++ .../books-detail/BookReviewCard.tsx | 57 +++++ .../books-detail/BookReviewsSection.tsx | 75 ++++++ .../books/components/books-detail/styles.ts | 13 ++ apps/web/src/features/books/hooks/useBooks.ts | 150 ++++++++++++ apps/web/src/features/music/api.ts | 32 +++ apps/web/src/features/music/api/requests.ts | 155 +++++++++++++ apps/web/src/features/music/api/schemas.ts | 160 +++++++++++++ apps/web/src/features/music/api/types.ts | 51 +++++ .../music/components/LogAlbumModal.tsx | 188 +++++++++++++++ .../music/components/MusicArchivePage.tsx | 185 +++++++++++++++ .../music/components/MusicDetailPage.tsx | 132 +++++++++++ .../music-archive/ArchiveMenuRadioOption.tsx | 30 +++ .../music-archive/ArchiveMenuTrigger.tsx | 76 ++++++ .../music-archive/ArchiveSkeletonGrid.tsx | 25 ++ .../music-archive/GridAlbumCard.tsx | 102 +++++++++ .../music-archive/MusicArchiveControls.tsx | 137 +++++++++++ .../components/music-archive/constants.ts | 34 +++ .../music/components/music-archive/types.ts | 1 + .../music-detail/AlbumActionsSidebar.tsx | 198 ++++++++++++++++ .../music-detail/AlbumDetailTopBar.tsx | 38 +++ .../music-detail/AlbumDetailsMainSection.tsx | 144 ++++++++++++ .../music-detail/AlbumReviewCard.tsx | 57 +++++ .../music-detail/AlbumReviewsSection.tsx | 75 ++++++ .../music/components/music-detail/styles.ts | 13 ++ apps/web/src/features/music/hooks/useMusic.ts | 150 ++++++++++++ .../search/components/GlobalSearchDialog.tsx | 44 +++- .../global-search/GlobalSearchResults.tsx | 2 +- .../global-search/SearchResultRow.tsx | 110 +++++++-- .../components/global-search/constants.ts | 22 +- .../components/global-search/mappers.ts | 36 ++- .../components/global-search/navigation.ts | 14 +- .../search/components/global-search/types.ts | 30 ++- .../global-search/useGlobalSearchState.ts | 66 +++++- apps/web/src/routeTree.gen.ts | 84 +++++++ apps/web/src/routes/books/$volumeId.tsx | 33 +++ apps/web/src/routes/books/index.tsx | 12 + apps/web/src/routes/music/$mbid.tsx | 35 +++ apps/web/src/routes/music/index.tsx | 12 + 56 files changed, 4543 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/features/books/api.ts create mode 100644 apps/web/src/features/books/api/requests.ts create mode 100644 apps/web/src/features/books/api/schemas.ts create mode 100644 apps/web/src/features/books/api/types.ts create mode 100644 apps/web/src/features/books/components/BookDetailPage.tsx create mode 100644 apps/web/src/features/books/components/BooksArchivePage.tsx create mode 100644 apps/web/src/features/books/components/LogBookModal.tsx create mode 100644 apps/web/src/features/books/components/books-archive/ArchiveMenuRadioOption.tsx create mode 100644 apps/web/src/features/books/components/books-archive/ArchiveMenuTrigger.tsx create mode 100644 apps/web/src/features/books/components/books-archive/ArchiveSkeletonGrid.tsx create mode 100644 apps/web/src/features/books/components/books-archive/BooksArchiveControls.tsx create mode 100644 apps/web/src/features/books/components/books-archive/GridBookCard.tsx create mode 100644 apps/web/src/features/books/components/books-archive/constants.ts create mode 100644 apps/web/src/features/books/components/books-archive/types.ts create mode 100644 apps/web/src/features/books/components/books-detail/BookActionsSidebar.tsx create mode 100644 apps/web/src/features/books/components/books-detail/BookDetailTopBar.tsx create mode 100644 apps/web/src/features/books/components/books-detail/BookDetailsMainSection.tsx create mode 100644 apps/web/src/features/books/components/books-detail/BookReviewCard.tsx create mode 100644 apps/web/src/features/books/components/books-detail/BookReviewsSection.tsx create mode 100644 apps/web/src/features/books/components/books-detail/styles.ts create mode 100644 apps/web/src/features/books/hooks/useBooks.ts create mode 100644 apps/web/src/features/music/api.ts create mode 100644 apps/web/src/features/music/api/requests.ts create mode 100644 apps/web/src/features/music/api/schemas.ts create mode 100644 apps/web/src/features/music/api/types.ts create mode 100644 apps/web/src/features/music/components/LogAlbumModal.tsx create mode 100644 apps/web/src/features/music/components/MusicArchivePage.tsx create mode 100644 apps/web/src/features/music/components/MusicDetailPage.tsx create mode 100644 apps/web/src/features/music/components/music-archive/ArchiveMenuRadioOption.tsx create mode 100644 apps/web/src/features/music/components/music-archive/ArchiveMenuTrigger.tsx create mode 100644 apps/web/src/features/music/components/music-archive/ArchiveSkeletonGrid.tsx create mode 100644 apps/web/src/features/music/components/music-archive/GridAlbumCard.tsx create mode 100644 apps/web/src/features/music/components/music-archive/MusicArchiveControls.tsx create mode 100644 apps/web/src/features/music/components/music-archive/constants.ts create mode 100644 apps/web/src/features/music/components/music-archive/types.ts create mode 100644 apps/web/src/features/music/components/music-detail/AlbumActionsSidebar.tsx create mode 100644 apps/web/src/features/music/components/music-detail/AlbumDetailTopBar.tsx create mode 100644 apps/web/src/features/music/components/music-detail/AlbumDetailsMainSection.tsx create mode 100644 apps/web/src/features/music/components/music-detail/AlbumReviewCard.tsx create mode 100644 apps/web/src/features/music/components/music-detail/AlbumReviewsSection.tsx create mode 100644 apps/web/src/features/music/components/music-detail/styles.ts create mode 100644 apps/web/src/features/music/hooks/useMusic.ts create mode 100644 apps/web/src/routes/books/$volumeId.tsx create mode 100644 apps/web/src/routes/books/index.tsx create mode 100644 apps/web/src/routes/music/$mbid.tsx create mode 100644 apps/web/src/routes/music/index.tsx diff --git a/apps/web/src/components/layout/navbar/navbar.constants.ts b/apps/web/src/components/layout/navbar/navbar.constants.ts index cf11aac..19b685d 100644 --- a/apps/web/src/components/layout/navbar/navbar.constants.ts +++ b/apps/web/src/components/layout/navbar/navbar.constants.ts @@ -1,5 +1,7 @@ import { + BookOpen, Film, + Music, Rss, Shield, Tv, @@ -7,7 +9,7 @@ import { } from "lucide-react"; export type PrimaryNavItem = { - to: "/" | "/cinema" | "/serials" | "/admin"; + to: "/" | "/cinema" | "/serials" | "/music" | "/books" | "/admin"; label: string; icon: LucideIcon; exact?: boolean; @@ -35,6 +37,18 @@ export const primaryNavItems: PrimaryNavItem[] = [ icon: Tv, activeColor: "var(--module-serial)", }, + { + to: "/music", + label: "MUSIC", + icon: Music, + activeColor: "var(--module-music)", + }, + { + to: "/books", + label: "BOOKS", + icon: BookOpen, + activeColor: "var(--module-book)", + }, { to: "/admin", label: "ADMIN", diff --git a/apps/web/src/features/books/api.ts b/apps/web/src/features/books/api.ts new file mode 100644 index 0000000..edc502e --- /dev/null +++ b/apps/web/src/features/books/api.ts @@ -0,0 +1,32 @@ +export { + searchBooks, + getBookByVolumeId, + getBookDetail, + getBooksArchive, + getBookLogs, + getBookInteraction, + updateBookInteraction, + createBookLog, + getMyBookLogs, + updateBookLog, + deleteBookLog, +} from "./api/requests"; + +export type { + GoogleBooksVolume, + BookDetail, + BooksArchiveItem, + BooksArchiveResponse, + BookDetailResponse, + BookInteraction, + BookLogItem, + MyBookLog, + UpdateBookLogInput, + CreateBookLogInput, + UpdateBookInteractionInput, + BooksArchiveSort, + BookDetailReviewSort, + QueryRequestOptions, + BooksArchiveInput, + BookDetailInput, +} from "./api/types"; diff --git a/apps/web/src/features/books/api/requests.ts b/apps/web/src/features/books/api/requests.ts new file mode 100644 index 0000000..3114237 --- /dev/null +++ b/apps/web/src/features/books/api/requests.ts @@ -0,0 +1,160 @@ +import { apiRequest } from "@/lib/api-client"; +import { + bookDetailSchema, + bookDetailResponseSchema, + bookInteractionSchema, + bookLogsListSchema, + booksArchiveResponseSchema, + googleBooksSearchResponseSchema, + myBookLogsListSchema, + updateBookInteractionInputSchema, + updateBookLogInputSchema, + createBookLogInputSchema, +} from "./schemas"; +import type { + BookDetail, + BookDetailInput, + BookDetailResponse, + BookInteraction, + BookLogItem, + BooksArchiveInput, + BooksArchiveResponse, + CreateBookLogInput, + GoogleBooksVolume, + MyBookLog, + QueryRequestOptions, + UpdateBookInteractionInput, + UpdateBookLogInput, +} from "./types"; + +function toBooksArchiveParams(input: BooksArchiveInput): string { + const params = new URLSearchParams(); + if (input.genre) params.set("genre", input.genre); + if (input.language) params.set("language", input.language); + if (input.sort) params.set("sort", input.sort); + if (input.page) params.set("page", String(input.page)); + if (input.limit) params.set("limit", String(input.limit)); + return params.toString(); +} + +export const searchBooks = async ( + query: string, + language?: string, + options: QueryRequestOptions = {}, +): Promise => { + const q = query.trim(); + if (q.length === 0) return []; + const params = new URLSearchParams({ query: q }); + if (language) params.set("language", language); + const response = await apiRequest( + `/api/books/search?${params.toString()}`, + { method: "GET", signal: options.signal }, + ); + return googleBooksSearchResponseSchema.parse(response); +}; + +export const getBookByVolumeId = async ( + volumeId: string, + options: QueryRequestOptions = {}, +): Promise => { + const response = await apiRequest(`/api/books/${volumeId}`, { + method: "GET", + signal: options.signal, + }); + return bookDetailSchema.parse(response); +}; + +export const getBookDetail = async ( + volumeId: string, + input: BookDetailInput = {}, + options: QueryRequestOptions = {}, +): Promise => { + const params = new URLSearchParams(); + if (input.reviewsSort) params.set("reviewsSort", input.reviewsSort); + const query = params.toString(); + const path = query + ? `/api/books/${volumeId}/detail?${query}` + : `/api/books/${volumeId}/detail`; + const response = await apiRequest(path, { + method: "GET", + signal: options.signal, + cache: "no-store", + }); + return bookDetailResponseSchema.parse(response); +}; + +export const getBooksArchive = async ( + input: BooksArchiveInput, + options: QueryRequestOptions = {}, +): Promise => { + const query = toBooksArchiveParams(input); + const path = query ? `/api/books/archive?${query}` : "/api/books/archive"; + const response = await apiRequest(path, { + method: "GET", + signal: options.signal, + cache: "no-store", + }); + return booksArchiveResponseSchema.parse(response); +}; + +export const getBookLogs = async ( + volumeId: string, + options: QueryRequestOptions = {}, +): Promise => { + const response = await apiRequest(`/api/books/${volumeId}/logs`, { + method: "GET", + signal: options.signal, + }); + return bookLogsListSchema.parse(response); +}; + +export const getBookInteraction = async (volumeId: string): Promise => { + const response = await apiRequest(`/api/books/${volumeId}/interaction`, { + method: "GET", + }); + return bookInteractionSchema.parse(response); +}; + +export const updateBookInteraction = async ( + volumeId: string, + input: UpdateBookInteractionInput, +): Promise => { + const payload = updateBookInteractionInputSchema.parse(input); + const response = await apiRequest( + `/api/books/${volumeId}/interaction`, + { method: "PUT", body: payload }, + ); + return bookInteractionSchema.parse(response); +}; + +export const createBookLog = async ( + volumeId: string, + input: CreateBookLogInput, +): Promise => { + const payload = createBookLogInputSchema.parse(input); + return apiRequest(`/api/books/${volumeId}/log`, { + method: "POST", + body: payload, + }); +}; + +export const getMyBookLogs = async (): Promise => { + const response = await apiRequest("/api/books/logs", { method: "GET" }); + return myBookLogsListSchema.parse(response); +}; + +export const updateBookLog = async ( + entryId: string, + input: UpdateBookLogInput, +): Promise => { + const payload = updateBookLogInputSchema.parse(input); + const response = await apiRequest( + `/api/books/logs/${entryId}`, + { method: "PUT", body: payload }, + ); + return myBookLogsListSchema.element.parse(response); +}; + +export const deleteBookLog = async (entryId: string): Promise => { + await apiRequest(`/api/books/logs/${entryId}`, { method: "DELETE" }); +}; diff --git a/apps/web/src/features/books/api/schemas.ts b/apps/web/src/features/books/api/schemas.ts new file mode 100644 index 0000000..90b6798 --- /dev/null +++ b/apps/web/src/features/books/api/schemas.ts @@ -0,0 +1,171 @@ +import { z } from "zod"; + +export const googleBooksVolumeInfoSchema = z.object({ + title: z.string(), + subtitle: z.string().optional(), + authors: z.array(z.string()).optional().default([]), + publisher: z.string().optional(), + publishedDate: z.string().optional(), + description: z.string().optional(), + pageCount: z.number().int().optional(), + language: z.string().optional(), + categories: z.array(z.string()).optional().default([]), + imageLinks: z.object({ + smallThumbnail: z.string().optional(), + thumbnail: z.string().optional(), + }).optional(), +}); + +export const googleBooksVolumeSchema = z.object({ + id: z.string(), + volumeInfo: googleBooksVolumeInfoSchema, +}); + +export const googleBooksSearchResponseSchema = z.array(googleBooksVolumeSchema); + +export const bookDetailSchema = z.object({ + id: z.number().int(), + googleVolumeId: z.string(), + title: z.string(), + subtitle: z.string().nullable(), + authors: z.array(z.string()), + publisher: z.string().nullable(), + publishedDate: z.string().nullable(), + publishedYear: z.number().int().nullable(), + pageCount: z.number().int().nullable(), + language: z.string().nullable(), + categories: z.array(z.string()), + description: z.string().nullable(), + coverImageUrl: z.string().nullable(), + isbn13: z.string().nullable(), + googleBooksUrl: z.string().nullable(), +}); + +export const booksArchiveItemSchema = z.object({ + googleVolumeId: z.string(), + title: z.string(), + authors: z.array(z.string()), + coverImageUrl: z.string().nullable(), + publishedYear: z.number().int().nullable(), + language: z.string().nullable(), + categories: z.array(z.string()), + logCount: z.number().int(), + avgRatingOutOfFive: z.number().nullable(), + viewerHasLogged: z.boolean(), + viewerWantToRead: z.boolean(), +}); + +export const booksArchiveResponseSchema = z.object({ + totalCount: z.number().int(), + filteredCount: z.number().int(), + selectedGenre: z.string().nullable(), + selectedLanguage: z.string().nullable(), + selectedSort: z.string(), + availableGenres: z.array(z.object({ name: z.string(), count: z.number().int() })), + page: z.number().int(), + limit: z.number().int(), + hasMore: z.boolean(), + nextPage: z.number().int().nullable(), + items: z.array(booksArchiveItemSchema), +}); + +export const bookDetailReviewItemSchema = z.object({ + id: z.string(), + content: z.string(), + containsSpoilers: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + readDate: z.string().nullable(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + likeCount: z.number().int(), + viewerHasLiked: z.boolean(), + author: z.object({ + id: z.string(), + username: z.string(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), + avatarUrl: z.string().nullable(), + }), +}); + +export const bookDetailResponseSchema = z.object({ + book: bookDetailSchema, + logsCount: z.number().int(), + reviewCount: z.number().int(), + userLog: z.object({ + diaryEntryId: z.string().nullable(), + readDate: z.string().nullable(), + reread: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + }).nullable(), + interaction: z.object({ + liked: z.boolean(), + wantToRead: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + }).nullable(), + reviewsSort: z.string(), + reviews: z.array(bookDetailReviewItemSchema), +}); + +export const bookInteractionSchema = z.object({ + liked: z.boolean(), + wantToRead: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), +}); + +export const bookLogItemSchema = z.object({ + diaryEntryId: z.string(), + readDate: z.string(), + rating: z.number().int().nullable(), + reread: z.boolean(), + createdAt: z.string(), + username: z.string(), + userDisplayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + reviewContent: z.string().nullable(), + reviewContainsSpoilers: z.boolean().nullable(), + reviewUpdatedAt: z.string().nullable(), +}); + +export const bookLogsListSchema = z.array(bookLogItemSchema); + +export const myBookLogSchema = z.object({ + id: z.string(), + readDate: z.string(), + rating: z.number().int().nullable(), + reread: z.boolean(), + bookId: z.number().int(), + createdAt: z.string(), + updatedAt: z.string(), + bookGoogleVolumeId: z.string(), + bookTitle: z.string(), + bookAuthors: z.array(z.string()), + bookCoverImageUrl: z.string().nullable(), + bookPublishedYear: z.number().int().nullable(), + reviewId: z.string().nullable(), + reviewContent: z.string().nullable(), +}); + +export const myBookLogsListSchema = z.array(myBookLogSchema); + +export const updateBookLogInputSchema = z.object({ + readDate: z.string().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), + reread: z.boolean().optional(), +}); + +export const createBookLogInputSchema = z.object({ + readDate: z.string(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).optional(), + reread: z.boolean().optional(), +}); + +export const updateBookInteractionInputSchema = z.object({ + liked: z.boolean().optional(), + wantToRead: z.boolean().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), +}); diff --git a/apps/web/src/features/books/api/types.ts b/apps/web/src/features/books/api/types.ts new file mode 100644 index 0000000..0ef2b2a --- /dev/null +++ b/apps/web/src/features/books/api/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { + bookDetailSchema, + booksArchiveItemSchema, + booksArchiveResponseSchema, + bookDetailResponseSchema, + bookInteractionSchema, + bookLogItemSchema, + myBookLogSchema, + updateBookLogInputSchema, + createBookLogInputSchema, + updateBookInteractionInputSchema, + googleBooksVolumeSchema, +} from "./schemas"; + +export type GoogleBooksVolume = z.infer; +export type BookDetail = z.infer; +export type BooksArchiveItem = z.infer; +export type BooksArchiveResponse = z.infer; +export type BookDetailResponse = z.infer; +export type BookInteraction = z.infer; +export type BookLogItem = z.infer; +export type MyBookLog = z.infer; +export type UpdateBookLogInput = z.infer; +export type CreateBookLogInput = z.infer; +export type UpdateBookInteractionInput = z.infer; + +export type BooksArchiveSort = + | "logs_desc" + | "published_desc" + | "published_asc" + | "rating_desc" + | "title_asc"; + +export type BookDetailReviewSort = "popular" | "recent"; + +export type QueryRequestOptions = { + signal?: AbortSignal; +}; + +export type BooksArchiveInput = { + genre?: string; + language?: string; + sort?: BooksArchiveSort; + page?: number; + limit?: number; +}; + +export type BookDetailInput = { + reviewsSort?: BookDetailReviewSort; +}; diff --git a/apps/web/src/features/books/components/BookDetailPage.tsx b/apps/web/src/features/books/components/BookDetailPage.tsx new file mode 100644 index 0000000..25ada20 --- /dev/null +++ b/apps/web/src/features/books/components/BookDetailPage.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { useAuth } from "@/features/auth/hooks/useAuth"; +import type { BookDetailReviewSort } from "@/features/books/api"; +import { BookActionsSidebar } from "@/features/books/components/books-detail/BookActionsSidebar"; +import { BookDetailsMainSection } from "@/features/books/components/books-detail/BookDetailsMainSection"; +import { BookDetailTopBar } from "@/features/books/components/books-detail/BookDetailTopBar"; +import { BookReviewsSection } from "@/features/books/components/books-detail/BookReviewsSection"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; +import { LogBookModal } from "@/features/books/components/LogBookModal"; +import { + useBookDetailView, + useBookInteraction, + useUpdateBookInteraction, +} from "@/features/books/hooks/useBooks"; + +type BookDetailPageProps = { + volumeId: string; +}; + +const BookDetailStatusPanel = ({ + message, + loading = false, +}: { + message: string; + loading?: boolean; +}) => { + return ( +
+ {loading ? ( +
+ ) : ( +
+ {message} +
+ )} +
+ ); +}; + +export const BookDetailPage = ({ volumeId }: BookDetailPageProps) => { + const [reviewsSort, setReviewsSort] = useState("popular"); + const [isLogOpen, setIsLogOpen] = useState(false); + + const detailQuery = useBookDetailView(volumeId, reviewsSort, volumeId.length > 0); + const { user } = useAuth(); + const interactionQuery = useBookInteraction(volumeId, Boolean(user) && volumeId.length > 0); + const updateInteractionMutation = useUpdateBookInteraction(volumeId); + + if (!volumeId) { + return ; + } + + if (detailQuery.isPending) { + return ; + } + + if (detailQuery.isError || !detailQuery.data) { + return ; + } + + const detail = detailQuery.data; + const book = detail.book; + + const wantToRead = interactionQuery.data?.wantToRead ?? false; + const liked = interactionQuery.data?.liked ?? false; + const interactionRatingOutOfFive = interactionQuery.data?.ratingOutOfFive ?? null; + const currentRatingOutOfFive = + interactionRatingOutOfFive ?? detail.userLog?.ratingOutOfFive ?? null; + const isInteractionBusy = + interactionQuery.isPending || updateInteractionMutation.isPending; + + return ( +
+ + +
+
+ { + if (!user || nextRating === currentRatingOutOfFive) return; + void updateInteractionMutation.mutateAsync({ ratingOutOfFive: nextRating }); + }} + isAuthenticated={Boolean(user)} + wantToRead={wantToRead} + liked={liked} + isInteractionBusy={isInteractionBusy} + onToggleWantToRead={() => { + void updateInteractionMutation.mutateAsync({ wantToRead: !wantToRead }); + }} + onToggleLike={() => { + void updateInteractionMutation.mutateAsync({ liked: !liked }); + }} + onOpenLog={() => setIsLogOpen(true)} + /> + + setIsLogOpen(false)} + /> + + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/features/books/components/BooksArchivePage.tsx b/apps/web/src/features/books/components/BooksArchivePage.tsx new file mode 100644 index 0000000..60c73ff --- /dev/null +++ b/apps/web/src/features/books/components/BooksArchivePage.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ARCHIVE_PAGE_SIZE, BOOK_MODULE_STYLES, languageOptions, sortOptions } from "@/features/books/components/books-archive/constants"; +import { ArchiveSkeletonGrid } from "@/features/books/components/books-archive/ArchiveSkeletonGrid"; +import { GridBookCard } from "@/features/books/components/books-archive/GridBookCard"; +import { BooksArchiveControls } from "@/features/books/components/books-archive/BooksArchiveControls"; +import type { OpenMenu } from "@/features/books/components/books-archive/types"; +import type { BooksArchiveSort } from "@/features/books/api"; +import { useBooksArchive } from "@/features/books/hooks/useBooks"; + +function formatArchiveCount(count: number): string { + if (count === 0) return "No books"; + if (count === 1) return "1 book"; + return `${count.toLocaleString()} books`; +} + +export const BooksArchivePage = () => { + const [selectedSort, setSelectedSort] = useState("logs_desc"); + const [selectedGenre, setSelectedGenre] = useState("all"); + const [selectedLanguage, setSelectedLanguage] = useState("all"); + const [openMenu, setOpenMenu] = useState(null); + + const controlsRef = useRef(null); + + const selectedSortLabel = useMemo( + () => sortOptions.find((o) => o.value === selectedSort)?.label ?? "Most read", + [selectedSort], + ); + + const selectedLanguageLabel = useMemo( + () => languageOptions.find((o) => o.value === selectedLanguage)?.label ?? "All languages", + [selectedLanguage], + ); + + const archiveQuery = useBooksArchive( + selectedGenre === "all" ? "" : selectedGenre, + selectedLanguage === "all" ? "" : selectedLanguage, + selectedSort, + ARCHIVE_PAGE_SIZE, + ); + + const archivePages = archiveQuery.data?.pages; + const firstPage = archivePages?.[0] ?? null; + const { fetchNextPage, hasNextPage, isFetchingNextPage } = archiveQuery; + + const archiveItems = useMemo( + () => (archivePages ? archivePages.flatMap((page) => page.items) : []), + [archivePages], + ); + + const archiveCount = firstPage?.filteredCount ?? archiveItems.length; + const archiveCountLabel = formatArchiveCount(archiveCount); + + useEffect(() => { + if (!openMenu) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (!controlsRef.current?.contains(target)) setOpenMenu(null); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpenMenu(null); + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [openMenu]); + + return ( +
+
+
+

+ Module 05 +

+

+ Books +

+

+ novels - non-fiction - essays - graphic novels +

+
+ + { + if (!openMenu) return; + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setOpenMenu(null); + } + }} + onToggleMenu={(menu) => setOpenMenu((current) => (current === menu ? null : menu))} + onCloseMenu={() => setOpenMenu(null)} + selectedGenre={selectedGenre} + selectedLanguage={selectedLanguage} + selectedSort={selectedSort} + selectedSortLabel={selectedSortLabel} + selectedLanguageLabel={selectedLanguageLabel} + availableGenres={firstPage?.availableGenres} + archiveCountLabel={archiveCountLabel} + onSelectGenre={setSelectedGenre} + onSelectSort={setSelectedSort} + onSelectLanguage={setSelectedLanguage} + /> + + {archiveQuery.isPending ? : null} + + {archiveQuery.isError ? ( +
+ Could not load the book archive right now. +
+ ) : null} + + {!archiveQuery.isPending && !archiveQuery.isError && archiveItems.length === 0 ? ( +
+ No books match these filters right now. +
+ ) : null} + + {!archiveQuery.isPending && !archiveQuery.isError && archiveItems.length > 0 ? ( + <> +
+ {archiveItems.map((book) => ( + + ))} +
+ + {hasNextPage ? ( +
+ +
+ ) : ( +

+ End of book archive. +

+ )} + + ) : null} +
+
+ ); +}; diff --git a/apps/web/src/features/books/components/LogBookModal.tsx b/apps/web/src/features/books/components/LogBookModal.tsx new file mode 100644 index 0000000..6ab36dc --- /dev/null +++ b/apps/web/src/features/books/components/LogBookModal.tsx @@ -0,0 +1,188 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { BookOpen, CalendarDays, Rocket, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SpaceRatingInput } from "@/features/films/components/SpaceRating"; +import { formatRatingOutOfFiveLabel } from "@/features/films/components/spaceRating.utils"; +import { isApiError } from "@/lib/api-client"; +import { useCreateBookLog } from "@/features/books/hooks/useBooks"; +import { BOOK_MODULE_STYLES } from "./books-detail/styles"; + +const todayAsDateInput = (): string => new Date().toISOString().slice(0, 10); + +type LogBookModalProps = { + volumeId: string; + bookTitle: string; + publishedYear: number | null; + coverImageUrl: string | null; + isOpen: boolean; + onClose: () => void; +}; + +export const LogBookModal = ({ + volumeId, + bookTitle, + publishedYear, + coverImageUrl, + isOpen, + onClose, +}: LogBookModalProps) => { + const createLogMutation = useCreateBookLog(volumeId); + + const [readDate, setReadDate] = useState(todayAsDateInput); + const [ratingOutOfFive, setRatingOutOfFive] = useState(null); + const [reread, setReread] = useState(false); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setReadDate(todayAsDateInput()); + setRatingOutOfFive(null); + setReread(false); + setFormError(null); + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setFormError(null); + try { + await createLogMutation.mutateAsync({ + readDate, + ...(ratingOutOfFive !== null ? { ratingOutOfFive } : {}), + reread, + }); + onClose(); + } catch (error) { + setFormError(isApiError(error) ? error.message : "Could not save this log right now."); + } + }; + + if (!isOpen) return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

Log Read

+

+ {bookTitle}{publishedYear ? ` (${publishedYear})` : ""} +

+
+ +
+ +
+
+
+
+
+ {coverImageUrl ? ( + {`${bookTitle} + ) : ( +
+ +
+ )} +
+
+ +
+
+ + setReadDate(e.target.value)} + className="h-10 border-border/70 bg-background/45 sm:h-11" + /> +
+ +
+ +
+ +
+ {formatRatingOutOfFiveLabel(ratingOutOfFive) ?? "Unrated"} +
+
+
+ + + + {formError ? ( +

+ {formError} +

+ ) : null} +
+
+
+ +
+
+ + +
+
+
+
+
, + document.body, + ); +}; diff --git a/apps/web/src/features/books/components/books-archive/ArchiveMenuRadioOption.tsx b/apps/web/src/features/books/components/books-archive/ArchiveMenuRadioOption.tsx new file mode 100644 index 0000000..e240c92 --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/ArchiveMenuRadioOption.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-archive/constants"; + +type ArchiveMenuRadioOptionProps = { + isSelected: boolean; + onSelect: () => void; + children: ReactNode; +}; + +export const ArchiveMenuRadioOption = ({ + isSelected, + onSelect, + children, +}: ArchiveMenuRadioOptionProps) => { + return ( + + ); +}; diff --git a/apps/web/src/features/books/components/books-archive/ArchiveMenuTrigger.tsx b/apps/web/src/features/books/components/books-archive/ArchiveMenuTrigger.tsx new file mode 100644 index 0000000..9be17ba --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/ArchiveMenuTrigger.tsx @@ -0,0 +1,63 @@ +import { ChevronDown } from "lucide-react"; +import type { ReactNode } from "react"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-archive/constants"; +import type { OpenMenu } from "@/features/books/components/books-archive/types"; +import { cn } from "@/lib/utils"; + +type ArchiveMenuTriggerProps = { + menu: Exclude; + openMenu: OpenMenu; + onToggleMenu: (menu: Exclude) => void; + icon: ReactNode; + label: ReactNode; + menuClassName: string; + children: ReactNode; +}; + +export const ArchiveMenuTrigger = ({ + menu, + openMenu, + onToggleMenu, + icon, + label, + menuClassName, + children, +}: ArchiveMenuTriggerProps) => { + const isOpen = openMenu === menu; + + return ( +
+ + + {isOpen ? ( +
+ {children} +
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/features/books/components/books-archive/ArchiveSkeletonGrid.tsx b/apps/web/src/features/books/components/books-archive/ArchiveSkeletonGrid.tsx new file mode 100644 index 0000000..b875b44 --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/ArchiveSkeletonGrid.tsx @@ -0,0 +1,25 @@ +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-archive/constants"; + +export const ArchiveSkeletonGrid = () => ( +
+ {Array.from({ length: 12 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+); diff --git a/apps/web/src/features/books/components/books-archive/BooksArchiveControls.tsx b/apps/web/src/features/books/components/books-archive/BooksArchiveControls.tsx new file mode 100644 index 0000000..84ee0cf --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/BooksArchiveControls.tsx @@ -0,0 +1,139 @@ +import { Award, Funnel, Globe2 } from "lucide-react"; +import type { FocusEventHandler, RefObject } from "react"; +import type { BooksArchiveResponse, BooksArchiveSort } from "@/features/books/api"; +import { ArchiveMenuRadioOption } from "@/features/books/components/books-archive/ArchiveMenuRadioOption"; +import { ArchiveMenuTrigger } from "@/features/books/components/books-archive/ArchiveMenuTrigger"; +import { + BOOK_MODULE_STYLES, + languageOptions, + sortOptions, +} from "@/features/books/components/books-archive/constants"; +import type { OpenMenu } from "@/features/books/components/books-archive/types"; + +type BooksArchiveControlsProps = { + controlsRef: RefObject; + openMenu: OpenMenu; + onBlurCapture: FocusEventHandler; + onToggleMenu: (menu: Exclude) => void; + onCloseMenu: () => void; + selectedGenre: string; + selectedLanguage: string; + selectedSort: BooksArchiveSort; + selectedSortLabel: string; + selectedLanguageLabel: string; + availableGenres?: BooksArchiveResponse["availableGenres"]; + archiveCountLabel: string; + onSelectGenre: (genre: string) => void; + onSelectSort: (sort: BooksArchiveSort) => void; + onSelectLanguage: (language: string) => void; +}; + +export const BooksArchiveControls = ({ + controlsRef, + openMenu, + onBlurCapture, + onToggleMenu, + onCloseMenu, + selectedGenre, + selectedLanguage, + selectedSort, + selectedSortLabel, + selectedLanguageLabel, + availableGenres, + archiveCountLabel, + onSelectGenre, + onSelectSort, + onSelectLanguage, +}: BooksArchiveControlsProps) => { + return ( +
+
+ + Filter: + + + } + label={selectedGenre === "all" ? "All Genres" : selectedGenre} + menuClassName="min-w-42.5" + > +
+ { onSelectGenre("all"); onCloseMenu(); }} + > + All Genres + + {(availableGenres ?? []).map((genre) => ( + { onSelectGenre(genre.name); onCloseMenu(); }} + > + {typeof genre.count === "number" ? `${genre.name} (${genre.count})` : genre.name} + + ))} +
+
+ + } + label={`Language: ${selectedLanguageLabel}`} + menuClassName="min-w-45" + > +
+ {languageOptions.map((option) => ( + { onSelectLanguage(option.value); onCloseMenu(); }} + > + {option.label} + + ))} +
+
+ + } + label={`Sort: ${selectedSortLabel}`} + menuClassName="min-w-45" + > + {sortOptions.map((option) => ( + { onSelectSort(option.value); onCloseMenu(); }} + > + {option.label} + + ))} + + +

+ {archiveCountLabel} +

+
+
+ ); +}; diff --git a/apps/web/src/features/books/components/books-archive/GridBookCard.tsx b/apps/web/src/features/books/components/books-archive/GridBookCard.tsx new file mode 100644 index 0000000..01f6b02 --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/GridBookCard.tsx @@ -0,0 +1,103 @@ +import { Link } from "@tanstack/react-router"; +import { BookOpen } from "lucide-react"; +import type { BooksArchiveItem } from "@/features/books/api"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-archive/constants"; + +type GridBookCardProps = { + book: BooksArchiveItem; +}; + +export const GridBookCard = ({ book }: GridBookCardProps) => { + const stateLabel = book.viewerHasLogged ? "Read" : book.viewerWantToRead ? "Queue" : null; + const authorsLine = book.authors.slice(0, 2).join(", "); + + return ( + +
+ {book.coverImageUrl ? ( + {`${book.title} + ) : ( +
+
+ +
+ + No Cover + +
+ )} + + {stateLabel ? ( +
+ + {stateLabel} + +
+ ) : null} + + {book.avgRatingOutOfFive !== null ? ( +
+ + {book.avgRatingOutOfFive.toFixed(1)} + +
+ ) : null} +
+ +

+ {book.title} +

+

+ {authorsLine || "Unknown author"} + {book.publishedYear ? ( + · {book.publishedYear} + ) : null} +

+ + ); +}; diff --git a/apps/web/src/features/books/components/books-archive/constants.ts b/apps/web/src/features/books/components/books-archive/constants.ts new file mode 100644 index 0000000..db3cc09 --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/constants.ts @@ -0,0 +1,41 @@ +import type { BooksArchiveSort } from "@/features/books/api"; + +export const ARCHIVE_PAGE_SIZE = 30; + +export const BOOK_MODULE_STYLES = { + accent: "var(--module-book)", + text: "var(--foreground)", + muted: "color-mix(in srgb, var(--foreground) 68%, transparent)", + faint: "color-mix(in srgb, var(--foreground) 36%, transparent)", + border: "color-mix(in srgb, var(--module-book) 26%, transparent)", + borderSoft: "color-mix(in srgb, var(--module-book) 16%, transparent)", + panel: "color-mix(in srgb, var(--card) 92%, var(--background) 8%)", + panelElevated: "color-mix(in srgb, var(--card) 84%, var(--background) 16%)", + panelSoft: "color-mix(in srgb, var(--module-book) 10%, transparent)", + panelStrong: "color-mix(in srgb, var(--module-book) 26%, transparent)", + badge: "color-mix(in srgb, var(--module-book) 14%, transparent)", +} as const; + +export const sortOptions: Array<{ value: BooksArchiveSort; label: string }> = [ + { value: "logs_desc", label: "Most read" }, + { value: "published_desc", label: "Newest published" }, + { value: "published_asc", label: "Oldest published" }, + { value: "rating_desc", label: "Highest rated" }, + { value: "title_asc", label: "Title A-Z" }, +]; + +export const languageOptions = [ + { value: "all", label: "All languages" }, + { value: "en", label: "English" }, + { value: "es", label: "Spanish" }, + { value: "fr", label: "French" }, + { value: "de", label: "German" }, + { value: "it", label: "Italian" }, + { value: "pt", label: "Portuguese" }, + { value: "ja", label: "Japanese" }, + { value: "ko", label: "Korean" }, + { value: "zh", label: "Chinese" }, + { value: "ru", label: "Russian" }, + { value: "ar", label: "Arabic" }, + { value: "nl", label: "Dutch" }, +] as const; diff --git a/apps/web/src/features/books/components/books-archive/types.ts b/apps/web/src/features/books/components/books-archive/types.ts new file mode 100644 index 0000000..d445271 --- /dev/null +++ b/apps/web/src/features/books/components/books-archive/types.ts @@ -0,0 +1 @@ +export type OpenMenu = "genre" | "sort" | "language" | null; diff --git a/apps/web/src/features/books/components/books-detail/BookActionsSidebar.tsx b/apps/web/src/features/books/components/books-detail/BookActionsSidebar.tsx new file mode 100644 index 0000000..11b5fe3 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/BookActionsSidebar.tsx @@ -0,0 +1,198 @@ +import { Link } from "@tanstack/react-router"; +import { BookOpen, Check, Heart, Plus } from "lucide-react"; +import type { BookDetailResponse } from "@/features/books/api"; +import { SpaceRatingInput } from "@/features/films/components/SpaceRating"; +import { formatRatingOutOfFiveLabel } from "@/features/films/components/spaceRating.utils"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; + +type BookActionsSidebarProps = { + detail: BookDetailResponse; + currentRatingOutOfFive: number | null; + isRatingSaving: boolean; + onRatingChange: (ratingOutOfFive: number | null) => void; + isAuthenticated: boolean; + wantToRead: boolean; + liked: boolean; + isInteractionBusy: boolean; + onToggleWantToRead: () => void; + onToggleLike: () => void; + onOpenLog: () => void; +}; + +export const BookActionsSidebar = ({ + detail, + currentRatingOutOfFive, + isRatingSaving, + onRatingChange, + isAuthenticated, + wantToRead, + liked, + isInteractionBusy, + onToggleWantToRead, + onToggleLike, + onOpenLog, +}: BookActionsSidebarProps) => { + const book = detail.book; + const ratingLabel = formatRatingOutOfFiveLabel(currentRatingOutOfFive) ?? "No rating yet"; + + return ( + + ); +}; diff --git a/apps/web/src/features/books/components/books-detail/BookDetailTopBar.tsx b/apps/web/src/features/books/components/books-detail/BookDetailTopBar.tsx new file mode 100644 index 0000000..6ad6567 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/BookDetailTopBar.tsx @@ -0,0 +1,38 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronLeft } from "lucide-react"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; + +type BookDetailTopBarProps = { + title: string; +}; + +export const BookDetailTopBar = ({ title }: BookDetailTopBarProps) => { + return ( +
+
+ + + Books + + · + + {title} + +
+
+ ); +}; diff --git a/apps/web/src/features/books/components/books-detail/BookDetailsMainSection.tsx b/apps/web/src/features/books/components/books-detail/BookDetailsMainSection.tsx new file mode 100644 index 0000000..ea882e2 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/BookDetailsMainSection.tsx @@ -0,0 +1,216 @@ +import type { BookDetailResponse } from "@/features/books/api"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; + +type BookDetailsMainSectionProps = { + detail: BookDetailResponse; +}; + +function toLanguageLabel(code: string | null): string { + if (!code) return "Unknown"; + try { + return new Intl.DisplayNames(["en"], { type: "language" }).of(code) ?? code; + } catch { + return code; + } +} + +export const BookDetailsMainSection = ({ detail }: BookDetailsMainSectionProps) => { + const book = detail.book; + + const communityRatingLabel = + detail.userLog?.ratingOutOfFive !== null && detail.userLog?.ratingOutOfFive !== undefined + ? detail.userLog.ratingOutOfFive.toFixed(1) + : "--"; + + const authorsLine = book.authors.join(", "); + + return ( +
+
+ {book.publishedYear ? ( + + {book.publishedYear} + + ) : null} + {book.categories.slice(0, 3).map((cat) => ( + + {cat} + + ))} +
+ +

+ {book.title} +

+ + {book.subtitle ? ( +

+ {book.subtitle} +

+ ) : null} + +

+ by + {authorsLine || "Unknown author"} +

+ +
+
+

+ Community +

+
+ + {communityRatingLabel} + + + {detail.logsCount.toLocaleString()} reads + +
+
+ +
+

+ Reviews +

+ + {detail.reviewCount} + +
+
+ + {book.description ? ( +

+ {book.description} +

+ ) : ( +

+ No description available. +

+ )} + +
+ {book.publisher ? ( +
+

+ Publisher +

+

+ {book.publisher} +

+
+ ) : null} + + {book.pageCount ? ( +
+

+ Pages +

+

+ {book.pageCount.toLocaleString()} +

+
+ ) : null} + + {book.language ? ( +
+

+ Language +

+

+ {toLanguageLabel(book.language)} +

+
+ ) : null} + + {book.isbn13 ? ( +
+

+ ISBN-13 +

+

+ {book.isbn13} +

+
+ ) : null} + + {book.publishedDate ? ( +
+

+ Published +

+

+ {book.publishedDate} +

+
+ ) : null} + + {book.googleBooksUrl ? ( +
+

+ Google Books +

+ + View → + +
+ ) : null} +
+
+ ); +}; diff --git a/apps/web/src/features/books/components/books-detail/BookReviewCard.tsx b/apps/web/src/features/books/components/books-detail/BookReviewCard.tsx new file mode 100644 index 0000000..ca14bc4 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/BookReviewCard.tsx @@ -0,0 +1,57 @@ +import type { BookDetailResponse } from "@/features/books/api"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; + +type BookReviewCardProps = { + review: BookDetailResponse["reviews"][number]; +}; + +export const BookReviewCard = ({ review }: BookReviewCardProps) => { + const authorName = review.author.displayUsername ?? review.author.username; + + return ( +
+
+ + {authorName} + + {review.ratingOutOfFive !== null ? ( + + {review.ratingOutOfFive.toFixed(1)} / 5 + + ) : null} + {review.containsSpoilers ? ( + + spoilers + + ) : null} + + {review.likeCount} {review.likeCount === 1 ? "like" : "likes"} + +
+

+ {review.content} +

+
+ ); +}; diff --git a/apps/web/src/features/books/components/books-detail/BookReviewsSection.tsx b/apps/web/src/features/books/components/books-detail/BookReviewsSection.tsx new file mode 100644 index 0000000..89750a5 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/BookReviewsSection.tsx @@ -0,0 +1,75 @@ +import type { BookDetailResponse, BookDetailReviewSort } from "@/features/books/api"; +import { BookReviewCard } from "@/features/books/components/books-detail/BookReviewCard"; +import { BOOK_MODULE_STYLES } from "@/features/books/components/books-detail/styles"; + +type BookReviewsSectionProps = { + reviewsSort: BookDetailReviewSort; + onSortChange: (nextSort: BookDetailReviewSort) => void; + reviews: BookDetailResponse["reviews"]; +}; + +export const BookReviewsSection = ({ + reviewsSort, + onSortChange, + reviews, +}: BookReviewsSectionProps) => { + return ( +
+
+

+ Reviews +

+ +
+ {(["popular", "recent"] as const).map((sort) => ( + + ))} +
+
+ + {reviews.length === 0 ? ( +
+ No reviews yet for this book. +
+ ) : ( +
+ {reviews.map((review) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/features/books/components/books-detail/styles.ts b/apps/web/src/features/books/components/books-detail/styles.ts new file mode 100644 index 0000000..83edcb2 --- /dev/null +++ b/apps/web/src/features/books/components/books-detail/styles.ts @@ -0,0 +1,13 @@ +export const BOOK_MODULE_STYLES = { + accent: "var(--module-book)", + text: "var(--foreground)", + muted: "color-mix(in srgb, var(--foreground) 68%, transparent)", + faint: "color-mix(in srgb, var(--foreground) 36%, transparent)", + border: "color-mix(in srgb, var(--module-book) 26%, transparent)", + borderSoft: "color-mix(in srgb, var(--module-book) 16%, transparent)", + panel: "color-mix(in srgb, var(--card) 92%, var(--background) 8%)", + panelElevated: "color-mix(in srgb, var(--card) 84%, var(--background) 16%)", + panelSoft: "color-mix(in srgb, var(--module-book) 10%, transparent)", + panelStrong: "color-mix(in srgb, var(--module-book) 26%, transparent)", + badge: "color-mix(in srgb, var(--module-book) 14%, transparent)", +} as const; diff --git a/apps/web/src/features/books/hooks/useBooks.ts b/apps/web/src/features/books/hooks/useBooks.ts new file mode 100644 index 0000000..4f63b09 --- /dev/null +++ b/apps/web/src/features/books/hooks/useBooks.ts @@ -0,0 +1,150 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + createBookLog, + deleteBookLog, + getBooksArchive, + getBookDetail, + getBookInteraction, + getMyBookLogs, + searchBooks, + updateBookInteraction, + updateBookLog, + type BooksArchiveSort, + type BookDetailReviewSort, + type BookInteraction, + type CreateBookLogInput, + type MyBookLog, + type UpdateBookInteractionInput, + type UpdateBookLogInput, +} from "@/features/books/api"; + +export const bookKeys = { + all: ["books"] as const, + search: (query: string) => ["books", "search", query] as const, + detailView: (volumeId: string, reviewsSort: BookDetailReviewSort) => + ["books", "detail-view", volumeId, reviewsSort] as const, + interaction: (volumeId: string) => ["books", "interaction", volumeId] as const, + myLogs: ["books", "my-logs"] as const, + archive: (genre: string, language: string, sort: BooksArchiveSort, limit: number) => + ["books", "archive", genre, language, sort, limit] as const, +}; + +export const useBookSearch = (query: string) => + useQuery({ + queryKey: bookKeys.search(query), + queryFn: ({ signal }) => searchBooks(query, undefined, { signal }), + enabled: query.trim().length >= 2, + }); + +export const useBookDetailView = ( + volumeId: string, + reviewsSort: BookDetailReviewSort, + enabled = true, +) => + useQuery({ + queryKey: bookKeys.detailView(volumeId, reviewsSort), + queryFn: ({ signal }) => getBookDetail(volumeId, { reviewsSort }, { signal }), + enabled: enabled && volumeId.length > 0, + }); + +export const useBookInteraction = (volumeId: string, enabled = true) => + useQuery({ + queryKey: bookKeys.interaction(volumeId), + queryFn: () => getBookInteraction(volumeId), + enabled: enabled && volumeId.length > 0, + }); + +export const useUpdateBookInteraction = (volumeId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateBookInteractionInput) => + updateBookInteraction(volumeId, input), + onMutate: async (input) => { + const queryKey = bookKeys.interaction(volumeId); + await queryClient.cancelQueries({ queryKey }); + const previousState = queryClient.getQueryData(queryKey); + if (previousState) { + queryClient.setQueryData(queryKey, { + ...previousState, + ...(input.liked !== undefined ? { liked: input.liked } : {}), + ...(input.wantToRead !== undefined ? { wantToRead: input.wantToRead } : {}), + ...(input.ratingOutOfFive !== undefined ? { ratingOutOfFive: input.ratingOutOfFive } : {}), + }); + } + return { previousState }; + }, + onError: (_error, _input, context) => { + if (!context?.previousState) return; + queryClient.setQueryData(bookKeys.interaction(volumeId), context.previousState); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: bookKeys.interaction(volumeId) }); + }, + }); +}; + +export const useCreateBookLog = (volumeId: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateBookLogInput) => createBookLog(volumeId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookKeys.myLogs }); + queryClient.invalidateQueries({ queryKey: bookKeys.all }); + }, + }); +}; + +export const useMyBookLogs = () => + useQuery({ + queryKey: bookKeys.myLogs, + queryFn: getMyBookLogs, + }); + +export const useUpdateBookLog = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ entryId, input }: { entryId: string; input: UpdateBookLogInput }) => + updateBookLog(entryId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: bookKeys.myLogs }); + }, + }); +}; + +export const useDeleteBookLog = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (entryId: string) => deleteBookLog(entryId), + onSuccess: (_data, entryId) => { + queryClient.invalidateQueries({ queryKey: bookKeys.myLogs }); + queryClient.setQueryData(bookKeys.myLogs, (prev) => + prev ? prev.filter((e) => e.id !== entryId) : prev, + ); + }, + }); +}; + +export const useBooksArchive = ( + genre: string, + language: string, + sort: BooksArchiveSort, + limit: number, +) => + useInfiniteQuery({ + queryKey: bookKeys.archive(genre, language, sort, limit), + initialPageParam: 1, + queryFn: ({ signal, pageParam }) => { + const page = typeof pageParam === "number" ? pageParam : 1; + return getBooksArchive({ genre, language, sort, page, limit }, { signal }); + }, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, + }); diff --git a/apps/web/src/features/music/api.ts b/apps/web/src/features/music/api.ts new file mode 100644 index 0000000..97981ae --- /dev/null +++ b/apps/web/src/features/music/api.ts @@ -0,0 +1,32 @@ +export { + searchMusic, + getAlbumByMbid, + getMusicDetail, + getMusicArchive, + getMusicLogs, + getMusicInteraction, + updateMusicInteraction, + createMusicLog, + getMyMusicLogs, + updateMusicLog, + deleteMusicLog, +} from "./api/requests"; + +export type { + Album, + MbSearchResult, + MusicArchiveItem, + MusicArchiveResponse, + MusicDetailResponse, + MusicInteraction, + MusicLogItem, + MyMusicLog, + UpdateMusicLogInput, + CreateMusicLogInput, + UpdateMusicInteractionInput, + MusicArchiveSort, + MusicDetailReviewSort, + QueryRequestOptions, + MusicArchiveInput, + MusicDetailInput, +} from "./api/types"; diff --git a/apps/web/src/features/music/api/requests.ts b/apps/web/src/features/music/api/requests.ts new file mode 100644 index 0000000..de1c49c --- /dev/null +++ b/apps/web/src/features/music/api/requests.ts @@ -0,0 +1,155 @@ +import { apiRequest } from "@/lib/api-client"; +import { + albumSchema, + mbSearchResultListSchema, + musicArchiveResponseSchema, + musicDetailResponseSchema, + musicInteractionSchema, + musicLogsListSchema, + myMusicLogsListSchema, + updateMusicInteractionInputSchema, + updateMusicLogInputSchema, + createMusicLogInputSchema, +} from "./schemas"; +import type { + Album, + CreateMusicLogInput, + MbSearchResult, + MusicArchiveInput, + MusicArchiveResponse, + MusicDetailInput, + MusicDetailResponse, + MusicInteraction, + MusicLogItem, + MyMusicLog, + QueryRequestOptions, + UpdateMusicInteractionInput, + UpdateMusicLogInput, +} from "./types"; + +function toMusicArchiveParams(input: MusicArchiveInput): string { + const params = new URLSearchParams(); + if (input.genre) params.set("genre", input.genre); + if (input.type) params.set("type", input.type); + if (input.sort) params.set("sort", input.sort); + if (input.page) params.set("page", String(input.page)); + if (input.limit) params.set("limit", String(input.limit)); + return params.toString(); +} + +export const searchMusic = async ( + query: string, + options: QueryRequestOptions = {}, +): Promise => { + const q = query.trim(); + if (q.length === 0) return []; + const response = await apiRequest( + `/api/music/search?query=${encodeURIComponent(q)}`, + { method: "GET", signal: options.signal }, + ); + return mbSearchResultListSchema.parse(response); +}; + +export const getAlbumByMbid = async ( + mbid: string, + options: QueryRequestOptions = {}, +): Promise => { + const response = await apiRequest(`/api/music/${mbid}`, { + method: "GET", + signal: options.signal, + }); + return albumSchema.parse(response); +}; + +export const getMusicDetail = async ( + mbid: string, + input: MusicDetailInput = {}, + options: QueryRequestOptions = {}, +): Promise => { + const params = new URLSearchParams(); + if (input.reviewsSort) params.set("reviewsSort", input.reviewsSort); + const query = params.toString(); + const path = query ? `/api/music/${mbid}/detail?${query}` : `/api/music/${mbid}/detail`; + const response = await apiRequest(path, { + method: "GET", + signal: options.signal, + cache: "no-store", + }); + return musicDetailResponseSchema.parse(response); +}; + +export const getMusicArchive = async ( + input: MusicArchiveInput, + options: QueryRequestOptions = {}, +): Promise => { + const query = toMusicArchiveParams(input); + const path = query ? `/api/music/archive?${query}` : "/api/music/archive"; + const response = await apiRequest(path, { + method: "GET", + signal: options.signal, + cache: "no-store", + }); + return musicArchiveResponseSchema.parse(response); +}; + +export const getMusicLogs = async ( + mbid: string, + options: QueryRequestOptions = {}, +): Promise => { + const response = await apiRequest(`/api/music/${mbid}/logs`, { + method: "GET", + signal: options.signal, + }); + return musicLogsListSchema.parse(response); +}; + +export const getMusicInteraction = async (mbid: string): Promise => { + const response = await apiRequest(`/api/music/${mbid}/interaction`, { + method: "GET", + }); + return musicInteractionSchema.parse(response); +}; + +export const updateMusicInteraction = async ( + mbid: string, + input: UpdateMusicInteractionInput, +): Promise => { + const payload = updateMusicInteractionInputSchema.parse(input); + const response = await apiRequest( + `/api/music/${mbid}/interaction`, + { method: "PUT", body: payload }, + ); + return musicInteractionSchema.parse(response); +}; + +export const createMusicLog = async ( + mbid: string, + input: CreateMusicLogInput, +): Promise => { + const payload = createMusicLogInputSchema.parse(input); + return apiRequest(`/api/music/${mbid}/log`, { + method: "POST", + body: payload, + }); +}; + +export const getMyMusicLogs = async (): Promise => { + const response = await apiRequest("/api/music/logs", { method: "GET" }); + return myMusicLogsListSchema.parse(response); +}; + +export const updateMusicLog = async ( + entryId: string, + input: UpdateMusicLogInput, +): Promise => { + const payload = updateMusicLogInputSchema.parse(input); + const response = await apiRequest( + `/api/music/logs/${entryId}`, + { method: "PUT", body: payload }, + ); + return myMusicLogsListSchema.element.parse(response); +}; + +export const deleteMusicLog = async (entryId: string): Promise => { + await apiRequest(`/api/music/logs/${entryId}`, { method: "DELETE" }); +}; diff --git a/apps/web/src/features/music/api/schemas.ts b/apps/web/src/features/music/api/schemas.ts new file mode 100644 index 0000000..b62fac9 --- /dev/null +++ b/apps/web/src/features/music/api/schemas.ts @@ -0,0 +1,160 @@ +import { z } from "zod"; + +export const albumSchema = z.object({ + id: z.number().int(), + mbid: z.string(), + title: z.string(), + artistName: z.string(), + artistMbid: z.string().nullable(), + coverArtUrl: z.string().nullable(), + primaryType: z.string().nullable(), + secondaryTypes: z.array(z.string()).optional().default([]), + firstReleaseDate: z.string().nullable(), + firstReleaseYear: z.number().int().nullable(), + genres: z.array(z.object({ name: z.string(), count: z.number().int() })).optional().default([]), + disambiguation: z.string().nullable(), +}); + +export const mbSearchResultSchema = z.object({ + id: z.string(), + title: z.string(), + "primary-type": z.string().optional().nullable(), + "first-release-date": z.string().optional(), + "artist-credit": z.array(z.object({ + name: z.string(), + joinphrase: z.string().optional(), + artist: z.object({ id: z.string(), name: z.string() }).optional(), + })).optional().default([]), + tags: z.array(z.object({ name: z.string(), count: z.number() })).optional().default([]), + disambiguation: z.string().optional().nullable(), +}); + +export const mbSearchResultListSchema = z.array(mbSearchResultSchema); + +export const musicArchiveItemSchema = z.object({ + mbid: z.string(), + title: z.string(), + artistName: z.string(), + coverArtUrl: z.string().nullable(), + primaryType: z.string().nullable(), + firstReleaseYear: z.number().int().nullable(), + genres: z.array(z.object({ name: z.string(), count: z.number().int() })), + logCount: z.number().int(), + avgRatingOutOfFive: z.number().nullable(), + viewerHasLogged: z.boolean(), + viewerWantToListen: z.boolean(), +}); + +export const musicArchiveResponseSchema = z.object({ + totalCount: z.number().int(), + filteredCount: z.number().int(), + selectedGenre: z.string().nullable(), + selectedSort: z.string(), + availableGenres: z.array(z.object({ name: z.string(), count: z.number().int() })), + page: z.number().int(), + limit: z.number().int(), + hasMore: z.boolean(), + nextPage: z.number().int().nullable(), + items: z.array(musicArchiveItemSchema), +}); + +export const musicDetailReviewItemSchema = z.object({ + id: z.string(), + content: z.string(), + containsSpoilers: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + listenedDate: z.string().nullable(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + likeCount: z.number().int(), + viewerHasLiked: z.boolean(), + author: z.object({ + id: z.string(), + username: z.string(), + displayUsername: z.string().nullable(), + image: z.string().nullable(), + avatarUrl: z.string().nullable(), + }), +}); + +export const musicDetailResponseSchema = z.object({ + album: albumSchema, + logsCount: z.number().int(), + reviewCount: z.number().int(), + userLog: z.object({ + diaryEntryId: z.string().nullable(), + listenedDate: z.string().nullable(), + relisten: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + }).nullable(), + interaction: z.object({ + liked: z.boolean(), + wantToListen: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), + }).nullable(), + reviewsSort: z.string(), + reviews: z.array(musicDetailReviewItemSchema), +}); + +export const musicInteractionSchema = z.object({ + liked: z.boolean(), + wantToListen: z.boolean(), + ratingOutOfTen: z.number().nullable(), + ratingOutOfFive: z.number().nullable(), +}); + +export const musicLogItemSchema = z.object({ + diaryEntryId: z.string(), + listenedDate: z.string(), + rating: z.number().int().nullable(), + relisten: z.boolean(), + createdAt: z.string(), + username: z.string(), + userDisplayName: z.string().nullable(), + avatarUrl: z.string().nullable(), + reviewContent: z.string().nullable(), + reviewContainsSpoilers: z.boolean().nullable(), + reviewUpdatedAt: z.string().nullable(), +}); + +export const musicLogsListSchema = z.array(musicLogItemSchema); + +export const myMusicLogSchema = z.object({ + id: z.string(), + listenedDate: z.string(), + rating: z.number().int().nullable(), + relisten: z.boolean(), + albumId: z.number().int(), + createdAt: z.string(), + updatedAt: z.string(), + albumMbid: z.string(), + albumTitle: z.string(), + albumArtistName: z.string(), + albumCoverArtUrl: z.string().nullable(), + albumFirstReleaseYear: z.number().int().nullable(), + reviewId: z.string().nullable(), + reviewContent: z.string().nullable(), +}); + +export const myMusicLogsListSchema = z.array(myMusicLogSchema); + +export const updateMusicLogInputSchema = z.object({ + listenedDate: z.string().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), + relisten: z.boolean().optional(), +}); + +export const createMusicLogInputSchema = z.object({ + listenedDate: z.string(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).optional(), + relisten: z.boolean().optional(), +}); + +export const updateMusicInteractionInputSchema = z.object({ + liked: z.boolean().optional(), + wantToListen: z.boolean().optional(), + ratingOutOfFive: z.number().min(0.5).max(5).multipleOf(0.5).nullable().optional(), +}); diff --git a/apps/web/src/features/music/api/types.ts b/apps/web/src/features/music/api/types.ts new file mode 100644 index 0000000..8ed8a5f --- /dev/null +++ b/apps/web/src/features/music/api/types.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import { + albumSchema, + mbSearchResultSchema, + musicArchiveItemSchema, + musicArchiveResponseSchema, + musicDetailResponseSchema, + musicInteractionSchema, + musicLogItemSchema, + myMusicLogSchema, + updateMusicLogInputSchema, + createMusicLogInputSchema, + updateMusicInteractionInputSchema, +} from "./schemas"; + +export type Album = z.infer; +export type MbSearchResult = z.infer; +export type MusicArchiveItem = z.infer; +export type MusicArchiveResponse = z.infer; +export type MusicDetailResponse = z.infer; +export type MusicInteraction = z.infer; +export type MusicLogItem = z.infer; +export type MyMusicLog = z.infer; +export type UpdateMusicLogInput = z.infer; +export type CreateMusicLogInput = z.infer; +export type UpdateMusicInteractionInput = z.infer; + +export type MusicArchiveSort = + | "logs_desc" + | "release_desc" + | "release_asc" + | "rating_desc" + | "title_asc"; + +export type MusicDetailReviewSort = "popular" | "recent"; + +export type QueryRequestOptions = { + signal?: AbortSignal; +}; + +export type MusicArchiveInput = { + genre?: string; + type?: string; + sort?: MusicArchiveSort; + page?: number; + limit?: number; +}; + +export type MusicDetailInput = { + reviewsSort?: MusicDetailReviewSort; +}; diff --git a/apps/web/src/features/music/components/LogAlbumModal.tsx b/apps/web/src/features/music/components/LogAlbumModal.tsx new file mode 100644 index 0000000..ab9be29 --- /dev/null +++ b/apps/web/src/features/music/components/LogAlbumModal.tsx @@ -0,0 +1,188 @@ +import { type FormEvent, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { CalendarDays, Music, Rocket, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { SpaceRatingInput } from "@/features/films/components/SpaceRating"; +import { formatRatingOutOfFiveLabel } from "@/features/films/components/spaceRating.utils"; +import { isApiError } from "@/lib/api-client"; +import { useCreateMusicLog } from "@/features/music/hooks/useMusic"; +import { MUSIC_MODULE_STYLES } from "./music-detail/styles"; + +const todayAsDateInput = (): string => new Date().toISOString().slice(0, 10); + +type LogAlbumModalProps = { + mbid: string; + albumTitle: string; + albumYear: number | null; + coverArtUrl: string | null; + isOpen: boolean; + onClose: () => void; +}; + +export const LogAlbumModal = ({ + mbid, + albumTitle, + albumYear, + coverArtUrl, + isOpen, + onClose, +}: LogAlbumModalProps) => { + const createLogMutation = useCreateMusicLog(mbid); + + const [listenedDate, setListenedDate] = useState(todayAsDateInput); + const [ratingOutOfFive, setRatingOutOfFive] = useState(null); + const [relisten, setRelisten] = useState(false); + const [formError, setFormError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + setListenedDate(todayAsDateInput()); + setRatingOutOfFive(null); + setRelisten(false); + setFormError(null); + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + setFormError(null); + try { + await createLogMutation.mutateAsync({ + listenedDate, + ...(ratingOutOfFive !== null ? { ratingOutOfFive } : {}), + relisten, + }); + onClose(); + } catch (error) { + setFormError(isApiError(error) ? error.message : "Could not save this log right now."); + } + }; + + if (!isOpen) return null; + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

Log Listen

+

+ {albumTitle}{albumYear ? ` (${albumYear})` : ""} +

+
+ +
+ +
+
+
+
+
+ {coverArtUrl ? ( + {`${albumTitle} + ) : ( +
+ +
+ )} +
+
+ +
+
+ + setListenedDate(e.target.value)} + className="h-10 border-border/70 bg-background/45 sm:h-11" + /> +
+ +
+ +
+ +
+ {formatRatingOutOfFiveLabel(ratingOutOfFive) ?? "Unrated"} +
+
+
+ + + + {formError ? ( +

+ {formError} +

+ ) : null} +
+
+
+ +
+
+ + +
+
+
+
+
, + document.body, + ); +}; diff --git a/apps/web/src/features/music/components/MusicArchivePage.tsx b/apps/web/src/features/music/components/MusicArchivePage.tsx new file mode 100644 index 0000000..deacced --- /dev/null +++ b/apps/web/src/features/music/components/MusicArchivePage.tsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { ARCHIVE_PAGE_SIZE, MUSIC_MODULE_STYLES, sortOptions, typeOptions } from "@/features/music/components/music-archive/constants"; +import { ArchiveSkeletonGrid } from "@/features/music/components/music-archive/ArchiveSkeletonGrid"; +import { GridAlbumCard } from "@/features/music/components/music-archive/GridAlbumCard"; +import { MusicArchiveControls } from "@/features/music/components/music-archive/MusicArchiveControls"; +import type { OpenMenu } from "@/features/music/components/music-archive/types"; +import type { MusicArchiveSort } from "@/features/music/api"; +import { useMusicArchive } from "@/features/music/hooks/useMusic"; + +function formatArchiveCount(count: number): string { + if (count === 0) return "No albums"; + if (count === 1) return "1 album"; + return `${count.toLocaleString()} albums`; +} + +export const MusicArchivePage = () => { + const [selectedSort, setSelectedSort] = useState("logs_desc"); + const [selectedGenre, setSelectedGenre] = useState("all"); + const [selectedType, setSelectedType] = useState("all"); + const [openMenu, setOpenMenu] = useState(null); + + const controlsRef = useRef(null); + + const selectedSortLabel = useMemo( + () => sortOptions.find((o) => o.value === selectedSort)?.label ?? "Most listened", + [selectedSort], + ); + + const selectedTypeLabel = useMemo( + () => typeOptions.find((o) => o.value === selectedType)?.label ?? "All types", + [selectedType], + ); + + const archiveQuery = useMusicArchive( + selectedGenre === "all" ? "" : selectedGenre, + selectedType === "all" ? "" : selectedType, + selectedSort, + ARCHIVE_PAGE_SIZE, + ); + + const archivePages = archiveQuery.data?.pages; + const firstPage = archivePages?.[0] ?? null; + const { fetchNextPage, hasNextPage, isFetchingNextPage } = archiveQuery; + + const archiveItems = useMemo( + () => (archivePages ? archivePages.flatMap((page) => page.items) : []), + [archivePages], + ); + + const archiveCount = firstPage?.filteredCount ?? archiveItems.length; + const archiveCountLabel = formatArchiveCount(archiveCount); + + useEffect(() => { + if (!openMenu) return; + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (!controlsRef.current?.contains(target)) setOpenMenu(null); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpenMenu(null); + }; + + window.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [openMenu]); + + return ( +
+
+
+

+ Module 04 +

+

+ Music +

+

+ albums - singles - EPs - releases +

+
+ + { + if (!openMenu) return; + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setOpenMenu(null); + } + }} + onToggleMenu={(menu) => setOpenMenu((current) => (current === menu ? null : menu))} + onCloseMenu={() => setOpenMenu(null)} + selectedGenre={selectedGenre} + selectedType={selectedType} + selectedSort={selectedSort} + selectedSortLabel={selectedSortLabel} + selectedTypeLabel={selectedTypeLabel} + availableGenres={firstPage?.availableGenres} + archiveCountLabel={archiveCountLabel} + onSelectGenre={setSelectedGenre} + onSelectSort={setSelectedSort} + onSelectType={setSelectedType} + /> + + {archiveQuery.isPending ? : null} + + {archiveQuery.isError ? ( +
+ Could not load the music archive right now. +
+ ) : null} + + {!archiveQuery.isPending && !archiveQuery.isError && archiveItems.length === 0 ? ( +
+ No albums match these filters right now. +
+ ) : null} + + {!archiveQuery.isPending && !archiveQuery.isError && archiveItems.length > 0 ? ( + <> +
+ {archiveItems.map((album) => ( + + ))} +
+ + {hasNextPage ? ( +
+ +
+ ) : ( +

+ End of music archive. +

+ )} + + ) : null} +
+
+ ); +}; diff --git a/apps/web/src/features/music/components/MusicDetailPage.tsx b/apps/web/src/features/music/components/MusicDetailPage.tsx new file mode 100644 index 0000000..6528898 --- /dev/null +++ b/apps/web/src/features/music/components/MusicDetailPage.tsx @@ -0,0 +1,132 @@ +import { useState } from "react"; +import { useAuth } from "@/features/auth/hooks/useAuth"; +import type { MusicDetailReviewSort } from "@/features/music/api"; +import { AlbumActionsSidebar } from "@/features/music/components/music-detail/AlbumActionsSidebar"; +import { AlbumDetailsMainSection } from "@/features/music/components/music-detail/AlbumDetailsMainSection"; +import { AlbumDetailTopBar } from "@/features/music/components/music-detail/AlbumDetailTopBar"; +import { AlbumReviewsSection } from "@/features/music/components/music-detail/AlbumReviewsSection"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; +import { LogAlbumModal } from "@/features/music/components/LogAlbumModal"; +import { + useMusicDetailView, + useMusicInteraction, + useUpdateMusicInteraction, +} from "@/features/music/hooks/useMusic"; + +type MusicDetailPageProps = { + mbid: string; +}; + +const MusicDetailStatusPanel = ({ + message, + loading = false, +}: { + message: string; + loading?: boolean; +}) => { + return ( +
+ {loading ? ( +
+ ) : ( +
+ {message} +
+ )} +
+ ); +}; + +export const MusicDetailPage = ({ mbid }: MusicDetailPageProps) => { + const [reviewsSort, setReviewsSort] = useState("popular"); + const [isLogOpen, setIsLogOpen] = useState(false); + + const detailQuery = useMusicDetailView(mbid, reviewsSort, mbid.length > 0); + const { user } = useAuth(); + const interactionQuery = useMusicInteraction(mbid, Boolean(user) && mbid.length > 0); + const updateInteractionMutation = useUpdateMusicInteraction(mbid); + + if (!mbid) { + return ; + } + + if (detailQuery.isPending) { + return ; + } + + if (detailQuery.isError || !detailQuery.data) { + return ; + } + + const detail = detailQuery.data; + const album = detail.album; + + const wantToListen = interactionQuery.data?.wantToListen ?? false; + const liked = interactionQuery.data?.liked ?? false; + const interactionRatingOutOfFive = interactionQuery.data?.ratingOutOfFive ?? null; + const currentRatingOutOfFive = + interactionRatingOutOfFive ?? detail.userLog?.ratingOutOfFive ?? null; + const isInteractionBusy = + interactionQuery.isPending || updateInteractionMutation.isPending; + + return ( +
+ + +
+
+ { + if (!user || nextRating === currentRatingOutOfFive) return; + void updateInteractionMutation.mutateAsync({ ratingOutOfFive: nextRating }); + }} + isAuthenticated={Boolean(user)} + wantToListen={wantToListen} + liked={liked} + isInteractionBusy={isInteractionBusy} + onToggleWantToListen={() => { + void updateInteractionMutation.mutateAsync({ wantToListen: !wantToListen }); + }} + onToggleLike={() => { + void updateInteractionMutation.mutateAsync({ liked: !liked }); + }} + onOpenLog={() => setIsLogOpen(true)} + /> + + setIsLogOpen(false)} + /> + + +
+ + +
+
+ ); +}; diff --git a/apps/web/src/features/music/components/music-archive/ArchiveMenuRadioOption.tsx b/apps/web/src/features/music/components/music-archive/ArchiveMenuRadioOption.tsx new file mode 100644 index 0000000..79d5312 --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/ArchiveMenuRadioOption.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-archive/constants"; + +type ArchiveMenuRadioOptionProps = { + isSelected: boolean; + onSelect: () => void; + children: ReactNode; +}; + +export const ArchiveMenuRadioOption = ({ + isSelected, + onSelect, + children, +}: ArchiveMenuRadioOptionProps) => { + return ( + + ); +}; diff --git a/apps/web/src/features/music/components/music-archive/ArchiveMenuTrigger.tsx b/apps/web/src/features/music/components/music-archive/ArchiveMenuTrigger.tsx new file mode 100644 index 0000000..4dad24e --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/ArchiveMenuTrigger.tsx @@ -0,0 +1,76 @@ +import { ChevronDown } from "lucide-react"; +import type { ReactNode } from "react"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-archive/constants"; +import type { OpenMenu } from "@/features/music/components/music-archive/types"; +import { cn } from "@/lib/utils"; + +type ArchiveMenuTriggerProps = { + menu: Exclude; + openMenu: OpenMenu; + onToggleMenu: (menu: Exclude) => void; + icon: ReactNode; + label: ReactNode; + menuClassName: string; + children: ReactNode; + disabled?: boolean; +}; + +export const ArchiveMenuTrigger = ({ + menu, + openMenu, + onToggleMenu, + icon, + label, + menuClassName, + children, + disabled = false, +}: ArchiveMenuTriggerProps) => { + const isOpen = !disabled && openMenu === menu; + + return ( +
+ + + {isOpen ? ( +
+ {children} +
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/features/music/components/music-archive/ArchiveSkeletonGrid.tsx b/apps/web/src/features/music/components/music-archive/ArchiveSkeletonGrid.tsx new file mode 100644 index 0000000..9019fab --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/ArchiveSkeletonGrid.tsx @@ -0,0 +1,25 @@ +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-archive/constants"; + +export const ArchiveSkeletonGrid = () => ( +
+ {Array.from({ length: 12 }).map((_, index) => ( +
+
+
+
+
+ ))} +
+); diff --git a/apps/web/src/features/music/components/music-archive/GridAlbumCard.tsx b/apps/web/src/features/music/components/music-archive/GridAlbumCard.tsx new file mode 100644 index 0000000..87c94e5 --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/GridAlbumCard.tsx @@ -0,0 +1,102 @@ +import { Link } from "@tanstack/react-router"; +import { Music } from "lucide-react"; +import type { MusicArchiveItem } from "@/features/music/api"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-archive/constants"; + +type GridAlbumCardProps = { + album: MusicArchiveItem; +}; + +export const GridAlbumCard = ({ album }: GridAlbumCardProps) => { + const stateLabel = album.viewerHasLogged ? "Listened" : album.viewerWantToListen ? "Queue" : null; + + return ( + +
+ {album.coverArtUrl ? ( + {`${album.title} + ) : ( +
+
+ +
+ + No Art + +
+ )} + + {stateLabel ? ( +
+ + {stateLabel} + +
+ ) : null} + + {album.avgRatingOutOfFive !== null ? ( +
+ + {album.avgRatingOutOfFive.toFixed(1)} + +
+ ) : null} +
+ +

+ {album.title} +

+

+ {album.artistName} + {album.firstReleaseYear ? ( + · {album.firstReleaseYear} + ) : null} +

+ + ); +}; diff --git a/apps/web/src/features/music/components/music-archive/MusicArchiveControls.tsx b/apps/web/src/features/music/components/music-archive/MusicArchiveControls.tsx new file mode 100644 index 0000000..045f315 --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/MusicArchiveControls.tsx @@ -0,0 +1,137 @@ +import { Award, Funnel, Tag } from "lucide-react"; +import type { FocusEventHandler, RefObject } from "react"; +import type { MusicArchiveResponse, MusicArchiveSort } from "@/features/music/api"; +import { ArchiveMenuRadioOption } from "@/features/music/components/music-archive/ArchiveMenuRadioOption"; +import { ArchiveMenuTrigger } from "@/features/music/components/music-archive/ArchiveMenuTrigger"; +import { + MUSIC_MODULE_STYLES, + sortOptions, + typeOptions, +} from "@/features/music/components/music-archive/constants"; +import type { OpenMenu } from "@/features/music/components/music-archive/types"; + +type MusicArchiveControlsProps = { + controlsRef: RefObject; + openMenu: OpenMenu; + onBlurCapture: FocusEventHandler; + onToggleMenu: (menu: Exclude) => void; + onCloseMenu: () => void; + selectedGenre: string; + selectedType: string; + selectedSort: MusicArchiveSort; + selectedSortLabel: string; + selectedTypeLabel: string; + availableGenres?: MusicArchiveResponse["availableGenres"]; + archiveCountLabel: string; + onSelectGenre: (genre: string) => void; + onSelectSort: (sort: MusicArchiveSort) => void; + onSelectType: (type: string) => void; +}; + +export const MusicArchiveControls = ({ + controlsRef, + openMenu, + onBlurCapture, + onToggleMenu, + onCloseMenu, + selectedGenre, + selectedType, + selectedSort, + selectedSortLabel, + selectedTypeLabel, + availableGenres, + archiveCountLabel, + onSelectGenre, + onSelectSort, + onSelectType, +}: MusicArchiveControlsProps) => { + return ( +
+
+ + Filter: + + + } + label={selectedGenre === "all" ? "All Genres" : selectedGenre} + menuClassName="min-w-42.5" + > +
+ { onSelectGenre("all"); onCloseMenu(); }} + > + All Genres + + {(availableGenres ?? []).map((genre) => ( + { onSelectGenre(genre.name); onCloseMenu(); }} + > + {typeof genre.count === "number" ? `${genre.name} (${genre.count})` : genre.name} + + ))} +
+
+ + } + label={`Type: ${selectedTypeLabel}`} + menuClassName="min-w-38" + > + {typeOptions.map((option) => ( + { onSelectType(option.value); onCloseMenu(); }} + > + {option.label} + + ))} + + + } + label={`Sort: ${selectedSortLabel}`} + menuClassName="min-w-45" + > + {sortOptions.map((option) => ( + { onSelectSort(option.value); onCloseMenu(); }} + > + {option.label} + + ))} + + +

+ {archiveCountLabel} +

+
+
+ ); +}; diff --git a/apps/web/src/features/music/components/music-archive/constants.ts b/apps/web/src/features/music/components/music-archive/constants.ts new file mode 100644 index 0000000..8688150 --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/constants.ts @@ -0,0 +1,34 @@ +import type { MusicArchiveSort } from "@/features/music/api"; + +export const ARCHIVE_PAGE_SIZE = 30; + +export const MUSIC_MODULE_STYLES = { + accent: "var(--module-music)", + text: "var(--foreground)", + muted: "color-mix(in srgb, var(--foreground) 68%, transparent)", + faint: "color-mix(in srgb, var(--foreground) 36%, transparent)", + border: "color-mix(in srgb, var(--module-music) 26%, transparent)", + borderSoft: "color-mix(in srgb, var(--module-music) 16%, transparent)", + panel: "color-mix(in srgb, var(--card) 92%, var(--background) 8%)", + panelElevated: "color-mix(in srgb, var(--card) 84%, var(--background) 16%)", + panelSoft: "color-mix(in srgb, var(--module-music) 10%, transparent)", + panelStrong: "color-mix(in srgb, var(--module-music) 26%, transparent)", + badge: "color-mix(in srgb, var(--module-music) 14%, transparent)", +} as const; + +export const sortOptions: Array<{ value: MusicArchiveSort; label: string }> = [ + { value: "logs_desc", label: "Most listened" }, + { value: "release_desc", label: "Newest release" }, + { value: "release_asc", label: "Oldest release" }, + { value: "rating_desc", label: "Highest rated" }, + { value: "title_asc", label: "Title A-Z" }, +]; + +export const typeOptions = [ + { value: "all", label: "All types" }, + { value: "Album", label: "Albums" }, + { value: "Single", label: "Singles" }, + { value: "EP", label: "EPs" }, + { value: "Broadcast", label: "Broadcasts" }, + { value: "Other", label: "Other" }, +]; diff --git a/apps/web/src/features/music/components/music-archive/types.ts b/apps/web/src/features/music/components/music-archive/types.ts new file mode 100644 index 0000000..a4bbca1 --- /dev/null +++ b/apps/web/src/features/music/components/music-archive/types.ts @@ -0,0 +1 @@ +export type OpenMenu = "genre" | "sort" | "type" | null; diff --git a/apps/web/src/features/music/components/music-detail/AlbumActionsSidebar.tsx b/apps/web/src/features/music/components/music-detail/AlbumActionsSidebar.tsx new file mode 100644 index 0000000..a205717 --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/AlbumActionsSidebar.tsx @@ -0,0 +1,198 @@ +import { Link } from "@tanstack/react-router"; +import { Check, Heart, Music, Plus } from "lucide-react"; +import type { MusicDetailResponse } from "@/features/music/api"; +import { SpaceRatingInput } from "@/features/films/components/SpaceRating"; +import { formatRatingOutOfFiveLabel } from "@/features/films/components/spaceRating.utils"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; + +type AlbumActionsSidebarProps = { + detail: MusicDetailResponse; + currentRatingOutOfFive: number | null; + isRatingSaving: boolean; + onRatingChange: (ratingOutOfFive: number | null) => void; + isAuthenticated: boolean; + wantToListen: boolean; + liked: boolean; + isInteractionBusy: boolean; + onToggleWantToListen: () => void; + onToggleLike: () => void; + onOpenLog: () => void; +}; + +export const AlbumActionsSidebar = ({ + detail, + currentRatingOutOfFive, + isRatingSaving, + onRatingChange, + isAuthenticated, + wantToListen, + liked, + isInteractionBusy, + onToggleWantToListen, + onToggleLike, + onOpenLog, +}: AlbumActionsSidebarProps) => { + const album = detail.album; + const ratingLabel = formatRatingOutOfFiveLabel(currentRatingOutOfFive) ?? "No rating yet"; + + return ( + + ); +}; diff --git a/apps/web/src/features/music/components/music-detail/AlbumDetailTopBar.tsx b/apps/web/src/features/music/components/music-detail/AlbumDetailTopBar.tsx new file mode 100644 index 0000000..d0289af --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/AlbumDetailTopBar.tsx @@ -0,0 +1,38 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronLeft } from "lucide-react"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; + +type AlbumDetailTopBarProps = { + title: string; +}; + +export const AlbumDetailTopBar = ({ title }: AlbumDetailTopBarProps) => { + return ( +
+
+ + + Music + + · + + {title} + +
+
+ ); +}; diff --git a/apps/web/src/features/music/components/music-detail/AlbumDetailsMainSection.tsx b/apps/web/src/features/music/components/music-detail/AlbumDetailsMainSection.tsx new file mode 100644 index 0000000..90aea72 --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/AlbumDetailsMainSection.tsx @@ -0,0 +1,144 @@ +import type { MusicDetailResponse } from "@/features/music/api"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; + +type AlbumDetailsMainSectionProps = { + detail: MusicDetailResponse; +}; + +export const AlbumDetailsMainSection = ({ detail }: AlbumDetailsMainSectionProps) => { + const album = detail.album; + + const communityRatingLabel = + detail.userLog?.ratingOutOfFive !== null && detail.userLog?.ratingOutOfFive !== undefined + ? detail.userLog.ratingOutOfFive.toFixed(1) + : "--"; + + const allGenres = [...(album.genres ?? [])].slice(0, 5); + const allTypes = [album.primaryType, ...(album.secondaryTypes ?? [])] + .filter(Boolean) as string[]; + + return ( +
+
+ {album.firstReleaseYear ? ( + + {album.firstReleaseYear} + + ) : null} + {allTypes.map((type) => ( + + {type} + + ))} +
+ +

+ {album.title} +

+ + {album.disambiguation ? ( +

+ ({album.disambiguation}) +

+ ) : null} + +

+ by + {album.artistName} +

+ +
+
+

+ Community +

+
+ + {communityRatingLabel} + + + {detail.logsCount.toLocaleString()} logs + +
+
+ +
+

+ Reviews +

+ + {detail.reviewCount} + +
+
+ + {allGenres.length > 0 ? ( +
+

+ Genres +

+
+ {allGenres.map((genre) => ( + + {genre.name} + + ))} +
+
+ ) : null} + + {album.firstReleaseDate ? ( +
+

+ First released +

+

+ {album.firstReleaseDate} +

+
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/features/music/components/music-detail/AlbumReviewCard.tsx b/apps/web/src/features/music/components/music-detail/AlbumReviewCard.tsx new file mode 100644 index 0000000..47add27 --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/AlbumReviewCard.tsx @@ -0,0 +1,57 @@ +import type { MusicDetailResponse } from "@/features/music/api"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; + +type AlbumReviewCardProps = { + review: MusicDetailResponse["reviews"][number]; +}; + +export const AlbumReviewCard = ({ review }: AlbumReviewCardProps) => { + const authorName = review.author.displayUsername ?? review.author.username; + + return ( +
+
+ + {authorName} + + {review.ratingOutOfFive !== null ? ( + + {review.ratingOutOfFive.toFixed(1)} / 5 + + ) : null} + {review.containsSpoilers ? ( + + spoilers + + ) : null} + + {review.likeCount} {review.likeCount === 1 ? "like" : "likes"} + +
+

+ {review.content} +

+
+ ); +}; diff --git a/apps/web/src/features/music/components/music-detail/AlbumReviewsSection.tsx b/apps/web/src/features/music/components/music-detail/AlbumReviewsSection.tsx new file mode 100644 index 0000000..d82dda6 --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/AlbumReviewsSection.tsx @@ -0,0 +1,75 @@ +import type { MusicDetailResponse, MusicDetailReviewSort } from "@/features/music/api"; +import { AlbumReviewCard } from "@/features/music/components/music-detail/AlbumReviewCard"; +import { MUSIC_MODULE_STYLES } from "@/features/music/components/music-detail/styles"; + +type AlbumReviewsSectionProps = { + reviewsSort: MusicDetailReviewSort; + onSortChange: (nextSort: MusicDetailReviewSort) => void; + reviews: MusicDetailResponse["reviews"]; +}; + +export const AlbumReviewsSection = ({ + reviewsSort, + onSortChange, + reviews, +}: AlbumReviewsSectionProps) => { + return ( +
+
+

+ Reviews +

+ +
+ {(["popular", "recent"] as const).map((sort) => ( + + ))} +
+
+ + {reviews.length === 0 ? ( +
+ No reviews yet for this album. +
+ ) : ( +
+ {reviews.map((review) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/features/music/components/music-detail/styles.ts b/apps/web/src/features/music/components/music-detail/styles.ts new file mode 100644 index 0000000..e57ab36 --- /dev/null +++ b/apps/web/src/features/music/components/music-detail/styles.ts @@ -0,0 +1,13 @@ +export const MUSIC_MODULE_STYLES = { + accent: "var(--module-music)", + text: "var(--foreground)", + muted: "color-mix(in srgb, var(--foreground) 68%, transparent)", + faint: "color-mix(in srgb, var(--foreground) 36%, transparent)", + border: "color-mix(in srgb, var(--module-music) 26%, transparent)", + borderSoft: "color-mix(in srgb, var(--module-music) 16%, transparent)", + panel: "color-mix(in srgb, var(--card) 92%, var(--background) 8%)", + panelElevated: "color-mix(in srgb, var(--card) 84%, var(--background) 16%)", + panelSoft: "color-mix(in srgb, var(--module-music) 10%, transparent)", + panelStrong: "color-mix(in srgb, var(--module-music) 26%, transparent)", + badge: "color-mix(in srgb, var(--module-music) 14%, transparent)", +} as const; diff --git a/apps/web/src/features/music/hooks/useMusic.ts b/apps/web/src/features/music/hooks/useMusic.ts new file mode 100644 index 0000000..84316fd --- /dev/null +++ b/apps/web/src/features/music/hooks/useMusic.ts @@ -0,0 +1,150 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + createMusicLog, + deleteMusicLog, + getMusicArchive, + getMusicDetail, + getMusicInteraction, + getMyMusicLogs, + searchMusic, + updateMusicInteraction, + updateMusicLog, + type CreateMusicLogInput, + type MusicArchiveSort, + type MusicDetailReviewSort, + type MusicInteraction, + type MyMusicLog, + type UpdateMusicInteractionInput, + type UpdateMusicLogInput, +} from "@/features/music/api"; + +export const musicKeys = { + all: ["music"] as const, + search: (query: string) => ["music", "search", query] as const, + detailView: (mbid: string, reviewsSort: MusicDetailReviewSort) => + ["music", "detail-view", mbid, reviewsSort] as const, + interaction: (mbid: string) => ["music", "interaction", mbid] as const, + myLogs: ["music", "my-logs"] as const, + archive: (genre: string, type: string, sort: MusicArchiveSort, limit: number) => + ["music", "archive", genre, type, sort, limit] as const, +}; + +export const useMusicSearch = (query: string) => + useQuery({ + queryKey: musicKeys.search(query), + queryFn: ({ signal }) => searchMusic(query, { signal }), + enabled: query.trim().length >= 2, + }); + +export const useMusicDetailView = ( + mbid: string, + reviewsSort: MusicDetailReviewSort, + enabled = true, +) => + useQuery({ + queryKey: musicKeys.detailView(mbid, reviewsSort), + queryFn: ({ signal }) => getMusicDetail(mbid, { reviewsSort }, { signal }), + enabled: enabled && mbid.length > 0, + }); + +export const useMusicInteraction = (mbid: string, enabled = true) => + useQuery({ + queryKey: musicKeys.interaction(mbid), + queryFn: () => getMusicInteraction(mbid), + enabled: enabled && mbid.length > 0, + }); + +export const useUpdateMusicInteraction = (mbid: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: UpdateMusicInteractionInput) => + updateMusicInteraction(mbid, input), + onMutate: async (input) => { + const queryKey = musicKeys.interaction(mbid); + await queryClient.cancelQueries({ queryKey }); + const previousState = queryClient.getQueryData(queryKey); + if (previousState) { + queryClient.setQueryData(queryKey, { + ...previousState, + ...(input.liked !== undefined ? { liked: input.liked } : {}), + ...(input.wantToListen !== undefined ? { wantToListen: input.wantToListen } : {}), + ...(input.ratingOutOfFive !== undefined ? { ratingOutOfFive: input.ratingOutOfFive } : {}), + }); + } + return { previousState }; + }, + onError: (_error, _input, context) => { + if (!context?.previousState) return; + queryClient.setQueryData(musicKeys.interaction(mbid), context.previousState); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: musicKeys.interaction(mbid) }); + }, + }); +}; + +export const useCreateMusicLog = (mbid: string) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: CreateMusicLogInput) => createMusicLog(mbid, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: musicKeys.myLogs }); + queryClient.invalidateQueries({ queryKey: musicKeys.all }); + }, + }); +}; + +export const useMyMusicLogs = () => + useQuery({ + queryKey: musicKeys.myLogs, + queryFn: getMyMusicLogs, + }); + +export const useUpdateMusicLog = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ entryId, input }: { entryId: string; input: UpdateMusicLogInput }) => + updateMusicLog(entryId, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: musicKeys.myLogs }); + }, + }); +}; + +export const useDeleteMusicLog = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (entryId: string) => deleteMusicLog(entryId), + onSuccess: (_data, entryId) => { + queryClient.invalidateQueries({ queryKey: musicKeys.myLogs }); + queryClient.setQueryData(musicKeys.myLogs, (prev) => + prev ? prev.filter((e) => e.id !== entryId) : prev, + ); + }, + }); +}; + +export const useMusicArchive = ( + genre: string, + type: string, + sort: MusicArchiveSort, + limit: number, +) => + useInfiniteQuery({ + queryKey: musicKeys.archive(genre, type, sort, limit), + initialPageParam: 1, + queryFn: ({ signal, pageParam }) => { + const page = typeof pageParam === "number" ? pageParam : 1; + return getMusicArchive({ genre, type, sort, page, limit }, { signal }); + }, + getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined, + }); diff --git a/apps/web/src/features/search/components/GlobalSearchDialog.tsx b/apps/web/src/features/search/components/GlobalSearchDialog.tsx index eee5ae3..2e01978 100644 --- a/apps/web/src/features/search/components/GlobalSearchDialog.tsx +++ b/apps/web/src/features/search/components/GlobalSearchDialog.tsx @@ -92,6 +92,44 @@ export const GlobalSearchDialog = ({ ); }; + const openMusic = (mbid: string) => { + closeDialog(); + + void navigateWithViewTransitionFallback( + () => + navigate({ + to: "/music/$mbid", + params: { mbid }, + viewTransition: true, + startTransition: true, + }), + () => + navigate({ + to: "/music/$mbid", + params: { mbid }, + }), + ); + }; + + const openBook = (volumeId: string) => { + closeDialog(); + + void navigateWithViewTransitionFallback( + () => + navigate({ + to: "/books/$volumeId", + params: { volumeId }, + viewTransition: true, + startTransition: true, + }), + () => + navigate({ + to: "/books/$volumeId", + params: { volumeId }, + }), + ); + }; + useEffect(() => { if (!isOpen) { return; @@ -147,7 +185,7 @@ export const GlobalSearchDialog = ({ return; } - openSearchEntry(selectedEntry, { openCinema, openSerial, openUser }); + openSearchEntry(selectedEntry, { openCinema, openSerial, openUser, openMusic, openBook }); } }; @@ -158,7 +196,7 @@ export const GlobalSearchDialog = ({ const currentPlaceholder = state.isScopedMode && state.scopedTarget ? scopedPlaceholder[state.scopedTarget] - : "Search users, cinema, serials..."; + : "Search users, cinema, serials, music, books..."; return createPortal(
@@ -254,7 +292,7 @@ export const GlobalSearchDialog = ({ effectiveHighlightedIndex={state.effectiveHighlightedIndex} onHoverIndex={state.setHighlightedIndex} onSelectEntry={(entry) => { - openSearchEntry(entry, { openCinema, openSerial, openUser }); + openSearchEntry(entry, { openCinema, openSerial, openUser, openMusic, openBook }); }} onEnterScope={(target) => { state.enterScopedMode(target); diff --git a/apps/web/src/features/search/components/global-search/GlobalSearchResults.tsx b/apps/web/src/features/search/components/global-search/GlobalSearchResults.tsx index 7c79ad9..af240d3 100644 --- a/apps/web/src/features/search/components/global-search/GlobalSearchResults.tsx +++ b/apps/web/src/features/search/components/global-search/GlobalSearchResults.tsx @@ -186,7 +186,7 @@ export const GlobalSearchResults = ({

{isScopedMode ? "No matches found for this scope." - : "No matches found across users, cinema, and serials."} + : "No matches found across users, cinema, serials, music, or books."}

) : null}
diff --git a/apps/web/src/features/search/components/global-search/SearchResultRow.tsx b/apps/web/src/features/search/components/global-search/SearchResultRow.tsx index c8f157b..b44c6c3 100644 --- a/apps/web/src/features/search/components/global-search/SearchResultRow.tsx +++ b/apps/web/src/features/search/components/global-search/SearchResultRow.tsx @@ -1,4 +1,4 @@ -import { ArrowRight } from "lucide-react"; +import { ArrowRight, BookOpen, Music } from "lucide-react"; import { getPosterUrl } from "@/features/films/components/utils"; import { cn } from "@/lib/utils"; import { toYear } from "./mappers"; @@ -13,6 +13,14 @@ type SearchResultRowProps = { onSelect: (entry: SearchResultEntry) => void; }; +const rowButtonClass = (isHighlighted: boolean) => + cn( + "group flex w-full items-center gap-3 border px-3 py-2 text-left transition-all", + isHighlighted + ? "border-primary/45 bg-primary/10" + : "border-border/70 bg-background/40 hover:bg-secondary/35", + ); + export const SearchResultRow = ({ inputId, entry, @@ -29,12 +37,7 @@ export const SearchResultRow = ({ type="button" role="option" aria-selected={isHighlighted} - className={cn( - "group flex w-full items-center gap-3 border px-3 py-2 text-left transition-all", - isHighlighted - ? "border-primary/45 bg-primary/10" - : "border-border/70 bg-background/40 hover:bg-secondary/35", - )} + className={rowButtonClass(isHighlighted)} onMouseEnter={() => onHover(index)} onClick={() => onSelect(entry)} > @@ -73,6 +76,92 @@ export const SearchResultRow = ({ ); } + if (entry.kind === "music") { + return ( +
  • + +
  • + ); + } + + if (entry.kind === "books") { + return ( +
  • + +
  • + ); + } + const isCinema = entry.kind === "cinema"; const year = isCinema ? toYear(entry.releaseDate) : toYear(entry.firstAirDate); @@ -83,12 +172,7 @@ export const SearchResultRow = ({ type="button" role="option" aria-selected={isHighlighted} - className={cn( - "group flex w-full items-center gap-3 border px-3 py-2 text-left transition-all", - isHighlighted - ? "border-primary/45 bg-primary/10" - : "border-border/70 bg-background/40 hover:bg-secondary/35", - )} + className={rowButtonClass(isHighlighted)} onMouseEnter={() => onHover(index)} onClick={() => onSelect(entry)} > diff --git a/apps/web/src/features/search/components/global-search/constants.ts b/apps/web/src/features/search/components/global-search/constants.ts index c327322..92427bd 100644 --- a/apps/web/src/features/search/components/global-search/constants.ts +++ b/apps/web/src/features/search/components/global-search/constants.ts @@ -1,4 +1,4 @@ -import { Film, Tv, Users } from "lucide-react"; +import { BookOpen, Film, Music, Tv, Users } from "lucide-react"; import type { QuickLink, ScopedTarget } from "./types"; export const MIN_QUERY_LENGTH = 2; @@ -8,12 +8,16 @@ export const scopedPlaceholder: Record = { users: "Search among users", cinema: "Search among cinema", serials: "Search among serials", + music: "Search among music", + books: "Search among books", }; export const scopedEmptyPrompt: Record = { users: "Search among users", cinema: "Search among cinema", serials: "Search among serials", + music: "Search among music", + books: "Search among books", }; export const quickLinks: QuickLink[] = [ @@ -41,4 +45,20 @@ export const quickLinks: QuickLink[] = [ color: "var(--module-serial)", tint: "rgba(0, 207, 255, 0.1)", }, + { + target: "music", + title: "Discover Music", + description: "Search among all albums", + icon: Music, + color: "var(--module-music)", + tint: "color-mix(in srgb, var(--module-music) 12%, transparent)", + }, + { + target: "books", + title: "Discover Books", + description: "Search among all books", + icon: BookOpen, + color: "var(--module-book)", + tint: "color-mix(in srgb, var(--module-book) 12%, transparent)", + }, ]; diff --git a/apps/web/src/features/search/components/global-search/mappers.ts b/apps/web/src/features/search/components/global-search/mappers.ts index 098bda4..75bd0c6 100644 --- a/apps/web/src/features/search/components/global-search/mappers.ts +++ b/apps/web/src/features/search/components/global-search/mappers.ts @@ -1,7 +1,15 @@ +import type { GoogleBooksVolume } from "@/features/books/api"; +import type { MbSearchResult } from "@/features/music/api"; import type { UserSearchResult } from "@/features/profile/api"; import type { TmdbSearchSeries } from "@/features/serials/api"; import type { TmdbSearchMovie } from "@/types/api"; -import type { CinemaResultEntry, SerialResultEntry, UserResultEntry } from "./types"; +import type { + BookResultEntry, + CinemaResultEntry, + MusicResultEntry, + SerialResultEntry, + UserResultEntry, +} from "./types"; export const toUserEntry = (profile: UserSearchResult): UserResultEntry => { const profileName = profile.displayUsername?.trim() || profile.username; @@ -33,6 +41,32 @@ export const toSerialEntry = (series: TmdbSearchSeries): SerialResultEntry => ({ firstAirDate: series.first_air_date, }); +export const toMusicEntry = (result: MbSearchResult): MusicResultEntry => { + const artistName = result["artist-credit"] + .map((credit) => credit.name + (credit.joinphrase ?? "")) + .join("") || "Unknown Artist"; + + return { + kind: "music", + id: `music-${result.id}`, + mbid: result.id, + title: result.title, + artistName, + primaryType: result["primary-type"] ?? null, + firstReleaseDate: result["first-release-date"] ?? null, + }; +}; + +export const toBookEntry = (volume: GoogleBooksVolume): BookResultEntry => ({ + kind: "books", + id: `books-${volume.id}`, + volumeId: volume.id, + title: volume.volumeInfo.title, + authors: volume.volumeInfo.authors, + coverImageUrl: volume.volumeInfo.imageLinks?.thumbnail ?? null, + publishedDate: volume.volumeInfo.publishedDate ?? null, +}); + export const toYear = (value: string | null | undefined): string | null => { if (!value || value.length < 4) { return null; diff --git a/apps/web/src/features/search/components/global-search/navigation.ts b/apps/web/src/features/search/components/global-search/navigation.ts index a91c6ce..7ab7121 100644 --- a/apps/web/src/features/search/components/global-search/navigation.ts +++ b/apps/web/src/features/search/components/global-search/navigation.ts @@ -4,6 +4,8 @@ type NavigationHandlers = { openCinema: (tmdbId: number) => void; openSerial: (tmdbId: number) => void; openUser: (username: string) => void; + openMusic: (mbid: string) => void; + openBook: (volumeId: string) => void; }; export const openSearchEntry = ( @@ -20,5 +22,15 @@ export const openSearchEntry = ( return; } - handlers.openSerial(entry.tmdbId); + if (entry.kind === "serials") { + handlers.openSerial(entry.tmdbId); + return; + } + + if (entry.kind === "music") { + handlers.openMusic(entry.mbid); + return; + } + + handlers.openBook(entry.volumeId); }; diff --git a/apps/web/src/features/search/components/global-search/types.ts b/apps/web/src/features/search/components/global-search/types.ts index 902e052..67fd219 100644 --- a/apps/web/src/features/search/components/global-search/types.ts +++ b/apps/web/src/features/search/components/global-search/types.ts @@ -2,9 +2,11 @@ import type { ComponentType, CSSProperties } from "react"; import type { UserSearchResult } from "@/features/profile/api"; import type { TmdbSearchSeries } from "@/features/serials/api"; import type { TmdbSearchMovie } from "@/types/api"; +import type { MbSearchResult } from "@/features/music/api"; +import type { GoogleBooksVolume } from "@/features/books/api"; export type SearchMode = "home" | "scoped"; -export type ScopedTarget = "users" | "cinema" | "serials"; +export type ScopedTarget = "users" | "cinema" | "serials" | "music" | "books"; export type UserResultEntry = { kind: "users"; @@ -32,10 +34,32 @@ export type SerialResultEntry = { firstAirDate: string; }; +export type MusicResultEntry = { + kind: "music"; + id: string; + mbid: string; + title: string; + artistName: string; + primaryType: string | null; + firstReleaseDate: string | null; +}; + +export type BookResultEntry = { + kind: "books"; + id: string; + volumeId: string; + title: string; + authors: string[]; + coverImageUrl: string | null; + publishedDate: string | null; +}; + export type SearchResultEntry = | UserResultEntry | CinemaResultEntry - | SerialResultEntry; + | SerialResultEntry + | MusicResultEntry + | BookResultEntry; export type SearchSection = { target: ScopedTarget; @@ -63,4 +87,6 @@ export type SearchResultMappers = { toUserEntry: (profile: UserSearchResult) => UserResultEntry; toCinemaEntry: (movie: TmdbSearchMovie) => CinemaResultEntry; toSerialEntry: (series: TmdbSearchSeries) => SerialResultEntry; + toMusicEntry: (result: MbSearchResult) => MusicResultEntry; + toBookEntry: (volume: GoogleBooksVolume) => BookResultEntry; }; diff --git a/apps/web/src/features/search/components/global-search/useGlobalSearchState.ts b/apps/web/src/features/search/components/global-search/useGlobalSearchState.ts index c2b5870..658fc1a 100644 --- a/apps/web/src/features/search/components/global-search/useGlobalSearchState.ts +++ b/apps/web/src/features/search/components/global-search/useGlobalSearchState.ts @@ -5,14 +5,16 @@ import { type Dispatch, type SetStateAction, } from "react"; +import { useBookSearch } from "@/features/books/hooks/useBooks"; import { useMovieSearch } from "@/features/films/hooks/useMovies"; +import { useMusicSearch } from "@/features/music/hooks/useMusic"; import { useUserSearch } from "@/features/profile/hooks/useProfile"; import { useSerialSearch } from "@/features/serials/hooks/useSerials"; import { MAX_RESULTS_PER_SECTION, MIN_QUERY_LENGTH, } from "./constants"; -import { toCinemaEntry, toSerialEntry, toUserEntry } from "./mappers"; +import { toBookEntry, toCinemaEntry, toMusicEntry, toSerialEntry, toUserEntry } from "./mappers"; import type { ScopedTarget, SearchMode, @@ -68,10 +70,20 @@ export const useGlobalSearchState = (): GlobalSearchState => { shouldRunSearch && (!isScopedMode || scopedTarget === "serials") ? deferredQuery : ""; + const musicQueryValue = + shouldRunSearch && (!isScopedMode || scopedTarget === "music") + ? deferredQuery + : ""; + const booksQueryValue = + shouldRunSearch && (!isScopedMode || scopedTarget === "books") + ? deferredQuery + : ""; const usersQuery = useUserSearch(usersQueryValue, MAX_RESULTS_PER_SECTION); const cinemaQuery = useMovieSearch(cinemaQueryValue); const serialsQuery = useSerialSearch(serialsQueryValue); + const musicQuery = useMusicSearch(musicQueryValue); + const booksQuery = useBookSearch(booksQueryValue); const userEntries = useMemo(() => { return (usersQuery.data ?? []).slice(0, MAX_RESULTS_PER_SECTION).map(toUserEntry); @@ -85,6 +97,14 @@ export const useGlobalSearchState = (): GlobalSearchState => { return (serialsQuery.data ?? []).slice(0, MAX_RESULTS_PER_SECTION).map(toSerialEntry); }, [serialsQuery.data]); + const musicEntries = useMemo(() => { + return (musicQuery.data ?? []).slice(0, MAX_RESULTS_PER_SECTION).map(toMusicEntry); + }, [musicQuery.data]); + + const bookEntries = useMemo(() => { + return (booksQuery.data ?? []).slice(0, MAX_RESULTS_PER_SECTION).map(toBookEntry); + }, [booksQuery.data]); + const sections = useMemo(() => { if (!shouldRunSearch) { return []; @@ -126,6 +146,30 @@ export const useGlobalSearchState = (): GlobalSearchState => { ]; } + if (isScopedMode && scopedTarget === "music") { + return [ + { + target: "music", + label: "Music", + items: musicEntries, + isLoading: musicQuery.isFetching, + isError: musicQuery.isError, + }, + ]; + } + + if (isScopedMode && scopedTarget === "books") { + return [ + { + target: "books", + label: "Books", + items: bookEntries, + isLoading: booksQuery.isFetching, + isError: booksQuery.isError, + }, + ]; + } + return [ { target: "users", @@ -148,6 +192,20 @@ export const useGlobalSearchState = (): GlobalSearchState => { isLoading: serialsQuery.isFetching, isError: serialsQuery.isError, }, + { + target: "music", + label: "Music", + items: musicEntries, + isLoading: musicQuery.isFetching, + isError: musicQuery.isError, + }, + { + target: "books", + label: "Books", + items: bookEntries, + isLoading: booksQuery.isFetching, + isError: booksQuery.isError, + }, ]; }, [ shouldRunSearch, @@ -156,12 +214,18 @@ export const useGlobalSearchState = (): GlobalSearchState => { userEntries, cinemaEntries, serialEntries, + musicEntries, + bookEntries, usersQuery.isFetching, usersQuery.isError, cinemaQuery.isFetching, cinemaQuery.isError, serialsQuery.isFetching, serialsQuery.isError, + musicQuery.isFetching, + musicQuery.isError, + booksQuery.isFetching, + booksQuery.isError, ]); const sectionOffsets = useMemo(() => { diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 03e7924..e59a64d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,8 +16,10 @@ import { Route as SettingsRouteRouteImport } from './routes/settings/route' import { Route as IndexRouteImport } from './routes/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as SerialsIndexRouteImport } from './routes/serials/index' +import { Route as MusicIndexRouteImport } from './routes/music/index' import { Route as FilmsIndexRouteImport } from './routes/films/index' import { Route as CinemaIndexRouteImport } from './routes/cinema/index' +import { Route as BooksIndexRouteImport } from './routes/books/index' import { Route as AdminIndexRouteImport } from './routes/admin/index' import { Route as SettingsThemeRouteImport } from './routes/settings/theme' import { Route as SettingsProfileRouteImport } from './routes/settings/profile' @@ -26,9 +28,11 @@ import { Route as SettingsFavoritesRouteImport } from './routes/settings/favorit import { Route as SettingsDataRouteImport } from './routes/settings/data' import { Route as SettingsAuthRouteImport } from './routes/settings/auth' import { Route as SerialsTmdbIdRouteImport } from './routes/serials/$tmdbId' +import { Route as MusicMbidRouteImport } from './routes/music/$mbid' import { Route as FilmsTmdbIdRouteImport } from './routes/films/$tmdbId' import { Route as DirectorSlugRouteImport } from './routes/director/$slug' import { Route as CinemaTmdbIdRouteImport } from './routes/cinema/$tmdbId' +import { Route as BooksVolumeIdRouteImport } from './routes/books/$volumeId' import { Route as ActorSlugRouteImport } from './routes/actor/$slug' import { Route as ProfileUsernameRouteRouteImport } from './routes/profile/$username/route' import { Route as ProfileUsernameIndexRouteImport } from './routes/profile/$username/index' @@ -79,6 +83,11 @@ const SerialsIndexRoute = SerialsIndexRouteImport.update({ path: '/serials/', getParentRoute: () => rootRouteImport, } as any) +const MusicIndexRoute = MusicIndexRouteImport.update({ + id: '/music/', + path: '/music/', + getParentRoute: () => rootRouteImport, +} as any) const FilmsIndexRoute = FilmsIndexRouteImport.update({ id: '/films/', path: '/films/', @@ -89,6 +98,11 @@ const CinemaIndexRoute = CinemaIndexRouteImport.update({ path: '/cinema/', getParentRoute: () => rootRouteImport, } as any) +const BooksIndexRoute = BooksIndexRouteImport.update({ + id: '/books/', + path: '/books/', + getParentRoute: () => rootRouteImport, +} as any) const AdminIndexRoute = AdminIndexRouteImport.update({ id: '/admin/', path: '/admin/', @@ -129,6 +143,11 @@ const SerialsTmdbIdRoute = SerialsTmdbIdRouteImport.update({ path: '/serials/$tmdbId', getParentRoute: () => rootRouteImport, } as any) +const MusicMbidRoute = MusicMbidRouteImport.update({ + id: '/music/$mbid', + path: '/music/$mbid', + getParentRoute: () => rootRouteImport, +} as any) const FilmsTmdbIdRoute = FilmsTmdbIdRouteImport.update({ id: '/films/$tmdbId', path: '/films/$tmdbId', @@ -144,6 +163,11 @@ const CinemaTmdbIdRoute = CinemaTmdbIdRouteImport.update({ path: '/cinema/$tmdbId', getParentRoute: () => rootRouteImport, } as any) +const BooksVolumeIdRoute = BooksVolumeIdRouteImport.update({ + id: '/books/$volumeId', + path: '/books/$volumeId', + getParentRoute: () => rootRouteImport, +} as any) const ActorSlugRoute = ActorSlugRouteImport.update({ id: '/actor/$slug', path: '/actor/$slug', @@ -227,9 +251,11 @@ export interface FileRoutesByFullPath { '/register': typeof RegisterRoute '/profile/$username': typeof ProfileUsernameRouteRouteWithChildren '/actor/$slug': typeof ActorSlugRoute + '/books/$volumeId': typeof BooksVolumeIdRoute '/cinema/$tmdbId': typeof CinemaTmdbIdRoute '/director/$slug': typeof DirectorSlugRoute '/films/$tmdbId': typeof FilmsTmdbIdRoute + '/music/$mbid': typeof MusicMbidRoute '/serials/$tmdbId': typeof SerialsTmdbIdRoute '/settings/auth': typeof SettingsAuthRoute '/settings/data': typeof SettingsDataRoute @@ -238,8 +264,10 @@ export interface FileRoutesByFullPath { '/settings/profile': typeof SettingsProfileRoute '/settings/theme': typeof SettingsThemeRoute '/admin/': typeof AdminIndexRoute + '/books/': typeof BooksIndexRoute '/cinema/': typeof CinemaIndexRoute '/films/': typeof FilmsIndexRoute + '/music/': typeof MusicIndexRoute '/serials/': typeof SerialsIndexRoute '/settings/': typeof SettingsIndexRoute '/profile/$username/cinema': typeof ProfileUsernameCinemaRoute @@ -261,9 +289,11 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/register': typeof RegisterRoute '/actor/$slug': typeof ActorSlugRoute + '/books/$volumeId': typeof BooksVolumeIdRoute '/cinema/$tmdbId': typeof CinemaTmdbIdRoute '/director/$slug': typeof DirectorSlugRoute '/films/$tmdbId': typeof FilmsTmdbIdRoute + '/music/$mbid': typeof MusicMbidRoute '/serials/$tmdbId': typeof SerialsTmdbIdRoute '/settings/auth': typeof SettingsAuthRoute '/settings/data': typeof SettingsDataRoute @@ -272,8 +302,10 @@ export interface FileRoutesByTo { '/settings/profile': typeof SettingsProfileRoute '/settings/theme': typeof SettingsThemeRoute '/admin': typeof AdminIndexRoute + '/books': typeof BooksIndexRoute '/cinema': typeof CinemaIndexRoute '/films': typeof FilmsIndexRoute + '/music': typeof MusicIndexRoute '/serials': typeof SerialsIndexRoute '/settings': typeof SettingsIndexRoute '/profile/$username/cinema': typeof ProfileUsernameCinemaRoute @@ -298,9 +330,11 @@ export interface FileRoutesById { '/register': typeof RegisterRoute '/profile/$username': typeof ProfileUsernameRouteRouteWithChildren '/actor/$slug': typeof ActorSlugRoute + '/books/$volumeId': typeof BooksVolumeIdRoute '/cinema/$tmdbId': typeof CinemaTmdbIdRoute '/director/$slug': typeof DirectorSlugRoute '/films/$tmdbId': typeof FilmsTmdbIdRoute + '/music/$mbid': typeof MusicMbidRoute '/serials/$tmdbId': typeof SerialsTmdbIdRoute '/settings/auth': typeof SettingsAuthRoute '/settings/data': typeof SettingsDataRoute @@ -309,8 +343,10 @@ export interface FileRoutesById { '/settings/profile': typeof SettingsProfileRoute '/settings/theme': typeof SettingsThemeRoute '/admin/': typeof AdminIndexRoute + '/books/': typeof BooksIndexRoute '/cinema/': typeof CinemaIndexRoute '/films/': typeof FilmsIndexRoute + '/music/': typeof MusicIndexRoute '/serials/': typeof SerialsIndexRoute '/settings/': typeof SettingsIndexRoute '/profile/$username/cinema': typeof ProfileUsernameCinemaRoute @@ -336,9 +372,11 @@ export interface FileRouteTypes { | '/register' | '/profile/$username' | '/actor/$slug' + | '/books/$volumeId' | '/cinema/$tmdbId' | '/director/$slug' | '/films/$tmdbId' + | '/music/$mbid' | '/serials/$tmdbId' | '/settings/auth' | '/settings/data' @@ -347,8 +385,10 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/theme' | '/admin/' + | '/books/' | '/cinema/' | '/films/' + | '/music/' | '/serials/' | '/settings/' | '/profile/$username/cinema' @@ -370,9 +410,11 @@ export interface FileRouteTypes { | '/login' | '/register' | '/actor/$slug' + | '/books/$volumeId' | '/cinema/$tmdbId' | '/director/$slug' | '/films/$tmdbId' + | '/music/$mbid' | '/serials/$tmdbId' | '/settings/auth' | '/settings/data' @@ -381,8 +423,10 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/theme' | '/admin' + | '/books' | '/cinema' | '/films' + | '/music' | '/serials' | '/settings' | '/profile/$username/cinema' @@ -406,9 +450,11 @@ export interface FileRouteTypes { | '/register' | '/profile/$username' | '/actor/$slug' + | '/books/$volumeId' | '/cinema/$tmdbId' | '/director/$slug' | '/films/$tmdbId' + | '/music/$mbid' | '/serials/$tmdbId' | '/settings/auth' | '/settings/data' @@ -417,8 +463,10 @@ export interface FileRouteTypes { | '/settings/profile' | '/settings/theme' | '/admin/' + | '/books/' | '/cinema/' | '/films/' + | '/music/' | '/serials/' | '/settings/' | '/profile/$username/cinema' @@ -443,13 +491,17 @@ export interface RootRouteChildren { RegisterRoute: typeof RegisterRoute ProfileUsernameRouteRoute: typeof ProfileUsernameRouteRouteWithChildren ActorSlugRoute: typeof ActorSlugRoute + BooksVolumeIdRoute: typeof BooksVolumeIdRoute CinemaTmdbIdRoute: typeof CinemaTmdbIdRoute DirectorSlugRoute: typeof DirectorSlugRoute FilmsTmdbIdRoute: typeof FilmsTmdbIdRoute + MusicMbidRoute: typeof MusicMbidRoute SerialsTmdbIdRoute: typeof SerialsTmdbIdRoute AdminIndexRoute: typeof AdminIndexRoute + BooksIndexRoute: typeof BooksIndexRoute CinemaIndexRoute: typeof CinemaIndexRoute FilmsIndexRoute: typeof FilmsIndexRoute + MusicIndexRoute: typeof MusicIndexRoute SerialsIndexRoute: typeof SerialsIndexRoute ReviewsUsernameReviewIdRoute: typeof ReviewsUsernameReviewIdRoute } @@ -505,6 +557,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SerialsIndexRouteImport parentRoute: typeof rootRouteImport } + '/music/': { + id: '/music/' + path: '/music' + fullPath: '/music/' + preLoaderRoute: typeof MusicIndexRouteImport + parentRoute: typeof rootRouteImport + } '/films/': { id: '/films/' path: '/films' @@ -519,6 +578,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CinemaIndexRouteImport parentRoute: typeof rootRouteImport } + '/books/': { + id: '/books/' + path: '/books' + fullPath: '/books/' + preLoaderRoute: typeof BooksIndexRouteImport + parentRoute: typeof rootRouteImport + } '/admin/': { id: '/admin/' path: '/admin' @@ -575,6 +641,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SerialsTmdbIdRouteImport parentRoute: typeof rootRouteImport } + '/music/$mbid': { + id: '/music/$mbid' + path: '/music/$mbid' + fullPath: '/music/$mbid' + preLoaderRoute: typeof MusicMbidRouteImport + parentRoute: typeof rootRouteImport + } '/films/$tmdbId': { id: '/films/$tmdbId' path: '/films/$tmdbId' @@ -596,6 +669,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CinemaTmdbIdRouteImport parentRoute: typeof rootRouteImport } + '/books/$volumeId': { + id: '/books/$volumeId' + path: '/books/$volumeId' + fullPath: '/books/$volumeId' + preLoaderRoute: typeof BooksVolumeIdRouteImport + parentRoute: typeof rootRouteImport + } '/actor/$slug': { id: '/actor/$slug' path: '/actor/$slug' @@ -760,13 +840,17 @@ const rootRouteChildren: RootRouteChildren = { RegisterRoute: RegisterRoute, ProfileUsernameRouteRoute: ProfileUsernameRouteRouteWithChildren, ActorSlugRoute: ActorSlugRoute, + BooksVolumeIdRoute: BooksVolumeIdRoute, CinemaTmdbIdRoute: CinemaTmdbIdRoute, DirectorSlugRoute: DirectorSlugRoute, FilmsTmdbIdRoute: FilmsTmdbIdRoute, + MusicMbidRoute: MusicMbidRoute, SerialsTmdbIdRoute: SerialsTmdbIdRoute, AdminIndexRoute: AdminIndexRoute, + BooksIndexRoute: BooksIndexRoute, CinemaIndexRoute: CinemaIndexRoute, FilmsIndexRoute: FilmsIndexRoute, + MusicIndexRoute: MusicIndexRoute, SerialsIndexRoute: SerialsIndexRoute, ReviewsUsernameReviewIdRoute: ReviewsUsernameReviewIdRoute, } diff --git a/apps/web/src/routes/books/$volumeId.tsx b/apps/web/src/routes/books/$volumeId.tsx new file mode 100644 index 0000000..ee97d83 --- /dev/null +++ b/apps/web/src/routes/books/$volumeId.tsx @@ -0,0 +1,33 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { getBookDetail } from "@/features/books/api"; +import { BookDetailPage } from "@/features/books/components/BookDetailPage"; +import { bookKeys } from "@/features/books/hooks/useBooks"; +import { RouteErrorBoundary } from "@/lib/router/RouteErrorBoundary"; + +function parseVolumeIdParam(raw: string): string | null { + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export const Route = createFileRoute("/books/$volumeId")({ + beforeLoad: ({ params }) => { + if (parseVolumeIdParam(params.volumeId) !== null) return; + throw redirect({ to: "/books" }); + }, + loader: async ({ context, params }) => { + const volumeId = parseVolumeIdParam(params.volumeId); + if (!volumeId) return; + + await context.queryClient.prefetchQuery({ + queryKey: bookKeys.detailView(volumeId, "popular"), + queryFn: ({ signal }) => getBookDetail(volumeId, { reviewsSort: "popular" }, { signal }), + }); + }, + component: BookDetailRoute, + errorComponent: (props) => , +}); + +function BookDetailRoute() { + const { volumeId } = Route.useParams(); + return ; +} diff --git a/apps/web/src/routes/books/index.tsx b/apps/web/src/routes/books/index.tsx new file mode 100644 index 0000000..fab491e --- /dev/null +++ b/apps/web/src/routes/books/index.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BooksArchivePage } from "@/features/books/components/BooksArchivePage"; +import { RouteErrorBoundary } from "@/lib/router/RouteErrorBoundary"; + +export const Route = createFileRoute("/books/")({ + component: BooksPage, + errorComponent: (props) => , +}); + +function BooksPage() { + return ; +} diff --git a/apps/web/src/routes/music/$mbid.tsx b/apps/web/src/routes/music/$mbid.tsx new file mode 100644 index 0000000..0b2e5cb --- /dev/null +++ b/apps/web/src/routes/music/$mbid.tsx @@ -0,0 +1,35 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { getMusicDetail } from "@/features/music/api"; +import { MusicDetailPage } from "@/features/music/components/MusicDetailPage"; +import { musicKeys } from "@/features/music/hooks/useMusic"; +import { RouteErrorBoundary } from "@/lib/router/RouteErrorBoundary"; + +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function parseMbidParam(raw: string): string | null { + const trimmed = raw.trim(); + return UUID_REGEX.test(trimmed) ? trimmed : null; +} + +export const Route = createFileRoute("/music/$mbid")({ + beforeLoad: ({ params }) => { + if (parseMbidParam(params.mbid) !== null) return; + throw redirect({ to: "/music" }); + }, + loader: async ({ context, params }) => { + const mbid = parseMbidParam(params.mbid); + if (!mbid) return; + + await context.queryClient.prefetchQuery({ + queryKey: musicKeys.detailView(mbid, "popular"), + queryFn: ({ signal }) => getMusicDetail(mbid, { reviewsSort: "popular" }, { signal }), + }); + }, + component: MusicDetailRoute, + errorComponent: (props) => , +}); + +function MusicDetailRoute() { + const { mbid } = Route.useParams(); + return ; +} diff --git a/apps/web/src/routes/music/index.tsx b/apps/web/src/routes/music/index.tsx new file mode 100644 index 0000000..765afef --- /dev/null +++ b/apps/web/src/routes/music/index.tsx @@ -0,0 +1,12 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { MusicArchivePage } from "@/features/music/components/MusicArchivePage"; +import { RouteErrorBoundary } from "@/lib/router/RouteErrorBoundary"; + +export const Route = createFileRoute("/music/")({ + component: MusicPage, + errorComponent: (props) => , +}); + +function MusicPage() { + return ; +} From 1e940291868e86d76086d2c3d4f6c4ab26e503fd Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:21:30 +0300 Subject: [PATCH 3/7] feat(reviews): support album and book media types Extends CreateReviewSchema to accept mediaSourceId (string) instead of tmdbId and adds "album" and "book" to the mediaType enum. ReviewsCoreService now resolves albums by mbid and books by volumeId when creating reviews for those types. --- .../src/modules/reviews/dto/reviews.dto.ts | 4 +- .../helpers/reviews-activity.helper.ts | 15 ++- .../reviews/services/reviews-core.service.ts | 117 +++++++++++++++++- .../modules/reviews/types/reviews.types.ts | 11 +- 4 files changed, 138 insertions(+), 9 deletions(-) diff --git a/apps/api/src/modules/reviews/dto/reviews.dto.ts b/apps/api/src/modules/reviews/dto/reviews.dto.ts index 281ce82..c8882bf 100644 --- a/apps/api/src/modules/reviews/dto/reviews.dto.ts +++ b/apps/api/src/modules/reviews/dto/reviews.dto.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export const CreateReviewSchema = z.object({ - tmdbId: z.number().int().positive(), - mediaType: z.enum(["movie", "tv"]).default("movie"), + mediaSourceId: z.string().min(1).max(200), + mediaType: z.enum(["movie", "tv", "album", "book"]).default("movie"), content: z.string().min(1).max(10000), containsSpoilers: z.boolean().optional(), diaryEntryId: z.uuid().optional(), diff --git a/apps/api/src/modules/reviews/helpers/reviews-activity.helper.ts b/apps/api/src/modules/reviews/helpers/reviews-activity.helper.ts index d7f4dae..a7b61b1 100644 --- a/apps/api/src/modules/reviews/helpers/reviews-activity.helper.ts +++ b/apps/api/src/modules/reviews/helpers/reviews-activity.helper.ts @@ -13,9 +13,14 @@ export const buildReviewCreatedActivityMetadata = (input: { }) => ({ reviewId: input.reviewId, mediaType: input.media.mediaType, - tmdbId: input.media.tmdbId, + tmdbId: input.media.tmdbId ?? null, + mbid: input.media.mbid ?? null, + volumeId: input.media.volumeId ?? null, title: input.media.title, - posterPath: input.media.posterPath, + posterPath: input.media.posterPath ?? null, + coverArtUrl: input.media.coverArtUrl ?? null, + artistName: input.media.artistName ?? null, + authors: input.media.authors ?? null, releaseYear: input.media.releaseYear, containsSpoilers: input.containsSpoilers, excerpt: toExcerpt(input.content), @@ -31,8 +36,11 @@ export const buildReviewLikedActivityMetadata = (input: { reviewId: input.reviewId, targetUsername: input.targetUsername, tmdbId: input.mediaMetadata?.tmdbId ?? null, + mbid: input.mediaMetadata?.mbid ?? null, + volumeId: input.mediaMetadata?.volumeId ?? null, title: input.mediaMetadata?.title ?? null, posterPath: input.mediaMetadata?.posterPath ?? null, + coverArtUrl: input.mediaMetadata?.coverArtUrl ?? null, releaseYear: input.mediaMetadata?.releaseYear ?? null, }); @@ -50,7 +58,10 @@ export const buildCommentCreatedActivityMetadata = (input: { targetUsername: input.targetUsername, excerpt: toExcerpt(input.content), tmdbId: input.mediaMetadata?.tmdbId ?? null, + mbid: input.mediaMetadata?.mbid ?? null, + volumeId: input.mediaMetadata?.volumeId ?? null, title: input.mediaMetadata?.title ?? null, posterPath: input.mediaMetadata?.posterPath ?? null, + coverArtUrl: input.mediaMetadata?.coverArtUrl ?? null, releaseYear: input.mediaMetadata?.releaseYear ?? null, }); diff --git a/apps/api/src/modules/reviews/services/reviews-core.service.ts b/apps/api/src/modules/reviews/services/reviews-core.service.ts index aa7042f..ef174e7 100644 --- a/apps/api/src/modules/reviews/services/reviews-core.service.ts +++ b/apps/api/src/modules/reviews/services/reviews-core.service.ts @@ -3,6 +3,8 @@ import { db } from "../../../infrastructure/database/db"; import { MoviesService } from "../../movies/movies.service"; import { SerialsService } from "../../serials/serials.service"; import { SerialsReviewsRepository } from "../../serials/repositories/serials-reviews.repository"; +import { MusicCacheService } from "../../music/services/music-cache.service"; +import { BooksCacheService } from "../../books/services/books-cache.service"; import { activities } from "../../social/social.entity"; import { reviewLikes, reviews } from "../reviews.entity"; import { buildReviewCreatedActivityMetadata } from "../helpers/reviews-activity.helper"; @@ -11,7 +13,9 @@ import type { CreateReviewDto, UpdateReviewDto } from "../dto/reviews.dto"; export class ReviewsCoreService { static async create(userId: string, input: CreateReviewDto) { if (input.mediaType === "tv") { - const series = await SerialsService.findOrCreate(input.tmdbId); + const tmdbId = Number.parseInt(input.mediaSourceId, 10); + if (!Number.isFinite(tmdbId)) throw new Error("Invalid tmdbId for tv"); + const series = await SerialsService.findOrCreate(tmdbId); if (!series) throw new Error("Series not found"); const review = await SerialsReviewsRepository.upsertReview({ @@ -47,7 +51,116 @@ export class ReviewsCoreService { return { review, series }; } - const movie = await MoviesService.findOrCreate(input.tmdbId); + if (input.mediaType === "album") { + const album = await MusicCacheService.findOrCreate(input.mediaSourceId); + if (!album) throw new Error("Album not found"); + + const [review] = await db + .insert(reviews) + .values({ + userId, + mediaType: "album", + mediaSource: "musicbrainz", + mediaSourceId: album.mbid, + movieId: null, + diaryEntryId: input.diaryEntryId ?? null, + content: input.content, + containsSpoilers: input.containsSpoilers ?? false, + }) + .onConflictDoUpdate({ + target: [reviews.userId, reviews.mediaType, reviews.mediaSource, reviews.mediaSourceId], + set: { + diaryEntryId: input.diaryEntryId ?? null, + content: input.content, + containsSpoilers: input.containsSpoilers ?? false, + updatedAt: new Date(), + }, + }) + .returning(); + + if (!review) throw new Error("Could not create review"); + + await db.insert(activities).values({ + userId, + type: "review", + entityId: review.id, + metadata: JSON.stringify( + buildReviewCreatedActivityMetadata({ + reviewId: review.id, + content: input.content, + containsSpoilers: review.containsSpoilers, + media: { + mediaType: "album", + mbid: album.mbid, + title: album.title, + coverArtUrl: album.coverArtUrl ?? null, + artistName: album.artistName, + releaseYear: album.firstReleaseYear ?? null, + }, + }), + ), + }); + + return { review, album }; + } + + if (input.mediaType === "book") { + const book = await BooksCacheService.findOrCreate(input.mediaSourceId); + if (!book) throw new Error("Book not found"); + + const [review] = await db + .insert(reviews) + .values({ + userId, + mediaType: "book", + mediaSource: "googlebooks", + mediaSourceId: book.googleVolumeId, + movieId: null, + diaryEntryId: input.diaryEntryId ?? null, + content: input.content, + containsSpoilers: input.containsSpoilers ?? false, + }) + .onConflictDoUpdate({ + target: [reviews.userId, reviews.mediaType, reviews.mediaSource, reviews.mediaSourceId], + set: { + diaryEntryId: input.diaryEntryId ?? null, + content: input.content, + containsSpoilers: input.containsSpoilers ?? false, + updatedAt: new Date(), + }, + }) + .returning(); + + if (!review) throw new Error("Could not create review"); + + await db.insert(activities).values({ + userId, + type: "review", + entityId: review.id, + metadata: JSON.stringify( + buildReviewCreatedActivityMetadata({ + reviewId: review.id, + content: input.content, + containsSpoilers: review.containsSpoilers, + media: { + mediaType: "book", + volumeId: book.googleVolumeId, + title: book.title, + coverArtUrl: book.coverImageUrl ?? null, + authors: (book.authors as string[]) ?? [], + releaseYear: book.publishedYear ?? null, + }, + }), + ), + }); + + return { review, book }; + } + + // movie (default) + const tmdbId = Number.parseInt(input.mediaSourceId, 10); + if (!Number.isFinite(tmdbId)) throw new Error("Invalid tmdbId for movie"); + const movie = await MoviesService.findOrCreate(tmdbId); const [review] = await db .insert(reviews) diff --git a/apps/api/src/modules/reviews/types/reviews.types.ts b/apps/api/src/modules/reviews/types/reviews.types.ts index 7b5a0c3..58dd7e9 100644 --- a/apps/api/src/modules/reviews/types/reviews.types.ts +++ b/apps/api/src/modules/reviews/types/reviews.types.ts @@ -1,9 +1,14 @@ -export type ReviewMediaType = "movie" | "tv"; +export type ReviewMediaType = "movie" | "tv" | "album" | "book"; export type ReviewMediaMetadata = { mediaType: ReviewMediaType; - tmdbId: number | null; + tmdbId?: number | null; + mbid?: string | null; + volumeId?: string | null; title: string | null; - posterPath: string | null; + posterPath?: string | null; + coverArtUrl?: string | null; + artistName?: string | null; + authors?: string[] | null; releaseYear: number | null; }; From d2de1fc6e91eea77d245d920f7e9d3d03bf7e951 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:21:45 +0300 Subject: [PATCH 4/7] feat(feed): resolve album and book entries in activity feed Backend resolveMovie now checks mediaType first: album rows query the albums table by mbid, book rows query the books table by volumeId. Frontend FeedChannel and feedChannelMeta extended with music/books; feedMovieSchema relaxes tmdbId to nullable for non-TMDB entries. --- .../helpers/social-feed-metadata.helper.ts | 2 +- .../helpers/social-feed-resolvers.helper.ts | 96 +++++++++++++++++-- .../modules/social/types/social-feed.types.ts | 13 ++- .../feed/components/MyProfileSummaryRail.tsx | 20 ++-- .../feed/components/feed-row.utils.ts | 42 ++++---- apps/web/src/features/feed/types.ts | 15 ++- 6 files changed, 141 insertions(+), 47 deletions(-) diff --git a/apps/api/src/modules/social/helpers/social-feed-metadata.helper.ts b/apps/api/src/modules/social/helpers/social-feed-metadata.helper.ts index b781c99..679dbf5 100644 --- a/apps/api/src/modules/social/helpers/social-feed-metadata.helper.ts +++ b/apps/api/src/modules/social/helpers/social-feed-metadata.helper.ts @@ -57,7 +57,7 @@ export const readPostMediaType = ( ): FeedPostMediaType | null => { const value = readString(metadata, key); - if (value === "movie" || value === "tv") { + if (value === "movie" || value === "tv" || value === "album" || value === "book") { return value; } diff --git a/apps/api/src/modules/social/helpers/social-feed-resolvers.helper.ts b/apps/api/src/modules/social/helpers/social-feed-resolvers.helper.ts index 5b44471..e098458 100644 --- a/apps/api/src/modules/social/helpers/social-feed-resolvers.helper.ts +++ b/apps/api/src/modules/social/helpers/social-feed-resolvers.helper.ts @@ -1,3 +1,7 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { albums } from "../../music/music.entity"; +import { books } from "../../books/books.entity"; import { readBoolean, readNumber, readPostMediaType, readString } from "./social-feed-metadata.helper"; import { SocialFeedRepository } from "../repositories/social-feed.repository"; import type { @@ -10,14 +14,10 @@ import type { } from "../types/social-feed.types"; const toFeedMediaType = (value: string | null): FeedMediaType | null => { - if (value === "tv") { - return "tv"; - } - - if (value === "movie") { - return "movie"; - } - + if (value === "tv") return "tv"; + if (value === "movie") return "movie"; + if (value === "album") return "album"; + if (value === "book") return "book"; return null; }; @@ -36,6 +36,8 @@ export const toFeedMetadata = (rawMetadata: FeedRawMetadata): FeedMetadata => { reviewId: readString(rawMetadata, "reviewId"), commentId: readString(rawMetadata, "commentId"), movieId: readNumber(rawMetadata, "movieId"), + mbid: readString(rawMetadata, "mbid"), + volumeId: readString(rawMetadata, "volumeId"), postId: readString(rawMetadata, "postId"), postMediaId: readNumber(rawMetadata, "mediaId"), postMediaType: readPostMediaType(rawMetadata, "mediaType"), @@ -96,10 +98,84 @@ export const resolveMovie = async ( activity: SocialActivity, metadata: FeedMetadata, ): Promise => { - const tmdbId = readNumber(rawMetadata, "tmdbId"); - const title = readString(rawMetadata, "title"); const mediaType = toFeedMediaType(readPostMediaType(rawMetadata, "mediaType")); + const title = readString(rawMetadata, "title"); + // Album + if (mediaType === "album") { + const mbid = readString(rawMetadata, "mbid") ?? metadata.mbid; + if (mbid && title) { + return { + tmdbId: null, + title, + posterPath: null, + coverArtUrl: readString(rawMetadata, "coverArtUrl"), + releaseYear: readNumber(rawMetadata, "releaseYear"), + mediaType: "album", + mbid, + artistName: readString(rawMetadata, "artistName"), + }; + } + if (mbid) { + const [album] = await db + .select({ title: albums.title, coverArtUrl: albums.coverArtUrl, firstReleaseYear: albums.firstReleaseYear, artistName: albums.artistName }) + .from(albums) + .where(eq(albums.mbid, mbid)) + .limit(1); + if (album) { + return { + tmdbId: null, + title: album.title, + posterPath: null, + coverArtUrl: album.coverArtUrl ?? null, + releaseYear: album.firstReleaseYear ?? null, + mediaType: "album", + mbid, + artistName: album.artistName, + }; + } + } + return null; + } + + // Book + if (mediaType === "book") { + const volumeId = readString(rawMetadata, "volumeId") ?? metadata.volumeId; + if (volumeId && title) { + return { + tmdbId: null, + title, + posterPath: null, + coverArtUrl: readString(rawMetadata, "coverArtUrl"), + releaseYear: readNumber(rawMetadata, "releaseYear"), + mediaType: "book", + volumeId, + authors: (rawMetadata.authors as string[] | null) ?? null, + }; + } + if (volumeId) { + const [book] = await db + .select({ title: books.title, coverImageUrl: books.coverImageUrl, publishedYear: books.publishedYear, authors: books.authors }) + .from(books) + .where(eq(books.googleVolumeId, volumeId)) + .limit(1); + if (book) { + return { + tmdbId: null, + title: book.title, + posterPath: null, + coverArtUrl: book.coverImageUrl ?? null, + releaseYear: book.publishedYear ?? null, + mediaType: "book", + volumeId, + authors: (book.authors as string[]) ?? null, + }; + } + } + return null; + } + + const tmdbId = readNumber(rawMetadata, "tmdbId"); if (tmdbId !== null && title) { return { tmdbId, diff --git a/apps/api/src/modules/social/types/social-feed.types.ts b/apps/api/src/modules/social/types/social-feed.types.ts index 78b20dd..0272640 100644 --- a/apps/api/src/modules/social/types/social-feed.types.ts +++ b/apps/api/src/modules/social/types/social-feed.types.ts @@ -21,17 +21,22 @@ export type FeedActivityKind = | "liked_post" | "commented_post"; -export type FeedMediaType = "movie" | "tv"; +export type FeedMediaType = "movie" | "tv" | "album" | "book"; export type FeedMovie = { - tmdbId: number; + tmdbId: number | null; title: string; posterPath: string | null; + coverArtUrl?: string | null; releaseYear: number | null; mediaType: FeedMediaType; + mbid?: string | null; + artistName?: string | null; + volumeId?: string | null; + authors?: string[] | null; }; -export type FeedPostMediaType = "movie" | "tv"; +export type FeedPostMediaType = "movie" | "tv" | "album" | "book"; export type FeedPost = { id: string; @@ -59,6 +64,8 @@ export type FeedMetadata = { reviewId: string | null; commentId: string | null; movieId: number | null; + mbid: string | null; + volumeId: string | null; postId: string | null; postMediaId: number | null; postMediaType: FeedPostMediaType | null; diff --git a/apps/web/src/features/feed/components/MyProfileSummaryRail.tsx b/apps/web/src/features/feed/components/MyProfileSummaryRail.tsx index 9da9e3a..d65c51a 100644 --- a/apps/web/src/features/feed/components/MyProfileSummaryRail.tsx +++ b/apps/web/src/features/feed/components/MyProfileSummaryRail.tsx @@ -28,7 +28,16 @@ const buildTopLoggedThings = (items: FeedItem[]): LoggedThing[] => { continue; } - const key = `${item.movie.mediaType}:${item.movie.tmdbId}`; + const { tmdbId, mediaType } = item.movie; + if (mediaType !== "movie" && mediaType !== "tv") { + continue; + } + + if (typeof tmdbId !== "number") { + continue; + } + + const key = `${mediaType}:${tmdbId}`; const existing = counts.get(key); if (existing) { existing.count += 1; @@ -38,12 +47,9 @@ const buildTopLoggedThings = (items: FeedItem[]): LoggedThing[] => { counts.set(key, { id: key, title: item.movie.title, - to: - item.movie.mediaType === "tv" - ? "/serials/$tmdbId" - : "/cinema/$tmdbId", - tmdbId: item.movie.tmdbId, - module: item.movie.mediaType === "tv" ? "SERIAL" : "CINEMA", + to: mediaType === "tv" ? "/serials/$tmdbId" : "/cinema/$tmdbId", + tmdbId, + module: mediaType === "tv" ? "SERIAL" : "CINEMA", count: 1, }); } diff --git a/apps/web/src/features/feed/components/feed-row.utils.ts b/apps/web/src/features/feed/components/feed-row.utils.ts index 1ae66e4..569eb37 100644 --- a/apps/web/src/features/feed/components/feed-row.utils.ts +++ b/apps/web/src/features/feed/components/feed-row.utils.ts @@ -1,33 +1,21 @@ import type { FeedItem } from "@/features/feed/types"; import { formatRelativeTime } from "@/lib/time"; -export type FeedChannel = "cinema" | "serial"; +export type FeedChannel = "cinema" | "serial" | "music" | "books"; export const inferFeedChannel = (item: FeedItem): FeedChannel | null => { - if (item.movie?.mediaType === "movie") { - return "cinema"; - } - - if (item.movie?.mediaType === "tv") { - return "serial"; - } + const mediaType = item.movie?.mediaType ?? item.metadata.mediaType; - if (item.metadata.mediaType === "movie") { - return "cinema"; - } - - if (item.metadata.mediaType === "tv") { - return "serial"; - } + if (mediaType === "movie") return "cinema"; + if (mediaType === "tv") return "serial"; + if (mediaType === "album") return "music"; + if (mediaType === "book") return "books"; const attachedMediaType = item.post?.mediaType ?? item.metadata.postMediaType; - if (attachedMediaType === "movie") { - return "cinema"; - } - - if (attachedMediaType === "tv") { - return "serial"; - } + if (attachedMediaType === "movie") return "cinema"; + if (attachedMediaType === "tv") return "serial"; + if (attachedMediaType === "album") return "music"; + if (attachedMediaType === "book") return "books"; return null; }; @@ -50,6 +38,16 @@ export const feedChannelMeta: Record< color: "var(--module-serial)", tint: "rgba(0, 207, 255, 0.08)", }, + music: { + label: "MUSIC", + color: "var(--module-music)", + tint: "rgba(168, 85, 247, 0.08)", + }, + books: { + label: "BOOKS", + color: "var(--module-book)", + tint: "rgba(249, 115, 22, 0.08)", + }, }; export const getRelativeTime = (value: string): string => { diff --git a/apps/web/src/features/feed/types.ts b/apps/web/src/features/feed/types.ts index df3057c..051d148 100644 --- a/apps/web/src/features/feed/types.ts +++ b/apps/web/src/features/feed/types.ts @@ -37,15 +37,20 @@ export const feedActorSchema = z.object({ export const feedMovieSchema = z .object({ - tmdbId: z.number().int().positive(), + tmdbId: z.number().int().nullable().optional(), title: z.string(), posterPath: z.string().nullable(), + coverArtUrl: z.string().nullable().optional(), releaseYear: z.number().int().nullable(), - mediaType: z.enum(["movie", "tv"]).default("movie"), + mediaType: z.enum(["movie", "tv", "album", "book"]).default("movie"), + mbid: z.string().nullable().optional(), + artistName: z.string().nullable().optional(), + volumeId: z.string().nullable().optional(), + authors: z.array(z.string()).nullable().optional(), }) .nullable(); -export const feedPostMediaTypeSchema = z.enum(["movie", "tv"]); +export const feedPostMediaTypeSchema = z.enum(["movie", "tv", "album", "book"]); export const feedPostSchema = z .object({ @@ -72,11 +77,13 @@ export const feedMetadataSchema = z.object({ rating: z.number().nullable(), rewatch: z.boolean().nullable(), hasReview: z.boolean().nullable().optional(), - mediaType: z.enum(["movie", "tv"]).nullable().optional(), + mediaType: z.enum(["movie", "tv", "album", "book"]).nullable().optional(), containsSpoilers: z.boolean().nullable(), reviewId: z.string().nullable(), commentId: z.string().nullable(), movieId: z.number().nullable(), + mbid: z.string().nullable().optional(), + volumeId: z.string().nullable().optional(), postId: z.string().nullable(), postMediaId: z.number().nullable(), postMediaType: feedPostMediaTypeSchema.nullable(), From 7daa55efd68bd2b41e02e99a839a644c4e9208d2 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:22:02 +0300 Subject: [PATCH 5/7] feat(top-picks): extend categories to include music and books Adds category IDs 3 (music/musicbrainz/album) and 4 (books/ googlebooks/book) to the top-picks constants and DTO superRefine. PublicTopPicksService resolves albums and books from their tables. Extracts PublicDiaryService to keep public.service under 380 lines and extends getDiary to merge movie/serial/album/book entries. --- apps/api/src/modules/public/public.service.ts | 147 +------------- .../public/services/public-diary.service.ts | 184 ++++++++++++++++++ .../services/public-top-picks.service.ts | 103 +++++----- .../users/constants/top-picks.constants.ts | 10 +- apps/api/src/modules/users/dto/users.dto.ts | 12 +- 5 files changed, 249 insertions(+), 207 deletions(-) create mode 100644 apps/api/src/modules/public/services/public-diary.service.ts diff --git a/apps/api/src/modules/public/public.service.ts b/apps/api/src/modules/public/public.service.ts index dc7ccfe..749e54d 100644 --- a/apps/api/src/modules/public/public.service.ts +++ b/apps/api/src/modules/public/public.service.ts @@ -1,14 +1,12 @@ import { and, asc, desc, eq, inArray } from "drizzle-orm"; import { db } from "../../infrastructure/database/db"; import { user } from "../../infrastructure/database/auth.entity"; -import { DiaryRepository } from "../diary/repositories/diary.repository"; import { listEntries, lists } from "../lists/lists.entity"; import { UsersService } from "../users/users.service"; import { movies } from "../movies/movies.entity"; -import { reviews } from "../reviews/reviews.entity"; -import { serialDiaryEntries, tvSeries } from "../serials/serials.entity"; import { SocialFeedService } from "../social/services/social-feed.service"; import { PublicTopPicksService } from "./services/public-top-picks.service"; +import { PublicDiaryService } from "./services/public-diary.service"; // Thin, read-only service for the public portfolio API // All responses are cached-friendly — no auth required @@ -35,29 +33,6 @@ type PublicProfileResponse = { }; }; -type PublicDiaryItem = { - id: string; - mediaType: "movie" | "tv"; - watchedDate: string; - ratingOutOfTen: number | null; - ratingOutOfFive: number | null; - rewatch: boolean; - createdAt: Date; - updatedAt: Date; - media: { - tmdbId: number; - title: string; - posterPath: string | null; - releaseYear: number | null; - }; - review: { - id: string; - content: string; - containsSpoilers: boolean; - createdAt: Date; - } | null; -}; - type PublicListEntry = { position: number; note: string | null; @@ -78,22 +53,6 @@ type PublicList = { items: PublicListEntry[]; }; -const toRatingOutOfFive = (ratingOutOfTen: number | null): number | null => { - if (ratingOutOfTen === null || !Number.isFinite(ratingOutOfTen)) { - return null; - } - - return Number((ratingOutOfTen / 2).toFixed(1)); -}; - -const toTimestamp = (value: string | Date): number => { - if (value instanceof Date) { - return value.getTime(); - } - - return Date.parse(value); -}; - export class PublicService { private static async findUserIdByUsername(username: string): Promise { const [profile] = await db @@ -150,8 +109,8 @@ export class PublicService { return null; } - const reviews = await UsersService.getReviewsWithMovies(userId); - return reviews.slice(0, limit); + const reviewList = await UsersService.getReviewsWithMovies(userId); + return reviewList.slice(0, limit); } static async getLikes(username: string, limit = 50) { @@ -174,109 +133,13 @@ export class PublicService { return watchlist.slice(0, limit); } - static async getDiary(username: string, limit = 50): Promise { + static async getDiary(username: string, limit = 50) { const userId = await PublicService.findUserIdByUsername(username); if (!userId) { return null; } - const [movieEntries, serialEntries] = await Promise.all([ - DiaryRepository.findAllByUser(userId), - db - .select({ - id: serialDiaryEntries.id, - watchedDate: serialDiaryEntries.watchedDate, - rating: serialDiaryEntries.rating, - rewatch: serialDiaryEntries.rewatch, - createdAt: serialDiaryEntries.createdAt, - updatedAt: serialDiaryEntries.updatedAt, - tmdbId: tvSeries.tmdbId, - title: tvSeries.title, - posterPath: tvSeries.posterPath, - releaseYear: tvSeries.firstAirYear, - reviewId: reviews.id, - reviewContent: reviews.content, - reviewContainsSpoilers: reviews.containsSpoilers, - reviewCreatedAt: reviews.createdAt, - }) - .from(serialDiaryEntries) - .innerJoin(tvSeries, eq(tvSeries.id, serialDiaryEntries.seriesId)) - .leftJoin( - reviews, - and( - eq(reviews.userId, serialDiaryEntries.userId), - eq(reviews.diaryEntryId, serialDiaryEntries.id), - eq(reviews.mediaType, "tv"), - ), - ) - .where(eq(serialDiaryEntries.userId, userId)) - .orderBy(desc(serialDiaryEntries.watchedDate), desc(serialDiaryEntries.createdAt)), - ]); - - const normalizedMovieEntries: PublicDiaryItem[] = movieEntries.map((entry) => ({ - id: entry.id, - mediaType: "movie", - watchedDate: entry.watchedDate, - ratingOutOfTen: entry.rating, - ratingOutOfFive: toRatingOutOfFive(entry.rating), - rewatch: entry.rewatch, - createdAt: entry.createdAt, - updatedAt: entry.updatedAt, - media: { - tmdbId: entry.movieTmdbId, - title: entry.movieTitle, - posterPath: entry.moviePosterPath, - releaseYear: entry.movieReleaseYear, - }, - review: entry.reviewId - ? { - id: entry.reviewId, - content: entry.reviewContent ?? "", - containsSpoilers: entry.reviewContainsSpoilers ?? false, - createdAt: entry.reviewCreatedAt ?? entry.createdAt, - } - : null, - })); - - const normalizedSerialEntries: PublicDiaryItem[] = serialEntries.map((entry) => ({ - id: entry.id, - mediaType: "tv", - watchedDate: entry.watchedDate, - ratingOutOfTen: entry.rating, - ratingOutOfFive: toRatingOutOfFive(entry.rating), - rewatch: entry.rewatch, - createdAt: entry.createdAt, - updatedAt: entry.updatedAt, - media: { - tmdbId: entry.tmdbId, - title: entry.title, - posterPath: entry.posterPath, - releaseYear: entry.releaseYear, - }, - review: entry.reviewId - ? { - id: entry.reviewId, - content: entry.reviewContent ?? "", - containsSpoilers: entry.reviewContainsSpoilers ?? false, - createdAt: entry.reviewCreatedAt ?? entry.createdAt, - } - : null, - })); - - const combined = [...normalizedMovieEntries, ...normalizedSerialEntries] - .sort((left, right) => { - const watchedDateDelta = - toTimestamp(right.watchedDate) - toTimestamp(left.watchedDate); - - if (watchedDateDelta !== 0) { - return watchedDateDelta; - } - - return toTimestamp(right.createdAt) - toTimestamp(left.createdAt); - }) - .slice(0, limit); - - return combined; + return PublicDiaryService.getDiary(userId, limit); } static async getLists(username: string, limit = 20): Promise { diff --git a/apps/api/src/modules/public/services/public-diary.service.ts b/apps/api/src/modules/public/services/public-diary.service.ts new file mode 100644 index 0000000..514e101 --- /dev/null +++ b/apps/api/src/modules/public/services/public-diary.service.ts @@ -0,0 +1,184 @@ +import { and, desc, eq } from "drizzle-orm"; +import { db } from "../../../infrastructure/database/db"; +import { DiaryRepository } from "../../diary/repositories/diary.repository"; +import { reviews } from "../../reviews/reviews.entity"; +import { serialDiaryEntries, tvSeries } from "../../serials/serials.entity"; +import { musicDiaryEntries, albums } from "../../music/music.entity"; +import { bookDiaryEntries, books } from "../../books/books.entity"; + +export type PublicDiaryItem = { + id: string; + mediaType: "movie" | "tv" | "album" | "book"; + watchedDate: string; + ratingOutOfTen: number | null; + ratingOutOfFive: number | null; + rewatch: boolean; + createdAt: Date; + updatedAt: Date; + media: { + tmdbId?: number; + mbid?: string; + volumeId?: string; + title: string; + posterPath?: string | null; + coverArtUrl?: string | null; + releaseYear: number | null; + artistName?: string | null; + authors?: string[] | null; + }; + review: { + id: string; + content: string; + containsSpoilers: boolean; + createdAt: Date; + } | null; +}; + +const toRatingOutOfFive = (ratingOutOfTen: number | null): number | null => { + if (ratingOutOfTen === null || !Number.isFinite(ratingOutOfTen)) { + return null; + } + + return Number((ratingOutOfTen / 2).toFixed(1)); +}; + +const toTimestamp = (value: string | Date): number => { + if (value instanceof Date) return value.getTime(); + return Date.parse(value); +}; + +export class PublicDiaryService { + static async getDiary(userId: string, limit = 50): Promise { + const [movieEntries, serialEntries, musicEntries, bookEntries] = await Promise.all([ + DiaryRepository.findAllByUser(userId), + db + .select({ + id: serialDiaryEntries.id, + watchedDate: serialDiaryEntries.watchedDate, + rating: serialDiaryEntries.rating, + rewatch: serialDiaryEntries.rewatch, + createdAt: serialDiaryEntries.createdAt, + updatedAt: serialDiaryEntries.updatedAt, + tmdbId: tvSeries.tmdbId, + title: tvSeries.title, + posterPath: tvSeries.posterPath, + releaseYear: tvSeries.firstAirYear, + reviewId: reviews.id, + reviewContent: reviews.content, + reviewContainsSpoilers: reviews.containsSpoilers, + reviewCreatedAt: reviews.createdAt, + }) + .from(serialDiaryEntries) + .innerJoin(tvSeries, eq(tvSeries.id, serialDiaryEntries.seriesId)) + .leftJoin(reviews, and(eq(reviews.userId, serialDiaryEntries.userId), eq(reviews.diaryEntryId, serialDiaryEntries.id), eq(reviews.mediaType, "tv"))) + .where(eq(serialDiaryEntries.userId, userId)) + .orderBy(desc(serialDiaryEntries.watchedDate), desc(serialDiaryEntries.createdAt)), + db + .select({ + id: musicDiaryEntries.id, + listenedDate: musicDiaryEntries.listenedDate, + rating: musicDiaryEntries.rating, + relisten: musicDiaryEntries.relisten, + createdAt: musicDiaryEntries.createdAt, + updatedAt: musicDiaryEntries.updatedAt, + mbid: albums.mbid, + title: albums.title, + coverArtUrl: albums.coverArtUrl, + releaseYear: albums.firstReleaseYear, + artistName: albums.artistName, + reviewId: reviews.id, + reviewContent: reviews.content, + reviewContainsSpoilers: reviews.containsSpoilers, + reviewCreatedAt: reviews.createdAt, + }) + .from(musicDiaryEntries) + .innerJoin(albums, eq(albums.id, musicDiaryEntries.albumId)) + .leftJoin(reviews, and(eq(reviews.userId, musicDiaryEntries.userId), eq(reviews.diaryEntryId, musicDiaryEntries.id), eq(reviews.mediaType, "album"))) + .where(eq(musicDiaryEntries.userId, userId)) + .orderBy(desc(musicDiaryEntries.listenedDate), desc(musicDiaryEntries.createdAt)), + db + .select({ + id: bookDiaryEntries.id, + readDate: bookDiaryEntries.readDate, + rating: bookDiaryEntries.rating, + reread: bookDiaryEntries.reread, + createdAt: bookDiaryEntries.createdAt, + updatedAt: bookDiaryEntries.updatedAt, + volumeId: books.googleVolumeId, + title: books.title, + coverImageUrl: books.coverImageUrl, + releaseYear: books.publishedYear, + authors: books.authors, + reviewId: reviews.id, + reviewContent: reviews.content, + reviewContainsSpoilers: reviews.containsSpoilers, + reviewCreatedAt: reviews.createdAt, + }) + .from(bookDiaryEntries) + .innerJoin(books, eq(books.id, bookDiaryEntries.bookId)) + .leftJoin(reviews, and(eq(reviews.userId, bookDiaryEntries.userId), eq(reviews.diaryEntryId, bookDiaryEntries.id), eq(reviews.mediaType, "book"))) + .where(eq(bookDiaryEntries.userId, userId)) + .orderBy(desc(bookDiaryEntries.readDate), desc(bookDiaryEntries.createdAt)), + ]); + + const normalizedMovies: PublicDiaryItem[] = movieEntries.map((e) => ({ + id: e.id, + mediaType: "movie" as const, + watchedDate: e.watchedDate, + ratingOutOfTen: e.rating, + ratingOutOfFive: toRatingOutOfFive(e.rating), + rewatch: e.rewatch, + createdAt: e.createdAt, + updatedAt: e.updatedAt, + media: { tmdbId: e.movieTmdbId, title: e.movieTitle, posterPath: e.moviePosterPath, releaseYear: e.movieReleaseYear }, + review: e.reviewId ? { id: e.reviewId, content: e.reviewContent ?? "", containsSpoilers: e.reviewContainsSpoilers ?? false, createdAt: e.reviewCreatedAt ?? e.createdAt } : null, + })); + + const normalizedSerials: PublicDiaryItem[] = serialEntries.map((e) => ({ + id: e.id, + mediaType: "tv" as const, + watchedDate: e.watchedDate, + ratingOutOfTen: e.rating, + ratingOutOfFive: toRatingOutOfFive(e.rating), + rewatch: e.rewatch, + createdAt: e.createdAt, + updatedAt: e.updatedAt, + media: { tmdbId: e.tmdbId, title: e.title, posterPath: e.posterPath, releaseYear: e.releaseYear }, + review: e.reviewId ? { id: e.reviewId, content: e.reviewContent ?? "", containsSpoilers: e.reviewContainsSpoilers ?? false, createdAt: e.reviewCreatedAt ?? e.createdAt } : null, + })); + + const normalizedMusic: PublicDiaryItem[] = musicEntries.map((e) => ({ + id: e.id, + mediaType: "album" as const, + watchedDate: e.listenedDate, + ratingOutOfTen: e.rating, + ratingOutOfFive: toRatingOutOfFive(e.rating), + rewatch: e.relisten, + createdAt: e.createdAt, + updatedAt: e.updatedAt, + media: { mbid: e.mbid, title: e.title, coverArtUrl: e.coverArtUrl ?? null, releaseYear: e.releaseYear ?? null, artistName: e.artistName }, + review: e.reviewId ? { id: e.reviewId, content: e.reviewContent ?? "", containsSpoilers: e.reviewContainsSpoilers ?? false, createdAt: e.reviewCreatedAt ?? e.createdAt } : null, + })); + + const normalizedBooks: PublicDiaryItem[] = bookEntries.map((e) => ({ + id: e.id, + mediaType: "book" as const, + watchedDate: e.readDate, + ratingOutOfTen: e.rating, + ratingOutOfFive: toRatingOutOfFive(e.rating), + rewatch: e.reread, + createdAt: e.createdAt, + updatedAt: e.updatedAt, + media: { volumeId: e.volumeId, title: e.title, coverArtUrl: e.coverImageUrl ?? null, releaseYear: e.releaseYear ?? null, authors: (e.authors as string[] | null) ?? null }, + review: e.reviewId ? { id: e.reviewId, content: e.reviewContent ?? "", containsSpoilers: e.reviewContainsSpoilers ?? false, createdAt: e.reviewCreatedAt ?? e.createdAt } : null, + })); + + return [...normalizedMovies, ...normalizedSerials, ...normalizedMusic, ...normalizedBooks] + .sort((a, b) => { + const delta = toTimestamp(b.watchedDate) - toTimestamp(a.watchedDate); + if (delta !== 0) return delta; + return toTimestamp(b.createdAt) - toTimestamp(a.createdAt); + }) + .slice(0, limit); + } +} diff --git a/apps/api/src/modules/public/services/public-top-picks.service.ts b/apps/api/src/modules/public/services/public-top-picks.service.ts index a7093fc..68f85d0 100644 --- a/apps/api/src/modules/public/services/public-top-picks.service.ts +++ b/apps/api/src/modules/public/services/public-top-picks.service.ts @@ -2,6 +2,8 @@ import { asc, eq, inArray } from "drizzle-orm"; import { db } from "../../../infrastructure/database/db"; import { movies } from "../../movies/movies.entity"; import { tvSeries } from "../../serials/serials.entity"; +import { albums } from "../../music/music.entity"; +import { books } from "../../books/books.entity"; import { TOP_PICK_CATEGORY_IDS, TOP_PICK_CATEGORY_KEYS, @@ -20,7 +22,10 @@ type PublicTopPickItem = { tmdbId: number | null; title: string | null; posterPath: string | null; + coverArtUrl: string | null; releaseYear: number | null; + artistName: string | null; + authors: string[] | null; }; type PublicTopPickCategory = { @@ -52,54 +57,40 @@ export class PublicTopPicksService { .orderBy(asc(profileTopPicks.categoryId), asc(profileTopPicks.slot)); const cinemaTmdbIds = topPickRows - .filter( - (row) => - row.categoryId === 1 && - row.mediaType === "movie" && - row.mediaSource === "tmdb" && - Number.isInteger(Number(row.mediaSourceId)), - ) + .filter((row) => row.categoryId === 1 && row.mediaSource === "tmdb" && Number.isInteger(Number(row.mediaSourceId))) .map((row) => Number(row.mediaSourceId)); const serialTmdbIds = topPickRows - .filter( - (row) => - row.categoryId === 2 && - row.mediaType === "tv" && - row.mediaSource === "tmdb" && - Number.isInteger(Number(row.mediaSourceId)), - ) + .filter((row) => row.categoryId === 2 && row.mediaSource === "tmdb" && Number.isInteger(Number(row.mediaSourceId))) .map((row) => Number(row.mediaSourceId)); - const [movieRows, serialRows] = await Promise.all([ + const musicMbids = topPickRows + .filter((row) => row.categoryId === 3 && row.mediaSource === "musicbrainz") + .map((row) => row.mediaSourceId); + + const bookVolumeIds = topPickRows + .filter((row) => row.categoryId === 4 && row.mediaSource === "googlebooks") + .map((row) => row.mediaSourceId); + + const [movieRows, serialRows, albumRows, bookRows] = await Promise.all([ cinemaTmdbIds.length > 0 - ? db - .select({ - id: movies.id, - tmdbId: movies.tmdbId, - title: movies.title, - posterPath: movies.posterPath, - releaseYear: movies.releaseYear, - }) - .from(movies) - .where(inArray(movies.tmdbId, [...new Set(cinemaTmdbIds)])) + ? db.select({ id: movies.id, tmdbId: movies.tmdbId, title: movies.title, posterPath: movies.posterPath, releaseYear: movies.releaseYear }).from(movies).where(inArray(movies.tmdbId, [...new Set(cinemaTmdbIds)])) : Promise.resolve([]), serialTmdbIds.length > 0 - ? db - .select({ - id: tvSeries.id, - tmdbId: tvSeries.tmdbId, - title: tvSeries.title, - posterPath: tvSeries.posterPath, - releaseYear: tvSeries.firstAirYear, - }) - .from(tvSeries) - .where(inArray(tvSeries.tmdbId, [...new Set(serialTmdbIds)])) + ? db.select({ id: tvSeries.id, tmdbId: tvSeries.tmdbId, title: tvSeries.title, posterPath: tvSeries.posterPath, releaseYear: tvSeries.firstAirYear }).from(tvSeries).where(inArray(tvSeries.tmdbId, [...new Set(serialTmdbIds)])) + : Promise.resolve([]), + musicMbids.length > 0 + ? db.select({ id: albums.id, mbid: albums.mbid, title: albums.title, coverArtUrl: albums.coverArtUrl, firstReleaseYear: albums.firstReleaseYear, artistName: albums.artistName }).from(albums).where(inArray(albums.mbid, [...new Set(musicMbids)])) + : Promise.resolve([]), + bookVolumeIds.length > 0 + ? db.select({ id: books.id, googleVolumeId: books.googleVolumeId, title: books.title, coverImageUrl: books.coverImageUrl, publishedYear: books.publishedYear, authors: books.authors }).from(books).where(inArray(books.googleVolumeId, [...new Set(bookVolumeIds)])) : Promise.resolve([]), ]); const movieByTmdbId = new Map(movieRows.map((row) => [row.tmdbId, row])); const serialByTmdbId = new Map(serialRows.map((row) => [row.tmdbId, row])); + const albumByMbid = new Map(albumRows.map((row) => [row.mbid, row])); + const bookByVolumeId = new Map(bookRows.map((row) => [row.googleVolumeId, row])); const categoriesById = new Map( TOP_PICK_CATEGORY_IDS.map((id) => [ @@ -114,39 +105,43 @@ export class PublicTopPicksService { ); for (const row of topPickRows) { - if (!(row.categoryId in TOP_PICK_CATEGORY_KEYS)) { - continue; - } + if (!(row.categoryId in TOP_PICK_CATEGORY_KEYS)) continue; const category = categoriesById.get(row.categoryId as TopPickCategoryId); - if (!category) { - continue; - } + if (!category) continue; const parsedSourceId = Number(row.mediaSourceId); const isTmdbSourceId = Number.isInteger(parsedSourceId); - const movie = - row.categoryId === 1 && row.mediaSource === "tmdb" && isTmdbSourceId - ? movieByTmdbId.get(parsedSourceId) - : null; + const movie = row.categoryId === 1 && row.mediaSource === "tmdb" && isTmdbSourceId + ? movieByTmdbId.get(parsedSourceId) ?? null + : null; + + const series = row.categoryId === 2 && row.mediaSource === "tmdb" && isTmdbSourceId + ? serialByTmdbId.get(parsedSourceId) ?? null + : null; + + const album = row.categoryId === 3 && row.mediaSource === "musicbrainz" + ? albumByMbid.get(row.mediaSourceId) ?? null + : null; - const series = - row.categoryId === 2 && row.mediaSource === "tmdb" && isTmdbSourceId - ? serialByTmdbId.get(parsedSourceId) - : null; + const book = row.categoryId === 4 && row.mediaSource === "googlebooks" + ? bookByVolumeId.get(row.mediaSourceId) ?? null + : null; category.items.push({ slot: row.slot, mediaType: row.mediaType, mediaSource: row.mediaSource, mediaSourceId: row.mediaSourceId, - entityId: movie?.id ?? series?.id ?? null, - tmdbId: - row.mediaSource === "tmdb" && isTmdbSourceId ? parsedSourceId : null, - title: movie?.title ?? series?.title ?? row.title ?? null, + entityId: movie?.id ?? series?.id ?? album?.id ?? book?.id ?? null, + tmdbId: row.mediaSource === "tmdb" && isTmdbSourceId ? parsedSourceId : null, + title: movie?.title ?? series?.title ?? album?.title ?? book?.title ?? row.title ?? null, posterPath: movie?.posterPath ?? series?.posterPath ?? row.posterPath ?? null, - releaseYear: movie?.releaseYear ?? series?.releaseYear ?? row.releaseYear ?? null, + coverArtUrl: album?.coverArtUrl ?? book?.coverImageUrl ?? null, + releaseYear: movie?.releaseYear ?? series?.releaseYear ?? album?.firstReleaseYear ?? book?.publishedYear ?? row.releaseYear ?? null, + artistName: album?.artistName ?? null, + authors: (book?.authors as string[] | null) ?? null, }); } diff --git a/apps/api/src/modules/users/constants/top-picks.constants.ts b/apps/api/src/modules/users/constants/top-picks.constants.ts index 3f6359f..d1adc7f 100644 --- a/apps/api/src/modules/users/constants/top-picks.constants.ts +++ b/apps/api/src/modules/users/constants/top-picks.constants.ts @@ -1,16 +1,18 @@ -export const TOP_PICK_CATEGORY_IDS = [1, 2] as const; +export const TOP_PICK_CATEGORY_IDS = [1, 2, 3, 4] as const; export type TopPickCategoryId = (typeof TOP_PICK_CATEGORY_IDS)[number]; export const TOP_PICK_CATEGORY_KEYS = { 1: "cinema", 2: "serial", + 3: "music", + 4: "books", } as const; export type TopPickCategoryKey = (typeof TOP_PICK_CATEGORY_KEYS)[TopPickCategoryId]; -export const TOP_PICK_MEDIA_TYPES = ["movie", "tv"] as const; +export const TOP_PICK_MEDIA_TYPES = ["movie", "tv", "album", "book"] as const; export type TopPickMediaType = (typeof TOP_PICK_MEDIA_TYPES)[number]; @@ -20,9 +22,11 @@ export const TOP_PICK_DEFAULT_MEDIA_TYPE: Record< > = { 1: "movie", 2: "tv", + 3: "album", + 4: "book", }; -export const TOP_PICK_SUPPORTED_CATEGORY_IDS: TopPickCategoryId[] = [1, 2]; +export const TOP_PICK_SUPPORTED_CATEGORY_IDS: TopPickCategoryId[] = [1, 2, 3, 4]; export const MAX_TOP_PICK_ITEMS_PER_CATEGORY = 4; diff --git a/apps/api/src/modules/users/dto/users.dto.ts b/apps/api/src/modules/users/dto/users.dto.ts index ce1746d..c0dfbfd 100644 --- a/apps/api/src/modules/users/dto/users.dto.ts +++ b/apps/api/src/modules/users/dto/users.dto.ts @@ -21,18 +21,14 @@ const TopPickCategoryIdSchema = z .int() .refine( (value): value is TopPickCategoryId => TOP_PICK_CATEGORY_ID_SET.has(value), - { - message: "Invalid top pick category", - }, + { message: "Invalid top pick category" }, ); const TopPickMediaTypeSchema = z .string() .refine( (value): value is TopPickMediaType => TOP_PICK_MEDIA_TYPE_SET.has(value), - { - message: "Invalid top pick media type", - }, + { message: "Invalid top pick media type" }, ); const TopPickItemSchema = z.object({ @@ -51,7 +47,7 @@ const TopPickCategorySchema = z items: z.array(TopPickItemSchema).max(MAX_TOP_PICK_ITEMS_PER_CATEGORY), }) .superRefine((category, ctx) => { - const expectedMediaType = TOP_PICK_DEFAULT_MEDIA_TYPE[category.categoryId]; + const expectedMediaType = TOP_PICK_DEFAULT_MEDIA_TYPE[category.categoryId as TopPickCategoryId]; const seenSlots = new Set(); const seenMediaSources = new Set(); @@ -85,7 +81,7 @@ const TopPickCategorySchema = z const TopPicksSchema = z .array(TopPickCategorySchema) - .max(TOP_PICK_CATEGORY_IDS.length) + .max(4) .superRefine((categories, ctx) => { const seenCategoryIds = new Set(); From 4e88104ac5f1b68f99a0b4a0ccbeb9c7244bee89 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:22:32 +0300 Subject: [PATCH 6/7] feat(profile): show music and books in diary, liked, watchlist, and top 4 getLikedFilms and getWatchlistedFilms now union music interaction rows (liked/wantToListen) and book interaction rows (liked/wantToRead). Frontend diary model maps coverArtUrl/mbid/volumeId. ProfileLikedPage and ProfileMediaInteractionGridSection gain Music/Books filter tabs with routes to /music/$mbid and /books/$volumeId. ProfileTopPicksSection renders all four categories. --- .../users-media-interactions.repository.ts | 72 ++++++-- apps/web/src/features/profile/api.ts | 16 +- .../ProfileMediaInteractionGridSection.tsx | 170 ++++++++++++------ .../profile/components/diary/diary-model.ts | 8 + .../overview/ProfileTopPicksSection.tsx | 103 ++++++----- .../overview/profileOverview.utils.ts | 10 +- .../profile/pages/ProfileDiaryPage.tsx | 4 +- .../profile/pages/ProfileLikedPage.tsx | 76 +++++--- apps/web/src/types/api.ts | 37 +++- 9 files changed, 345 insertions(+), 151 deletions(-) diff --git a/apps/api/src/modules/users/repositories/users-media-interactions.repository.ts b/apps/api/src/modules/users/repositories/users-media-interactions.repository.ts index 4967594..c5c6b30 100644 --- a/apps/api/src/modules/users/repositories/users-media-interactions.repository.ts +++ b/apps/api/src/modules/users/repositories/users-media-interactions.repository.ts @@ -3,10 +3,12 @@ import { db } from "../../../infrastructure/database/db"; import { movieInteractions } from "../../interactions/interactions.entity"; import { movies } from "../../movies/movies.entity"; import { serialInteractions, tvSeries } from "../../serials/serials.entity"; +import { musicInteractions, albums } from "../../music/music.entity"; +import { bookInteractions, books } from "../../books/books.entity"; export class UsersMediaInteractionsRepository { static async getLikedFilms(userId: string) { - const [movieRows, serialRows] = await Promise.all([ + const [movieRows, serialRows, albumRows, bookRows] = await Promise.all([ db .select({ tmdbId: movies.tmdbId, @@ -35,15 +37,41 @@ export class UsersMediaInteractionsRepository { .from(serialInteractions) .innerJoin(tvSeries, eq(serialInteractions.seriesId, tvSeries.id)) .where(and(eq(serialInteractions.userId, userId), eq(serialInteractions.liked, true))), + db + .select({ + mbid: albums.mbid, + title: albums.title, + coverArtUrl: albums.coverArtUrl, + releaseYear: albums.firstReleaseYear, + artistName: albums.artistName, + mediaType: sql<"album">`'album'`, + lastInteractionAt: musicInteractions.updatedAt, + }) + .from(musicInteractions) + .innerJoin(albums, eq(musicInteractions.albumId, albums.id)) + .where(and(eq(musicInteractions.userId, userId), eq(musicInteractions.liked, true))), + db + .select({ + volumeId: books.googleVolumeId, + title: books.title, + coverImageUrl: books.coverImageUrl, + releaseYear: books.publishedYear, + authors: books.authors, + mediaType: sql<"book">`'book'`, + lastInteractionAt: bookInteractions.updatedAt, + }) + .from(bookInteractions) + .innerJoin(books, eq(bookInteractions.bookId, books.id)) + .where(and(eq(bookInteractions.userId, userId), eq(bookInteractions.liked, true))), ]); - return [...movieRows, ...serialRows].sort( + return [...movieRows, ...serialRows, ...albumRows, ...bookRows].sort( (left, right) => right.lastInteractionAt.getTime() - left.lastInteractionAt.getTime(), ); } static async getWatchlistedFilms(userId: string) { - const [movieRows, serialRows] = await Promise.all([ + const [movieRows, serialRows, albumRows, bookRows] = await Promise.all([ db .select({ tmdbId: movies.tmdbId, @@ -58,10 +86,7 @@ export class UsersMediaInteractionsRepository { .from(movieInteractions) .innerJoin(movies, eq(movieInteractions.movieId, movies.id)) .where( - and( - eq(movieInteractions.userId, userId), - eq(movieInteractions.watchlisted, true), - ), + and(eq(movieInteractions.userId, userId), eq(movieInteractions.watchlisted, true)), ), db .select({ @@ -77,14 +102,37 @@ export class UsersMediaInteractionsRepository { .from(serialInteractions) .innerJoin(tvSeries, eq(serialInteractions.seriesId, tvSeries.id)) .where( - and( - eq(serialInteractions.userId, userId), - eq(serialInteractions.watchlisted, true), - ), + and(eq(serialInteractions.userId, userId), eq(serialInteractions.watchlisted, true)), ), + db + .select({ + mbid: albums.mbid, + title: albums.title, + coverArtUrl: albums.coverArtUrl, + releaseYear: albums.firstReleaseYear, + artistName: albums.artistName, + mediaType: sql<"album">`'album'`, + lastInteractionAt: musicInteractions.updatedAt, + }) + .from(musicInteractions) + .innerJoin(albums, eq(musicInteractions.albumId, albums.id)) + .where(and(eq(musicInteractions.userId, userId), eq(musicInteractions.wantToListen, true))), + db + .select({ + volumeId: books.googleVolumeId, + title: books.title, + coverImageUrl: books.coverImageUrl, + releaseYear: books.publishedYear, + authors: books.authors, + mediaType: sql<"book">`'book'`, + lastInteractionAt: bookInteractions.updatedAt, + }) + .from(bookInteractions) + .innerJoin(books, eq(bookInteractions.bookId, books.id)) + .where(and(eq(bookInteractions.userId, userId), eq(bookInteractions.wantToRead, true))), ]); - return [...movieRows, ...serialRows].sort( + return [...movieRows, ...serialRows, ...albumRows, ...bookRows].sort( (left, right) => right.lastInteractionAt.getTime() - left.lastInteractionAt.getTime(), ); } diff --git a/apps/web/src/features/profile/api.ts b/apps/web/src/features/profile/api.ts index b9f30e4..09b9588 100644 --- a/apps/web/src/features/profile/api.ts +++ b/apps/web/src/features/profile/api.ts @@ -34,13 +34,19 @@ const userReviewListSchema = z.array(userReviewSchema); const userInteractionMovieSchema = z .object({ - tmdbId: z.number().int(), + tmdbId: z.number().int().nullable().optional(), + mbid: z.string().nullable().optional(), + volumeId: z.string().nullable().optional(), title: z.string(), - posterPath: z.string().nullable(), + posterPath: z.string().nullable().optional(), + coverArtUrl: z.string().nullable().optional(), + coverImageUrl: z.string().nullable().optional(), releaseYear: z.number().int().nullable(), - runtime: z.number().int().nullable(), + runtime: z.number().int().nullable().optional(), genres: z.array(movieGenreSchema).nullish(), - mediaType: z.enum(["movie", "tv"]).default("movie"), + mediaType: z.enum(["movie", "tv", "album", "book"]).default("movie"), + artistName: z.string().nullable().optional(), + authors: z.array(z.string()).nullable().optional(), lastInteractionAt: z.string(), }) .passthrough(); @@ -132,7 +138,7 @@ const likedReviewSchema = z.object({ containsSpoilers: z.boolean(), createdAt: z.string(), likedAt: z.string(), - mediaType: z.enum(["movie", "tv"]), + mediaType: z.enum(["movie", "tv", "album", "book"]), reviewerUsername: z.string(), reviewerDisplayUsername: z.string().nullable().optional(), mediaTitle: z.string().nullable(), diff --git a/apps/web/src/features/profile/components/ProfileMediaInteractionGridSection.tsx b/apps/web/src/features/profile/components/ProfileMediaInteractionGridSection.tsx index f3fbf60..39037a8 100644 --- a/apps/web/src/features/profile/components/ProfileMediaInteractionGridSection.tsx +++ b/apps/web/src/features/profile/components/ProfileMediaInteractionGridSection.tsx @@ -1,38 +1,129 @@ import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; +import { Disc3, BookOpen } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { getPosterUrl } from "@/features/films/components/utils"; import { ProfileTabEmptyState } from "@/features/profile/components/ProfileTabEmptyState"; import type { UserInteractionMovie } from "@/features/profile/api"; import { getRelativeTime } from "@/features/profile/utils/profile.utils"; -type FavoritesFilter = "all" | "cinema" | "serial"; +type FavoritesFilter = "all" | "cinema" | "serial" | "music" | "books"; const filterTabs: Array<{ key: FavoritesFilter; label: string }> = [ { key: "all", label: "All" }, { key: "cinema", label: "Cinema" }, { key: "serial", label: "Serial" }, + { key: "music", label: "Music" }, + { key: "books", label: "Books" }, ]; -const routeByMediaType: Record = { - movie: "/cinema/$tmdbId", - tv: "/serials/$tmdbId", +const filterMatches = (item: UserInteractionMovie, filter: FavoritesFilter): boolean => { + if (filter === "all") return true; + if (filter === "cinema") return item.mediaType === "movie"; + if (filter === "serial") return item.mediaType === "tv"; + if (filter === "music") return item.mediaType === "album"; + if (filter === "books") return item.mediaType === "book"; + return false; }; -const filterMatches = (item: UserInteractionMovie, filter: FavoritesFilter): boolean => { - if (filter === "all") { - return true; +const getItemKey = (item: UserInteractionMovie, prefix: string): string => { + if (item.mediaType === "album" && item.mbid) return `${prefix}-album-${item.mbid}`; + if (item.mediaType === "book" && item.volumeId) return `${prefix}-book-${item.volumeId}`; + return `${prefix}-${item.mediaType}-${item.tmdbId}`; +}; + +type ItemCardProps = { + item: UserInteractionMovie; + interactionVerb: string; + sectionTitle: string; +}; + +const ItemCard = ({ item, interactionVerb, sectionTitle }: ItemCardProps) => { + const coverUrl = item.coverArtUrl ?? item.coverImageUrl ?? null; + const posterUrl = item.posterPath ? getPosterUrl(item.posterPath) : null; + const imageUrl = posterUrl ?? coverUrl; + + const isAlbum = item.mediaType === "album"; + const isBook = item.mediaType === "book"; + + const cardContent = ( + <> +
    + {imageUrl ? ( + {item.title} + ) : ( +
    + {isAlbum ? ( + + ) : isBook ? ( + + ) : null} +
    + )} +
    +

    + {item.title} +

    +

    + {item.releaseYear ?? "Unknown year"} · {interactionVerb}{" "} + {getRelativeTime(item.lastInteractionAt)} +

    + + ); + + if (isAlbum && item.mbid) { + return ( + + {cardContent} + + ); } - if (filter === "cinema") { - return item.mediaType === "movie"; + if (isBook && item.volumeId) { + return ( + + {cardContent} + + ); } - if (filter === "serial") { - return item.mediaType === "tv"; + if ((item.mediaType === "movie" || item.mediaType === "tv") && item.tmdbId != null) { + const to = item.mediaType === "tv" ? "/serials/$tmdbId" : "/cinema/$tmdbId"; + return ( + + {cardContent} + + ); } - return false; + return ( +
    + {cardContent} +
    + ); }; type ProfileMediaInteractionGridSectionProps = { @@ -134,53 +225,14 @@ export const ProfileMediaInteractionGridSection = ({
    ) : (
    - {filteredItems.map((item) => { - const mediaRoute = routeByMediaType[item.mediaType]; - - const card = ( - <> -
    - {item.title} -
    - -

    - {item.title} -

    -

    - {item.releaseYear ?? "Unknown year"} · {interactionVerb}{" "} - {getRelativeTime(item.lastInteractionAt)} -

    - - ); - - if (mediaRoute) { - return ( - - {card} - - ); - } - - return ( -
    - {card} -
    - ); - })} + {filteredItems.map((item) => ( + + ))}
    )}
    diff --git a/apps/web/src/features/profile/components/diary/diary-model.ts b/apps/web/src/features/profile/components/diary/diary-model.ts index e6f000b..8215118 100644 --- a/apps/web/src/features/profile/components/diary/diary-model.ts +++ b/apps/web/src/features/profile/components/diary/diary-model.ts @@ -9,7 +9,10 @@ export type DiaryRow = { channel: FeedChannel; title: string; posterPath: string | null; + coverArtUrl: string | null; tmdbId: number | null; + mbid: string | null; + volumeId: string | null; releaseYear: number | null; createdAt: string; ratingOutOfFive: number | null; @@ -29,6 +32,8 @@ export type DiaryRow = { export const channelDisplayLabel: Record = { cinema: "Cinema", serial: "Serial", + music: "Music", + books: "Books", }; export const toDateParts = (value: string): { @@ -145,7 +150,10 @@ export const toDiaryRows = ( channel, title, posterPath: item.movie?.posterPath ?? null, + coverArtUrl: item.movie?.coverArtUrl ?? null, tmdbId, + mbid: item.movie?.mbid ?? null, + volumeId: item.movie?.volumeId ?? null, releaseYear: item.movie?.releaseYear ?? null, createdAt: item.createdAt, ratingOutOfFive, diff --git a/apps/web/src/features/profile/components/overview/ProfileTopPicksSection.tsx b/apps/web/src/features/profile/components/overview/ProfileTopPicksSection.tsx index dbbe4fd..1bb1f24 100644 --- a/apps/web/src/features/profile/components/overview/ProfileTopPicksSection.tsx +++ b/apps/web/src/features/profile/components/overview/ProfileTopPicksSection.tsx @@ -1,6 +1,8 @@ import { Link } from "@tanstack/react-router"; import { + BookOpen, Film, + Music, Tv, type LucideIcon, } from "lucide-react"; @@ -18,11 +20,13 @@ type ProfileTopPicksSectionProps = { isTopPicksError: boolean; }; -type TopPickCategoryKey = "cinema" | "serial"; +type TopPickCategoryKey = "cinema" | "serial" | "music" | "books"; const topPickCategoryOrder: TopPickCategoryKey[] = [ "cinema", "serial", + "music", + "books", ]; const topPickCategoryMeta: Record< @@ -46,23 +50,28 @@ const topPickCategoryMeta: Record< icon: Tv, defaultSupported: true, }, + music: { + label: "Music", + color: "var(--module-music)", + icon: Music, + defaultSupported: true, + }, + books: { + label: "Books", + color: "var(--module-book)", + icon: BookOpen, + defaultSupported: true, + }, }; const resolveTmdbId = (item: UserTopPickItem | null): number | null => { - if (!item) { - return null; - } - + if (!item) return null; const directTmdbId = item.tmdbId; - if (Number.isInteger(directTmdbId)) { - return directTmdbId; - } - + if (Number.isInteger(directTmdbId)) return directTmdbId; if (item.mediaSource === "tmdb") { const parsed = Number(item.mediaSourceId); return Number.isInteger(parsed) ? parsed : null; } - return null; }; @@ -70,13 +79,9 @@ const toSlotItems = ( category: UserTopPickCategory | undefined, ): Array => { const bySlot = new Map(); - for (const item of category?.items ?? []) { - if (!bySlot.has(item.slot)) { - bySlot.set(item.slot, item); - } + if (!bySlot.has(item.slot)) bySlot.set(item.slot, item); } - return [1, 2, 3, 4].map((slot) => bySlot.get(slot) ?? null); }; @@ -95,12 +100,19 @@ const TopPickSlot = ({ }) => { const [didPosterFail, setDidPosterFail] = useState(false); const tmdbId = resolveTmdbId(item); - const shouldLiftOnHover = categoryKey === "cinema" || categoryKey === "serial"; const title = item?.title?.trim() || (isCategorySupported ? "Not set" : "Not supported"); - const posterUrl = item?.posterPath ? getPosterUrl(item.posterPath) : null; - const showPoster = Boolean(posterUrl && !didPosterFail); + + const coverUrl = item + ? categoryKey === "music" || categoryKey === "books" + ? (item.coverArtUrl ?? null) + : item.posterPath + ? getPosterUrl(item.posterPath) + : null + : null; + + const showPoster = Boolean(coverUrl && !didPosterFail); const body = (
    {showPoster ? ( {title} { - setDidPosterFail(true); - }} + onError={() => { setDidPosterFail(true); }} /> ) : (
    @@ -139,11 +149,7 @@ const TopPickSlot = ({ {body} @@ -156,11 +162,20 @@ const TopPickSlot = ({ + {body} + + ); + } + + if (categoryKey === "music" && item?.mediaSourceId) { + return ( + {body} @@ -168,15 +183,24 @@ const TopPickSlot = ({ ); } - if (shouldLiftOnHover) { + if (categoryKey === "books" && item?.mediaSourceId) { return ( -
    + {body} -
    + ); } - return body; + return ( +
    + {body} +
    + ); }; export const ProfileTopPicksSection = ({ @@ -213,16 +237,11 @@ export const ProfileTopPicksSection = ({ const meta = topPickCategoryMeta[categoryKey]; const Icon = meta.icon; const isCategorySupported = category?.supported ?? meta.defaultSupported; - const shouldLiftPanel = categoryKey === "cinema" || categoryKey === "serial"; return (
    { const likedTmdbIdSet = useMemo(() => { return new Set( (likedQuery.data ?? []) - .filter((item) => item.mediaType === "movie") - .map((movie) => movie.tmdbId), + .filter((item) => item.mediaType === "movie" && typeof item.tmdbId === "number") + .map((movie) => movie.tmdbId as number), ); }, [likedQuery.data]); diff --git a/apps/web/src/features/profile/pages/ProfileLikedPage.tsx b/apps/web/src/features/profile/pages/ProfileLikedPage.tsx index 7735870..8c2cba2 100644 --- a/apps/web/src/features/profile/pages/ProfileLikedPage.tsx +++ b/apps/web/src/features/profile/pages/ProfileLikedPage.tsx @@ -14,7 +14,7 @@ import { getRelativeTime } from "@/features/profile/utils/profile.utils"; import { formatRelativeTime } from "@/lib/time"; type LikedTab = "medias" | "reviews" | "lists"; -type MediaFilter = "all" | "cinema" | "serial"; +type MediaFilter = "all" | "cinema" | "serial" | "music" | "books"; type ListFilter = "all" | "cinema" | "serial" | "mixed"; const topTabs: Array<{ key: LikedTab; label: string }> = [ @@ -27,6 +27,8 @@ const mediaSubFilters: Array<{ key: MediaFilter; label: string }> = [ { key: "all", label: "All" }, { key: "cinema", label: "Cinema" }, { key: "serial", label: "Serial" }, + { key: "music", label: "Music" }, + { key: "books", label: "Books" }, ]; const listSubFilters: Array<{ key: ListFilter; label: string }> = [ @@ -59,11 +61,6 @@ const tabButtonStyle = (isActive: boolean): React.CSSProperties => }; // ── Media grid (inlined to share the sub-filter row with top tabs) ────────── -const routeByMediaType: Record = { - movie: "/cinema/$tmdbId", - tv: "/serials/$tmdbId", -}; - const MediaGrid = ({ items, filter, @@ -74,7 +71,10 @@ const MediaGrid = ({ const filtered = useMemo(() => { if (filter === "all") return items; if (filter === "cinema") return items.filter((i) => i.mediaType === "movie"); - return items.filter((i) => i.mediaType === "tv"); + if (filter === "serial") return items.filter((i) => i.mediaType === "tv"); + if (filter === "music") return items.filter((i) => i.mediaType === "album"); + if (filter === "books") return items.filter((i) => i.mediaType === "book"); + return items; }, [items, filter]); if (filtered.length === 0) { @@ -88,16 +88,26 @@ const MediaGrid = ({ return (
    {filtered.map((item) => { - const mediaRoute = routeByMediaType[item.mediaType]; + const coverUrl = item.mediaType === "album" || item.mediaType === "book" + ? (item.coverArtUrl ?? item.coverImageUrl ?? null) + : getPosterUrl(item.posterPath ?? null); + const itemKey = item.mediaType === "album" + ? `media-album-${item.mbid}` + : item.mediaType === "book" + ? `media-book-${item.volumeId}` + : `media-${item.mediaType}-${item.tmdbId}`; + const card = ( <>
    - {item.title} + {coverUrl ? ( + {item.title} + ) : null}

    {item.title} @@ -108,22 +118,37 @@ const MediaGrid = ({ ); - if (mediaRoute) { + if (item.mediaType === "movie" && item.tmdbId) { return ( - + + {card} + + ); + } + if (item.mediaType === "tv" && item.tmdbId) { + return ( + + {card} + + ); + } + if (item.mediaType === "album" && item.mbid) { + return ( + + {card} + + ); + } + if (item.mediaType === "book" && item.volumeId) { + return ( + {card} ); } return ( -

    +
    {card}
    ); @@ -315,7 +340,10 @@ export const ProfileLikedPage = ({ username }: ProfileLikedPageProps) => { const filteredReviews = useMemo(() => { if (reviewFilter === "all") return reviewItems; if (reviewFilter === "cinema") return reviewItems.filter((r) => r.mediaType === "movie"); - return reviewItems.filter((r) => r.mediaType === "tv"); + if (reviewFilter === "serial") return reviewItems.filter((r) => r.mediaType === "tv"); + if (reviewFilter === "music") return reviewItems.filter((r) => r.mediaType === "album"); + if (reviewFilter === "books") return reviewItems.filter((r) => r.mediaType === "book"); + return reviewItems; }, [reviewItems, reviewFilter]); const filteredLists = useMemo(() => { diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 4ffbf54..c4b29c1 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -148,14 +148,18 @@ export const userStatsSchema = z.object({ export const topPickCategoryIdSchema = z.union([ z.literal(1), z.literal(2), + z.literal(3), + z.literal(4), ]); export const topPickCategoryKeySchema = z.enum([ "cinema", "serial", + "music", + "books", ]); -export const topPickMediaTypeSchema = z.enum(["movie", "tv"]); +export const topPickMediaTypeSchema = z.enum(["movie", "tv", "album", "book"]); export const topPickItemSchema = z.object({ slot: z.number().int().min(1).max(4), @@ -166,7 +170,10 @@ export const topPickItemSchema = z.object({ tmdbId: z.number().int().nullable(), title: z.string().nullable(), posterPath: z.string().nullable(), + coverArtUrl: z.string().nullable().optional(), releaseYear: z.number().int().nullable(), + artistName: z.string().nullable().optional(), + authors: z.array(z.string()).nullable().optional(), }); export const topPickCategorySchema = z.object({ @@ -177,7 +184,7 @@ export const topPickCategorySchema = z.object({ }); export const publicTop4ResponseSchema = z.object({ - categories: z.array(topPickCategorySchema).length(2), + categories: z.array(topPickCategorySchema).min(2), }); export const updateTopPickItemInputSchema = z.object({ @@ -195,6 +202,30 @@ export const updateTopPickCategoryInputSchema = z.object({ items: z.array(updateTopPickItemInputSchema).max(4), }); +export const updateTopPickItemInputMusicSchema = z.object({ + slot: z.number().int().min(1).max(4), + mediaType: z.literal("album"), + mediaSource: z.literal("musicbrainz"), + mediaSourceId: z.string().trim().min(1).max(128), + title: z.string().trim().max(200).optional(), + posterPath: z.string().trim().max(500).nullable().optional(), + coverArtUrl: z.string().trim().max(500).nullable().optional(), + releaseYear: z.number().int().min(1800).max(2200).nullable().optional(), + artistName: z.string().trim().max(200).nullable().optional(), +}); + +export const updateTopPickItemInputBooksSchema = z.object({ + slot: z.number().int().min(1).max(4), + mediaType: z.literal("book"), + mediaSource: z.literal("googlebooks"), + mediaSourceId: z.string().trim().min(1).max(128), + title: z.string().trim().max(200).optional(), + posterPath: z.string().trim().max(500).nullable().optional(), + coverArtUrl: z.string().trim().max(500).nullable().optional(), + releaseYear: z.number().int().min(1800).max(2200).nullable().optional(), + authors: z.array(z.string()).nullable().optional(), +}); + export const publicProfileSchema = z .object({ id: z.string(), @@ -234,7 +265,7 @@ export const updateProfileInputSchema = z.object({ bio: z.string().max(300).optional(), location: z.string().max(100).optional(), avatarUrl: z.string().url().optional().or(z.literal("")), - topPicks: z.array(updateTopPickCategoryInputSchema).max(2).optional(), + topPicks: z.array(updateTopPickCategoryInputSchema).max(4).optional(), favoriteGenres: z.array(favoriteGenreSchema).max(8).optional(), }); From 303a909ce74c60bdf0ac5794671a4d5d02328303 Mon Sep 17 00:00:00 2001 From: Glory42 Date: Mon, 11 May 2026 22:22:53 +0300 Subject: [PATCH 7/7] feat(settings): add music and books to top picks picker Extends the favorites section with Music and Books slot lists (slots 3 and 4). Adds Top4AlbumSearchDialog (MusicBrainz) and Top4BookSearchDialog (Google Books). Controller manages draft state and resolves albums via getAlbumByMbid, books via getBookByVolumeId. --- .../profile/Top4AlbumSearchDialog.tsx | 140 +++++++++++++ .../profile/Top4BookSearchDialog.tsx | 145 ++++++++++++++ .../sections/SettingsFavoritesSection.tsx | 70 +++++-- .../favorites/FavoritesPickerDialogs.tsx | 30 +++ .../sections/favorites/FavoritesSlotList.tsx | 2 +- .../components/sections/favorites/models.ts | 91 ++++++--- .../useSettingsFavoritesController.ts | 187 +++++++++++------- 7 files changed, 543 insertions(+), 122 deletions(-) create mode 100644 apps/web/src/features/settings/components/profile/Top4AlbumSearchDialog.tsx create mode 100644 apps/web/src/features/settings/components/profile/Top4BookSearchDialog.tsx diff --git a/apps/web/src/features/settings/components/profile/Top4AlbumSearchDialog.tsx b/apps/web/src/features/settings/components/profile/Top4AlbumSearchDialog.tsx new file mode 100644 index 0000000..3878229 --- /dev/null +++ b/apps/web/src/features/settings/components/profile/Top4AlbumSearchDialog.tsx @@ -0,0 +1,140 @@ +import { useDeferredValue } from "react"; +import { createPortal } from "react-dom"; +import { Disc3, Search, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useMusicSearch } from "@/features/music/hooks/useMusic"; +import type { MbSearchResult } from "@/features/music/api"; + +type Top4AlbumSearchDialogProps = { + isOpen: boolean; + onClose: () => void; + query: string; + onQueryChange: (value: string) => void; + onSelectAlbum: (result: MbSearchResult) => void; + isSelectingAlbum: boolean; +}; + +const getArtistName = (result: MbSearchResult): string => { + const credits = result["artist-credit"] ?? []; + return credits.map((c) => c.name + (c.joinphrase ?? "")).join("").trim() || "Unknown Artist"; +}; + +const getReleaseYear = (result: MbSearchResult): string | null => { + const date = result["first-release-date"]; + if (!date) return null; + return date.slice(0, 4); +}; + +export const Top4AlbumSearchDialog = ({ + isOpen, + onClose, + query, + onQueryChange, + onSelectAlbum, + isSelectingAlbum, +}: Top4AlbumSearchDialogProps) => { + const deferredQuery = useDeferredValue(query); + const searchQuery = useMusicSearch(deferredQuery); + const suggestions = (searchQuery.data ?? []).slice(0, 10); + + if (!isOpen) return null; + + return createPortal( +
    + +
    +
    + +
    + {query.trim().length < 2 ? ( +

    + Type at least 2 characters to search. +

    + ) : null} + + {query.trim().length >= 2 && searchQuery.isFetching ? ( +

    + Finding albums... +

    + ) : null} + + {query.trim().length >= 2 && !searchQuery.isFetching && suggestions.length === 0 ? ( +

    + No matches found. +

    + ) : null} + + {!searchQuery.isFetching && query.trim().length >= 2 && suggestions.length > 0 ? ( +
      + {suggestions.map((result) => { + const artistName = getArtistName(result); + const year = getReleaseYear(result); + + return ( +
    • + +
    • + ); + })} +
    + ) : null} +
    +
    +
    +
    , + document.body, + ); +}; diff --git a/apps/web/src/features/settings/components/profile/Top4BookSearchDialog.tsx b/apps/web/src/features/settings/components/profile/Top4BookSearchDialog.tsx new file mode 100644 index 0000000..9d0060d --- /dev/null +++ b/apps/web/src/features/settings/components/profile/Top4BookSearchDialog.tsx @@ -0,0 +1,145 @@ +import { useDeferredValue } from "react"; +import { createPortal } from "react-dom"; +import { BookOpen, Search, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useBookSearch } from "@/features/books/hooks/useBooks"; +import type { GoogleBooksVolume } from "@/features/books/api"; + +type Top4BookSearchDialogProps = { + isOpen: boolean; + onClose: () => void; + query: string; + onQueryChange: (value: string) => void; + onSelectBook: (volume: GoogleBooksVolume) => void; + isSelectingBook: boolean; +}; + +const getCoverUrl = (volume: GoogleBooksVolume): string | null => { + const links = volume.volumeInfo.imageLinks; + return links?.thumbnail ?? links?.smallThumbnail ?? null; +}; + +const getPublishedYear = (volume: GoogleBooksVolume): string | null => { + const date = volume.volumeInfo.publishedDate; + if (!date) return null; + return date.slice(0, 4); +}; + +export const Top4BookSearchDialog = ({ + isOpen, + onClose, + query, + onQueryChange, + onSelectBook, + isSelectingBook, +}: Top4BookSearchDialogProps) => { + const deferredQuery = useDeferredValue(query); + const searchQuery = useBookSearch(deferredQuery); + const suggestions = (searchQuery.data ?? []).slice(0, 10); + + if (!isOpen) return null; + + return createPortal( +
    + +
    +
    + +
    + {query.trim().length < 2 ? ( +

    + Type at least 2 characters to search. +

    + ) : null} + + {query.trim().length >= 2 && searchQuery.isFetching ? ( +

    + Finding books... +

    + ) : null} + + {query.trim().length >= 2 && !searchQuery.isFetching && suggestions.length === 0 ? ( +

    + No matches found. +

    + ) : null} + + {!searchQuery.isFetching && query.trim().length >= 2 && suggestions.length > 0 ? ( +
      + {suggestions.map((volume) => { + const coverUrl = getCoverUrl(volume); + const authors = volume.volumeInfo.authors?.join(", ") ?? "Unknown Author"; + const year = getPublishedYear(volume); + + return ( +
    • + +
    • + ); + })} +
    + ) : null} +
    + +
    +
    , + document.body, + ); +}; diff --git a/apps/web/src/features/settings/components/sections/SettingsFavoritesSection.tsx b/apps/web/src/features/settings/components/sections/SettingsFavoritesSection.tsx index 25a9ea7..7a8e6bd 100644 --- a/apps/web/src/features/settings/components/sections/SettingsFavoritesSection.tsx +++ b/apps/web/src/features/settings/components/sections/SettingsFavoritesSection.tsx @@ -1,4 +1,4 @@ -import { Film, Tv } from "lucide-react"; +import { BookOpen, Film, Music, Tv } from "lucide-react"; import { Spinner } from "@/components/ui/spinner"; import { useAuth } from "@/features/auth/hooks/useAuth"; import { FavoritesPickerDialogs } from "./favorites/FavoritesPickerDialogs"; @@ -26,24 +26,20 @@ export const SettingsFavoritesSection = () => { Top Picks

    - Set up to 4 favorites for Cinema and Serial. These appear as a showcase on your public profile. + Set up to 4 favorites for Cinema, Serial, Music, and Books. These appear as a showcase on your public profile.

    - + Cinema ({controller.selectedCinemaCount}/4)
    - {
    - + Serial ({controller.selectedSerialCount}/4)
    - { onClearSlot={controller.handleClearSlot} />
    + +
    +
    + + + Music + + + ({controller.selectedMusicCount}/4) + +
    + +
    + +
    +
    + + + Books + + + ({controller.selectedBooksCount}/4) + +
    + +
    {controller.topPicksQuery.isPending ? ( @@ -103,9 +133,7 @@ export const SettingsFavoritesSection = () => {
    ); diff --git a/apps/web/src/features/settings/components/sections/favorites/FavoritesPickerDialogs.tsx b/apps/web/src/features/settings/components/sections/favorites/FavoritesPickerDialogs.tsx index 4630909..dda05a6 100644 --- a/apps/web/src/features/settings/components/sections/favorites/FavoritesPickerDialogs.tsx +++ b/apps/web/src/features/settings/components/sections/favorites/FavoritesPickerDialogs.tsx @@ -1,7 +1,11 @@ import type { TmdbSearchMovie } from "@/types/api"; import type { TmdbSearchSeries } from "@/features/serials/api"; +import type { MbSearchResult } from "@/features/music/api"; +import type { GoogleBooksVolume } from "@/features/books/api"; import { Top4MovieSearchDialog } from "@/features/settings/components/profile/Top4MovieSearchDialog"; import { Top4SeriesSearchDialog } from "@/features/settings/components/profile/Top4SeriesSearchDialog"; +import { Top4AlbumSearchDialog } from "@/features/settings/components/profile/Top4AlbumSearchDialog"; +import { Top4BookSearchDialog } from "@/features/settings/components/profile/Top4BookSearchDialog"; import type { PickerTarget } from "./models"; type FavoritesPickerDialogsProps = { @@ -9,10 +13,14 @@ type FavoritesPickerDialogsProps = { searchQuery: string; isSelectingMovie: boolean; isSelectingSeries: boolean; + isSelectingAlbum: boolean; + isSelectingBook: boolean; onClose: () => void; onQueryChange: (value: string) => void; onSelectMovie: (movie: TmdbSearchMovie) => void; onSelectSeries: (series: TmdbSearchSeries) => void; + onSelectAlbum: (result: MbSearchResult) => void; + onSelectBook: (volume: GoogleBooksVolume) => void; }; export const FavoritesPickerDialogs = ({ @@ -20,10 +28,14 @@ export const FavoritesPickerDialogs = ({ searchQuery, isSelectingMovie, isSelectingSeries, + isSelectingAlbum, + isSelectingBook, onClose, onQueryChange, onSelectMovie, onSelectSeries, + onSelectAlbum, + onSelectBook, }: FavoritesPickerDialogsProps) => { return ( <> @@ -44,6 +56,24 @@ export const FavoritesPickerDialogs = ({ onSelectSeries={onSelectSeries} isSelectingSeries={isSelectingSeries} /> + + + + ); }; diff --git a/apps/web/src/features/settings/components/sections/favorites/FavoritesSlotList.tsx b/apps/web/src/features/settings/components/sections/favorites/FavoritesSlotList.tsx index 9df03b0..ffc60f0 100644 --- a/apps/web/src/features/settings/components/sections/favorites/FavoritesSlotList.tsx +++ b/apps/web/src/features/settings/components/sections/favorites/FavoritesSlotList.tsx @@ -31,7 +31,7 @@ export const FavoritesSlotList = ({ disabled={isBusy} > - {slot?.title ?? `${category === "cinema" ? "Cinema" : "Serial"} #${index + 1}`} + {slot?.title ?? `${category === "cinema" ? "Cinema" : category === "serial" ? "Serial" : category === "music" ? "Music" : "Books"} #${index + 1}`} diff --git a/apps/web/src/features/settings/components/sections/favorites/models.ts b/apps/web/src/features/settings/components/sections/favorites/models.ts index 2beadf9..e870253 100644 --- a/apps/web/src/features/settings/components/sections/favorites/models.ts +++ b/apps/web/src/features/settings/components/sections/favorites/models.ts @@ -2,16 +2,21 @@ import type { UserTopPickCategory } from "@/features/profile/api"; export type TopPickSlot = { slot: number; - mediaType: "movie" | "tv"; - mediaSource: "tmdb"; + mediaType: "movie" | "tv" | "album" | "book"; + mediaSource: "tmdb" | "musicbrainz" | "googlebooks"; mediaSourceId: string; - tmdbId: number; + tmdbId?: number; + mbid?: string; + volumeId?: string; title: string; posterPath: string | null; + coverArtUrl?: string | null; releaseYear: number | null; + artistName?: string | null; + authors?: string[] | null; }; -export type TopPickCategoryKey = "cinema" | "serial"; +export type TopPickCategoryKey = "cinema" | "serial" | "music" | "books"; export type PickerTarget = { category: TopPickCategoryKey; @@ -32,7 +37,7 @@ export const asTopPickSlot = (slot: TopPickSlot | null): slot is TopPickSlot => export const resolveCategorySlots = ( category: UserTopPickCategory | undefined, - mediaType: "movie" | "tv", + mediaType: "movie" | "tv" | "album" | "book", ): Array => { if (!category) { return [null, null, null, null]; @@ -41,50 +46,69 @@ export const resolveCategorySlots = ( const slots = [null, null, null, null] as Array; for (const item of category.items) { - if (item.mediaType !== mediaType) { - continue; - } + if (item.mediaType !== mediaType) continue; const zeroIndexedSlot = item.slot - 1; - if (zeroIndexedSlot < 0 || zeroIndexedSlot > 3) { - continue; - } + if (zeroIndexedSlot < 0 || zeroIndexedSlot > 3) continue; - const resolvedTmdbId = - item.tmdbId ?? - (item.mediaSource === "tmdb" ? Number(item.mediaSourceId) : Number.NaN); + if (!item.title) continue; - if (!Number.isInteger(resolvedTmdbId) || !item.title) { - continue; + if (mediaType === "movie" || mediaType === "tv") { + const resolvedTmdbId = + item.tmdbId ?? + (item.mediaSource === "tmdb" ? Number(item.mediaSourceId) : Number.NaN); + if (!Number.isInteger(resolvedTmdbId)) continue; + slots[zeroIndexedSlot] = { + slot: item.slot, + mediaType, + mediaSource: "tmdb", + mediaSourceId: String(resolvedTmdbId), + tmdbId: resolvedTmdbId, + title: item.title, + posterPath: item.posterPath ?? null, + releaseYear: item.releaseYear ?? null, + }; + } else if (mediaType === "album") { + slots[zeroIndexedSlot] = { + slot: item.slot, + mediaType: "album", + mediaSource: "musicbrainz", + mediaSourceId: item.mediaSourceId, + mbid: item.mediaSourceId, + title: item.title, + posterPath: null, + coverArtUrl: item.coverArtUrl ?? null, + releaseYear: item.releaseYear ?? null, + artistName: item.artistName ?? null, + }; + } else if (mediaType === "book") { + slots[zeroIndexedSlot] = { + slot: item.slot, + mediaType: "book", + mediaSource: "googlebooks", + mediaSourceId: item.mediaSourceId, + volumeId: item.mediaSourceId, + title: item.title, + posterPath: null, + coverArtUrl: item.coverArtUrl ?? null, + releaseYear: item.releaseYear ?? null, + authors: item.authors ?? null, + }; } - - slots[zeroIndexedSlot] = { - slot: item.slot, - mediaType, - mediaSource: "tmdb", - mediaSourceId: String(resolvedTmdbId), - tmdbId: resolvedTmdbId, - title: item.title, - posterPath: item.posterPath, - releaseYear: item.releaseYear, - }; } return toFixedLengthSlots(slots); }; export const buildTopPickPayload = ( - categoryId: 1 | 2, + categoryId: 1 | 2 | 3 | 4, slots: Array, ) => { return { categoryId, items: slots .map((slot, index) => { - if (!slot) { - return null; - } - + if (!slot) return null; return { slot: index + 1, mediaType: slot.mediaType, @@ -92,7 +116,10 @@ export const buildTopPickPayload = ( mediaSourceId: slot.mediaSourceId, title: slot.title, posterPath: slot.posterPath, + coverArtUrl: slot.coverArtUrl, releaseYear: slot.releaseYear, + artistName: slot.artistName, + authors: slot.authors, }; }) .filter((item): item is NonNullable => item !== null), diff --git a/apps/web/src/features/settings/components/sections/favorites/useSettingsFavoritesController.ts b/apps/web/src/features/settings/components/sections/favorites/useSettingsFavoritesController.ts index bf3cff5..6d3a4e3 100644 --- a/apps/web/src/features/settings/components/sections/favorites/useSettingsFavoritesController.ts +++ b/apps/web/src/features/settings/components/sections/favorites/useSettingsFavoritesController.ts @@ -2,6 +2,10 @@ import { useMemo, useState } from "react"; import { getMovieByTmdbId } from "@/features/films/api"; import { useUpdateMyProfile, useUserTopPicks } from "@/features/profile/hooks/useProfile"; import { getSeriesByTmdbId, type TmdbSearchSeries } from "@/features/serials/api"; +import { getAlbumByMbid } from "@/features/music/api"; +import { getBookByVolumeId } from "@/features/books/api"; +import type { MbSearchResult } from "@/features/music/api"; +import type { GoogleBooksVolume } from "@/features/books/api"; import type { TmdbSearchMovie } from "@/types/api"; import { isApiError } from "@/lib/api-client"; import { @@ -18,46 +22,43 @@ export const useSettingsFavoritesController = (username: string) => { const updateProfileMutation = useUpdateMyProfile(); const topPicksQuery = useUserTopPicks(username); - const [draftCinemaSlots, setDraftCinemaSlots] = useState< - Array | null - >(null); - const [draftSerialSlots, setDraftSerialSlots] = useState< - Array | null - >(null); + const [draftCinemaSlots, setDraftCinemaSlots] = useState | null>(null); + const [draftSerialSlots, setDraftSerialSlots] = useState | null>(null); + const [draftMusicSlots, setDraftMusicSlots] = useState | null>(null); + const [draftBooksSlots, setDraftBooksSlots] = useState | null>(null); const [pickerTarget, setPickerTarget] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [isSelectingMovie, setIsSelectingMovie] = useState(false); const [isSelectingSeries, setIsSelectingSeries] = useState(false); + const [isSelectingAlbum, setIsSelectingAlbum] = useState(false); + const [isSelectingBook, setIsSelectingBook] = useState(false); const [saveError, setSaveError] = useState(null); const [saveSuccess, setSaveSuccess] = useState(null); const categories = topPicksQuery.data?.categories ?? []; const cinemaCategory = categories.find((category) => category.key === "cinema"); const serialCategory = categories.find((category) => category.key === "serial"); + const musicCategory = categories.find((category) => category.key === "music"); + const booksCategory = categories.find((category) => category.key === "books"); - const savedCinemaSlots = useMemo( - () => resolveCategorySlots(cinemaCategory, "movie"), - [cinemaCategory], - ); - const savedSerialSlots = useMemo( - () => resolveCategorySlots(serialCategory, "tv"), - [serialCategory], - ); + const savedCinemaSlots = useMemo(() => resolveCategorySlots(cinemaCategory, "movie"), [cinemaCategory]); + const savedSerialSlots = useMemo(() => resolveCategorySlots(serialCategory, "tv"), [serialCategory]); + const savedMusicSlots = useMemo(() => resolveCategorySlots(musicCategory, "album"), [musicCategory]); + const savedBooksSlots = useMemo(() => resolveCategorySlots(booksCategory, "book"), [booksCategory]); const cinemaSlots = draftCinemaSlots ?? savedCinemaSlots; const serialSlots = draftSerialSlots ?? savedSerialSlots; - const isDirty = draftCinemaSlots !== null || draftSerialSlots !== null; + const musicSlots = draftMusicSlots ?? savedMusicSlots; + const booksSlots = draftBooksSlots ?? savedBooksSlots; - const selectedCinemaCount = useMemo( - () => cinemaSlots.filter(asTopPickSlot).length, - [cinemaSlots], - ); - const selectedSerialCount = useMemo( - () => serialSlots.filter(asTopPickSlot).length, - [serialSlots], - ); + const isDirty = draftCinemaSlots !== null || draftSerialSlots !== null || draftMusicSlots !== null || draftBooksSlots !== null; - const isBusy = updateProfileMutation.isPending || isSelectingMovie || isSelectingSeries; + const selectedCinemaCount = useMemo(() => cinemaSlots.filter(asTopPickSlot).length, [cinemaSlots]); + const selectedSerialCount = useMemo(() => serialSlots.filter(asTopPickSlot).length, [serialSlots]); + const selectedMusicCount = useMemo(() => musicSlots.filter(asTopPickSlot).length, [musicSlots]); + const selectedBooksCount = useMemo(() => booksSlots.filter(asTopPickSlot).length, [booksSlots]); + + const isBusy = updateProfileMutation.isPending || isSelectingMovie || isSelectingSeries || isSelectingAlbum || isSelectingBook; const closePicker = () => { setPickerTarget(null); @@ -65,19 +66,12 @@ export const useSettingsFavoritesController = (username: string) => { }; const openPickerForSlot = (category: TopPickCategoryKey, slotIndex: number) => { - if (isBusy) { - return; - } - + if (isBusy) return; setPickerTarget({ category, slotIndex }); setSearchQuery(""); }; - const updateSlotDraft = ( - category: TopPickCategoryKey, - slotIndex: number, - value: TopPickSlot | null, - ) => { + const updateSlotDraft = (category: TopPickCategoryKey, slotIndex: number, value: TopPickSlot | null) => { if (category === "cinema") { setDraftCinemaSlots((currentDraft) => { const current = currentDraft ?? cinemaSlots; @@ -85,29 +79,37 @@ export const useSettingsFavoritesController = (username: string) => { next[slotIndex] = value; return toFixedLengthSlots(next); }); - return; + } else if (category === "serial") { + setDraftSerialSlots((currentDraft) => { + const current = currentDraft ?? serialSlots; + const next = [...current]; + next[slotIndex] = value; + return toFixedLengthSlots(next); + }); + } else if (category === "music") { + setDraftMusicSlots((currentDraft) => { + const current = currentDraft ?? musicSlots; + const next = [...current]; + next[slotIndex] = value; + return toFixedLengthSlots(next); + }); + } else if (category === "books") { + setDraftBooksSlots((currentDraft) => { + const current = currentDraft ?? booksSlots; + const next = [...current]; + next[slotIndex] = value; + return toFixedLengthSlots(next); + }); } - - setDraftSerialSlots((currentDraft) => { - const current = currentDraft ?? serialSlots; - const next = [...current]; - next[slotIndex] = value; - return toFixedLengthSlots(next); - }); }; const handleSelectMovie = async (movie: TmdbSearchMovie) => { - if (!pickerTarget || pickerTarget.category !== "cinema") { - return; - } - + if (!pickerTarget || pickerTarget.category !== "cinema") return; setIsSelectingMovie(true); setSaveError(null); setSaveSuccess(null); - try { const resolvedMovie = await getMovieByTmdbId(movie.id); - updateSlotDraft("cinema", pickerTarget.slotIndex, { slot: pickerTarget.slotIndex + 1, mediaType: "movie", @@ -118,29 +120,21 @@ export const useSettingsFavoritesController = (username: string) => { posterPath: resolvedMovie.posterPath, releaseYear: resolvedMovie.releaseYear, }); - closePicker(); } catch (error) { - setSaveError( - isApiError(error) ? error.message : "Could not select this favorite right now.", - ); + setSaveError(isApiError(error) ? error.message : "Could not select this favorite right now."); } finally { setIsSelectingMovie(false); } }; const handleSelectSeries = async (series: TmdbSearchSeries) => { - if (!pickerTarget || pickerTarget.category !== "serial") { - return; - } - + if (!pickerTarget || pickerTarget.category !== "serial") return; setIsSelectingSeries(true); setSaveError(null); setSaveSuccess(null); - try { const resolvedSeries = await getSeriesByTmdbId(series.id); - updateSlotDraft("serial", pickerTarget.slotIndex, { slot: pickerTarget.slotIndex + 1, mediaType: "tv", @@ -151,19 +145,68 @@ export const useSettingsFavoritesController = (username: string) => { posterPath: resolvedSeries.posterPath, releaseYear: resolvedSeries.firstAirYear, }); - closePicker(); } catch (error) { - setSaveError( - isApiError(error) - ? error.message - : "Could not select this serial favorite right now.", - ); + setSaveError(isApiError(error) ? error.message : "Could not select this serial favorite right now."); } finally { setIsSelectingSeries(false); } }; + const handleSelectAlbum = async (result: MbSearchResult) => { + if (!pickerTarget || pickerTarget.category !== "music") return; + setIsSelectingAlbum(true); + setSaveError(null); + setSaveSuccess(null); + try { + const album = await getAlbumByMbid(result.id); + updateSlotDraft("music", pickerTarget.slotIndex, { + slot: pickerTarget.slotIndex + 1, + mediaType: "album", + mediaSource: "musicbrainz", + mediaSourceId: album.mbid, + mbid: album.mbid, + title: album.title, + posterPath: null, + coverArtUrl: album.coverArtUrl, + releaseYear: album.firstReleaseYear, + artistName: album.artistName, + }); + closePicker(); + } catch (error) { + setSaveError(isApiError(error) ? error.message : "Could not select this album right now."); + } finally { + setIsSelectingAlbum(false); + } + }; + + const handleSelectBook = async (volume: GoogleBooksVolume) => { + if (!pickerTarget || pickerTarget.category !== "books") return; + setIsSelectingBook(true); + setSaveError(null); + setSaveSuccess(null); + try { + const book = await getBookByVolumeId(volume.id); + updateSlotDraft("books", pickerTarget.slotIndex, { + slot: pickerTarget.slotIndex + 1, + mediaType: "book", + mediaSource: "googlebooks", + mediaSourceId: book.googleVolumeId, + volumeId: book.googleVolumeId, + title: book.title, + posterPath: null, + coverArtUrl: book.coverImageUrl, + releaseYear: book.publishedYear ?? null, + authors: book.authors ?? [], + }); + closePicker(); + } catch (error) { + setSaveError(isApiError(error) ? error.message : "Could not select this book right now."); + } finally { + setIsSelectingBook(false); + } + }; + const handleClearSlot = (category: TopPickCategoryKey, slotIndex: number) => { updateSlotDraft(category, slotIndex, null); setSaveError(null); @@ -173,22 +216,22 @@ export const useSettingsFavoritesController = (username: string) => { const handleSaveFavorites = async () => { setSaveError(null); setSaveSuccess(null); - try { await updateProfileMutation.mutateAsync({ topPicks: [ buildTopPickPayload(1, cinemaSlots), buildTopPickPayload(2, serialSlots), + buildTopPickPayload(3, musicSlots), + buildTopPickPayload(4, booksSlots), ], }); - setDraftCinemaSlots(null); setDraftSerialSlots(null); + setDraftMusicSlots(null); + setDraftBooksSlots(null); setSaveSuccess("Favorites saved."); } catch (error) { - setSaveError( - isApiError(error) ? error.message : "Could not save favorites right now.", - ); + setSaveError(isApiError(error) ? error.message : "Could not save favorites right now."); } }; @@ -199,11 +242,17 @@ export const useSettingsFavoritesController = (username: string) => { searchQuery, cinemaSlots, serialSlots, + musicSlots, + booksSlots, selectedCinemaCount, selectedSerialCount, + selectedMusicCount, + selectedBooksCount, isDirty, isSelectingMovie, isSelectingSeries, + isSelectingAlbum, + isSelectingBook, saveError, saveSuccess, isBusy, @@ -212,6 +261,8 @@ export const useSettingsFavoritesController = (username: string) => { closePicker, handleSelectMovie, handleSelectSeries, + handleSelectAlbum, + handleSelectBook, handleClearSlot, handleSaveFavorites, };