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/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/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; }; 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/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(); 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/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/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(), 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/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/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/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, }; 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; } 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 ; +} 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(), });