diff --git a/.github/workflows/ios-deploy.yml b/.github/workflows/ios-deploy.yml index 497996d2..60f3ae20 100644 --- a/.github/workflows/ios-deploy.yml +++ b/.github/workflows/ios-deploy.yml @@ -1,7 +1,7 @@ name: Deploy IOS (Caller) on: push: - branches: [main, ios-deploy] + branches: [prod, ios-deploy] jobs: deploy: diff --git a/backend/package.json b/backend/package.json index 1d46c57d..8e501521 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,6 +21,7 @@ "db:push:supabase": "sh scripts/push-schema-to-supabase.sh", "db:seed": "sh scripts/seed-supabase.sh --local", "db:seed:supabase": "sh scripts/seed-supabase.sh --prod", + "import:tmdb": "tsx scripts/import-tmdb-ids.ts", "lint": "eslint src --ext .ts", "lint:fix": "eslint src --ext .ts --fix", "format": "prettier --write \"src/**/*.ts\"", @@ -39,6 +40,7 @@ "express": "^4.21.2", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", + "openai": "^6.9.1", "pg": "^8.16.3", "prisma": "^6.15.0", "swagger-autogen": "^2.23.7" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8f777533..06dda685 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -111,20 +111,21 @@ model mfa_challenges { /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. model mfa_factors { - id String @id @db.Uuid - user_id String @db.Uuid - friendly_name String? - factor_type factor_type - status factor_status - created_at DateTime @db.Timestamptz(6) - updated_at DateTime @db.Timestamptz(6) - secret String? - phone String? - last_challenged_at DateTime? @unique @db.Timestamptz(6) - web_authn_credential Json? - web_authn_aaguid String? @db.Uuid - mfa_challenges mfa_challenges[] - users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + id String @id @db.Uuid + user_id String @db.Uuid + friendly_name String? + factor_type factor_type + status factor_status + created_at DateTime @db.Timestamptz(6) + updated_at DateTime @db.Timestamptz(6) + secret String? + phone String? + last_challenged_at DateTime? @unique @db.Timestamptz(6) + web_authn_credential Json? + web_authn_aaguid String? @db.Uuid + last_webauthn_challenge_data Json? + mfa_challenges mfa_challenges[] + users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@unique([user_id, phone], map: "unique_phone_factor_per_user") @@index([user_id, created_at], map: "factor_id_created_at_idx") @@ -150,6 +151,7 @@ model oauth_authorizations { created_at DateTime @default(now()) @db.Timestamptz(6) expires_at DateTime @default(dbgenerated("(now() + '00:03:00'::interval)")) @db.Timestamptz(6) approved_at DateTime? @db.Timestamptz(6) + nonce String? oauth_clients oauth_clients @relation(fields: [client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) users auth_users? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@ -282,25 +284,29 @@ model schema_migrations { @@schema("auth") } +/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. /// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments /// This model contains row level security and requires additional setup for migrations. Visit https://pris.ly/d/row-level-security for more info. model sessions { - id String @id @db.Uuid - user_id String @db.Uuid - created_at DateTime? @db.Timestamptz(6) - updated_at DateTime? @db.Timestamptz(6) - factor_id String? @db.Uuid - aal aal_level? - not_after DateTime? @db.Timestamptz(6) - refreshed_at DateTime? @db.Timestamp(6) - user_agent String? - ip String? @db.Inet - tag String? - oauth_client_id String? @db.Uuid - mfa_amr_claims mfa_amr_claims[] - refresh_tokens refresh_tokens[] - oauth_clients oauth_clients? @relation(fields: [oauth_client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) - users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + id String @id @db.Uuid + user_id String @db.Uuid + created_at DateTime? @db.Timestamptz(6) + updated_at DateTime? @db.Timestamptz(6) + factor_id String? @db.Uuid + aal aal_level? + not_after DateTime? @db.Timestamptz(6) + refreshed_at DateTime? @db.Timestamp(6) + user_agent String? + ip String? @db.Inet + tag String? + oauth_client_id String? @db.Uuid + refresh_token_hmac_key String? + refresh_token_counter BigInt? + scopes String? + mfa_amr_claims mfa_amr_claims[] + refresh_tokens refresh_tokens[] + oauth_clients oauth_clients? @relation(fields: [oauth_client_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + users auth_users @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction) @@index([not_after(sort: Desc)]) @@index([oauth_client_id]) @@ -397,41 +403,57 @@ model auth_users { } model Comment { - id String @id @default(uuid()) - userId String @db.Uuid - ratingId String? - postId String? - content String - createdAt DateTime @default(now()) - parentId String? - parent_comment Comment? @relation("CommentToComment", fields: [parentId], references: [id], onDelete: SetNull) - child_comment Comment[] @relation("CommentToComment") - Post Post? @relation(fields: [postId], references: [id]) - Rating Rating? @relation(fields: [ratingId], references: [id]) - UserProfile UserProfile @relation(fields: [userId], references: [userId]) + id String @id + userId String @db.Uuid + postId String? + content String + createdAt DateTime @default(now()) + parentId String? + Comment Comment? @relation("CommentToComment", fields: [parentId], references: [id]) + other_Comment Comment[] @relation("CommentToComment") + Post Post? @relation(fields: [postId], references: [id]) + UserProfile UserProfile @relation(fields: [userId], references: [userId]) + CommentLike CommentLike[] + + @@schema("public") +} + +model CommentLike { + id String @id + commentId String + userId String @db.Uuid + createdAt DateTime @default(now()) + Comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade) + UserProfile UserProfile @relation(fields: [userId], references: [userId]) + @@unique([commentId, userId]) @@schema("public") } model Post { - id String @id @default(uuid()) - userId String @db.Uuid - content String - type PostType - createdAt DateTime @default(now()) - imageUrls String[] @default([]) - parentPostId String? // For threading/replies - Comment Comment[] - UserProfile UserProfile @relation(fields: [userId], references: [userId]) - PostReaction PostReaction[] // Track individual reactions - ParentPost Post? @relation("PostReplies", fields: [parentPostId], references: [id], onDelete: SetNull) - Replies Post[] @relation("PostReplies") + id String @id + userId String @db.Uuid + movieId String + content String + type PostType + stars Int? + spoiler Boolean @default(false) + tags String[] @default([]) + createdAt DateTime @default(now()) + imageUrls String[] @default([]) + repostedPostId String? + Comment Comment[] + movie movie @relation(fields: [movieId], references: [movieId]) + Post Post? @relation("PostToPost", fields: [repostedPostId], references: [id]) + other_Post Post[] @relation("PostToPost") + UserProfile UserProfile @relation(fields: [userId], references: [userId]) + PostReaction PostReaction[] @@schema("public") } model PostReaction { - id String @id @default(uuid()) + id String @id postId String userId String @db.Uuid reactionType ReactionType @@ -444,17 +466,14 @@ model PostReaction { } model Rating { - id String @id @default(uuid()) - userId String @db.Uuid - movieId String - stars Int - comment String? - tags String[] @default([]) - date DateTime - votes Int @default(0) - Comment Comment[] - UserProfile UserProfile @relation(fields: [userId], references: [userId]) - movie movie @relation(fields: [movieId], references: [movieId]) + id String @id + userId String @db.Uuid + movieId String + stars Int + comment String? + tags String[] @default([]) + date DateTime + votes Int @default(0) @@schema("public") } @@ -479,17 +498,27 @@ model UserProfile { profilePicture String? country String? city String? + displayName String? favoriteGenres String[] @default([]) favoriteMovies String[] @default([]) + privateAccount Boolean @default(false) + spoiler Boolean @default(false) + bio String? + moviesToWatch String[] @default([]) + moviesCompleted String[] @default([]) + eventsSaved String[] @default([]) + eventsAttended String[] @default([]) createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + updatedAt DateTime Comment Comment[] + CommentLike CommentLike[] Post Post[] PostReaction PostReaction[] - Rating Rating[] UserFollow_UserFollow_followerIdToUserProfile UserFollow[] @relation("UserFollow_followerIdToUserProfile") UserFollow_UserFollow_followingIdToUserProfile UserFollow[] @relation("UserFollow_followingIdToUserProfile") event_rsvp event_rsvp[] + bookmarkedToWatch String[] @default([]) + bookmarkedWatched String[] @default([]) @@schema("public") } @@ -503,8 +532,22 @@ model bootcamp { @@schema("public") } +model event_rsvp { + id String @id @db.Uuid + eventId String @db.Uuid + userId String @db.Uuid + status String + createdAt DateTime @default(now()) + updatedAt DateTime + local_event local_event @relation(fields: [eventId], references: [id], onDelete: Cascade) + UserProfile UserProfile @relation(fields: [userId], references: [userId], onDelete: Cascade) + + @@unique([eventId, userId]) + @@schema("public") +} + model local_event { - id String @id(map: "local_events_pkey") @default(uuid()) @db.Uuid + id String @id(map: "local_events_pkey") @db.Uuid title String time DateTime? @default(dbgenerated("(now() AT TIME ZONE 'utc'::text)")) @db.Timestamptz(6) description String @@ -520,20 +563,6 @@ model local_event { @@schema("public") } -model event_rsvp { - id String @id @default(uuid()) @db.Uuid - eventId String @db.Uuid - userId String @db.Uuid - status String // 'yes', 'maybe', 'no' - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - local_event local_event @relation(fields: [eventId], references: [id], onDelete: Cascade) - UserProfile UserProfile @relation(fields: [userId], references: [userId], onDelete: Cascade) - - @@unique([eventId, userId]) - @@schema("public") -} - model movie { movieId String @id localRating String? @@ -545,7 +574,7 @@ model movie { imageUrl String? releaseYear Int? director String? - Rating Rating[] + Post Post[] @@schema("public") } @@ -637,10 +666,10 @@ enum PostType { } enum ReactionType { - SPICY // 🌶️ Drama-filled, bold, or full of tension - STAR_STUDDED // ✨ Packed with A-listers - THOUGHT_PROVOKING // 🧠 Thought-provoking/mind blowing - BLOCKBUSTER // 🧨 Mega-hit with hype, memes, and madness + SPICY + STAR_STUDDED + THOUGHT_PROVOKING + BLOCKBUSTER @@schema("public") } diff --git a/backend/prisma/seed.sql b/backend/prisma/seed.sql index 5fd0e5cf..d510b466 100644 --- a/backend/prisma/seed.sql +++ b/backend/prisma/seed.sql @@ -42,21 +42,111 @@ INSERT INTO "public"."UserProfile" ( "profilePicture", "country", "city", + "displayName", "favoriteGenres", "favoriteMovies", + "privateAccount", + "spoiler", + "bio", + "eventsSaved", + "eventsAttended", "createdAt", - "updatedAt" + "updatedAt", + "bookmarkedToWatch", + "bookmarkedWatched" +) VALUES + ('11111111-1111-1111-1111-111111111111', 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', ARRAY['Drama', 'Thriller'], ARRAY['tt0111161', 'tt0068646'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0073486', 'tt0099685']), + ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', ARRAY['Action', 'Sci-Fi'], ARRAY['tt0468569', 'tt0137523'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0468569', 'tt0137523']), + ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', ARRAY['Comedy', 'Romance'], ARRAY['tt0109830', 'tt1375666'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0109830', 'tt1375666']), + ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', ARRAY['Drama', 'Biography'], ARRAY['tt0073486', 'tt0099685'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0073486', 'tt0099685']), + ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', ARRAY['Animation', 'Fantasy'], ARRAY['tt0245429', 'tt1853728'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0245429', 'tt1853728']), + ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', ARRAY['Horror', 'Mystery'], ARRAY['tt0816692', 'tt0110912'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0816692', 'tt0110912']), + ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', ARRAY['Western', 'Crime'], ARRAY['tt0076759', 'tt0050083'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0076759', 'tt0050083']), + ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', ARRAY['Drama', 'Thriller'], ARRAY['tt6751668', 'tt0167260'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt6751668', 'tt0167260']), + ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', ARRAY['Independent', 'Documentary'], ARRAY['tt0114369', 'tt0120737'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0114369', 'tt0120737']), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', ARRAY['Drama', 'Romance'], ARRAY['tt0133093', 'tt0088763'], NOW(), NOW(), ARRAY[]::text[], ARRAY['tt0133093', 'tt0088763']) + "spoiler" ) VALUES - ('11111111-1111-1111-1111-111111111111', 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', ARRAY['Drama', 'Thriller'], ARRAY['tt0111161', 'tt0068646'], NOW(), NOW()), - ('22222222-2222-2222-2222-222222222222', 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', ARRAY['Action', 'Sci-Fi'], ARRAY['tt0468569', 'tt0137523'], NOW(), NOW()), - ('33333333-3333-3333-3333-333333333333', 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', ARRAY['Comedy', 'Romance'], ARRAY['tt0109830', 'tt1375666'], NOW(), NOW()), - ('44444444-4444-4444-4444-444444444444', 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', ARRAY['Drama', 'Biography'], ARRAY['tt0073486', 'tt0099685'], NOW(), NOW()), - ('55555555-5555-5555-5555-555555555555', 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', ARRAY['Animation', 'Fantasy'], ARRAY['tt0245429', 'tt1853728'], NOW(), NOW()), - ('66666666-6666-6666-6666-666666666666', 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', ARRAY['Horror', 'Mystery'], ARRAY['tt0816692', 'tt0110912'], NOW(), NOW()), - ('77777777-7777-7777-7777-777777777777', 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', ARRAY['Western', 'Crime'], ARRAY['tt0076759', 'tt0050083'], NOW(), NOW()), - ('88888888-8888-8888-8888-888888888888', 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', ARRAY['Drama', 'Thriller'], ARRAY['tt6751668', 'tt0167260'], NOW(), NOW()), - ('99999999-9999-9999-9999-999999999999', 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', ARRAY['Independent', 'Documentary'], ARRAY['tt0114369', 'tt0120737'], NOW(), NOW()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', ARRAY['Drama', 'Romance'], ARRAY['tt0133093', 'tt0088763'], NOW(), NOW()) + ('11111111-1111-1111-1111-111111111111', + 'alice_movie_fan', true, 'English', ARRAY['Spanish'], NULL, 'USA', 'New York', + 'Alice', ARRAY['Drama','Thriller'], ARRAY['tt0111161','tt0068646'], + false, false, NULL, + ARRAY['e1111111-1111-1111-1111-111111111111','e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('22222222-2222-2222-2222-222222222222', + 'bob_cineaste', true, 'English', ARRAY['French'], NULL, 'USA', 'Los Angeles', + 'Bob', ARRAY['Action','Sci-Fi'], ARRAY['tt0468569','tt0137523'], + false, false, NULL, + ARRAY['e2222222-2222-2222-2222-222222222222','e3333333-3333-3333-3333-333333333333'], + ARRAY['e1111111-1111-1111-1111-111111111111','e2222222-2222-2222-2222-222222222222'], + NOW(), NOW() + ), + ('33333333-3333-3333-3333-333333333333', + 'charlie_critic', true, 'English', ARRAY[]::text[], NULL, 'Canada', 'Toronto', + 'Charlie', ARRAY['Comedy','Romance'], ARRAY['tt0109830','tt1375666'], + false, false, NULL, + ARRAY['e3333333-3333-3333-3333-333333333333'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333'], + NOW(), NOW() + ), + ('44444444-4444-4444-4444-444444444444', + 'diana_director', true, 'English', ARRAY['Italian'], NULL, 'Italy', 'Rome', + 'Diana', ARRAY['Drama','Biography'], ARRAY['tt0073486','tt0099685'], + false, false, NULL, + ARRAY['e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('55555555-5555-5555-5555-555555555555', + 'evan_enthusiast', true, 'English', ARRAY['Japanese'], NULL, 'USA', 'San Francisco', + 'Evan', ARRAY['Animation','Fantasy'], ARRAY['tt0245429','tt1853728'], + false, false, NULL, + ARRAY['e1111111-1111-1111-1111-111111111111','e5555555-5555-5555-5555-555555555555'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('66666666-6666-6666-6666-666666666666', + 'fiona_film_buff', true, 'English', ARRAY['German'], NULL, 'Germany', 'Berlin', + 'Fiona', ARRAY['Horror','Mystery'], ARRAY['tt0816692','tt0110912'], + false, false, NULL, + ARRAY['e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], + ARRAY['e1111111-1111-1111-1111-111111111111','e3333333-3333-3333-3333-333333333333'], + NOW(), NOW() + ), + ('77777777-7777-7777-7777-777777777777', + 'george_genre_fan', true, 'English', ARRAY[]::text[], NULL, 'USA', 'Chicago', + 'George', ARRAY['Western','Crime'], ARRAY['tt0076759','tt0050083'], + false, false, NULL, + ARRAY['e2222222-2222-2222-2222-222222222222','e5555555-5555-5555-5555-555555555555'], + ARRAY['e3333333-3333-3333-3333-333333333333','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('88888888-8888-8888-8888-888888888888', + 'hannah_hollywood', true, 'English', ARRAY['Korean'], NULL, 'South Korea', 'Seoul', + 'Hannah', ARRAY['Drama','Thriller'], ARRAY['tt6751668','tt0167260'], + false, false, NULL, + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e1111111-1111-1111-1111-111111111111','e4444444-4444-4444-4444-444444444444','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('99999999-9999-9999-9999-999999999999', + 'isaac_indie', true, 'English', ARRAY[]::text[], NULL, 'UK', 'London', + 'Isaac', ARRAY['Independent','Documentary'], ARRAY['tt0114369','tt0120737'], + false, false, NULL, + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e4444444-4444-4444-4444-444444444444','e5555555-5555-5555-5555-555555555555'], + NOW(), NOW() + ), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'julia_junkie', true, 'English', ARRAY['Portuguese'], NULL, 'Brazil', 'São Paulo', + 'Julia', ARRAY['Drama','Romance'], ARRAY['tt0133093','tt0088763'], + false, false, NULL, + ARRAY['e2222222-2222-2222-2222-222222222222','e4444444-4444-4444-4444-444444444444'], + ARRAY['e4444444-4444-4444-4444-444444444444'], + NOW(), NOW() + ) ON CONFLICT ("userId") DO NOTHING; -- ============================================ @@ -175,45 +265,68 @@ ON CONFLICT ("id") DO NOTHING; INSERT INTO "public"."Post" ( "id", "userId", + "movieId", "content", "type", + "stars", + "spoiler", + "tags", "createdAt", "imageUrls", - "parentPostId" + "repostedPostId" ) VALUES -- Text posts - ('p1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'Just watched Shawshank Redemption again. Never gets old! 🎬', 'SHORT', NOW() - INTERVAL '2 days', '{}', NULL), - ('p2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'The Dark Knight is a masterpiece of modern cinema. Christopher Nolan''s direction, Heath Ledger''s performance, and the moral complexity make it unforgettable.', 'LONG', NOW() - INTERVAL '5 days', '{}', NULL), - ('p3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'Anyone else think Inception is overrated?', 'SHORT', NOW() - INTERVAL '1 day', '{}', NULL), - ('p4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'Film analysis: The use of color grading in The Godfather to represent moral decay is absolutely brilliant. Each scene''s palette tells its own story.', 'LONG', NOW() - INTERVAL '7 days', '{}', NULL), - ('p5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'Spirited Away on the big screen tonight! Can''t wait! 🍿', 'SHORT', NOW() - INTERVAL '3 hours', '{}', NULL), - ('p6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'Just discovered the indie film scene. Why didn''t anyone tell me about this sooner?!', 'SHORT', NOW() - INTERVAL '12 hours', '{}', NULL), - ('p7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'Hot take: Fight Club aged poorly', 'SHORT', NOW() - INTERVAL '4 days', '{}', NULL), - ('p8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'Parasite winning Best Picture was a watershed moment for international cinema. It opened doors and changed perceptions about what mainstream audiences can appreciate.', 'LONG', NOW() - INTERVAL '10 days', '{}', NULL), - ('p9999999-9999-9999-9999-999999999999', '99999999-9999-9999-9999-999999999999', 'Documentary recommendations anyone?', 'SHORT', NOW() - INTERVAL '6 hours', '{}', NULL), - ('paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'The Matrix revolutionized action choreography and visual effects. Its influence can still be seen in films today, 25 years later.', 'LONG', NOW() - INTERVAL '8 days', '{}', NULL), - ('pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'Going to the Summer Film Festival next month. Who''s in?', 'SHORT', NOW() - INTERVAL '1 hour', '{}', NULL), - ('pcccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'Rewatching LOTR trilogy this weekend. Epic! 🧙‍♂️', 'SHORT', NOW() - INTERVAL '2 hours', '{}', NULL), - ('pdddddd-dddd-dddd-dddd-dddddddddddd', '33333333-3333-3333-3333-333333333333', 'Pulp Fiction: nonlinear storytelling at its finest', 'SHORT', NOW() - INTERVAL '3 days', '{}', NULL), - ('peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '44444444-4444-4444-4444-444444444444', 'The cinematography in Interstellar is breathtaking. IMAX made all the difference.', 'SHORT', NOW() - INTERVAL '6 days', '{}', NULL), - ('pffffff-ffff-ffff-ffff-ffffffffffff', '55555555-5555-5555-5555-555555555555', 'Studio Ghibli marathon happening! Starting with Totoro 🌳', 'SHORT', NOW() - INTERVAL '4 hours', '{}', NULL), - ('pgggggg-gggg-gggg-gggg-gggggggggggg', '66666666-6666-6666-6666-666666666666', 'Se7en still holds up as one of the best thriller endings ever', 'SHORT', NOW() - INTERVAL '5 days', '{}', NULL), - ('phhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '77777777-7777-7777-7777-777777777777', 'Star Wars original trilogy > prequels > sequels. Fight me.', 'SHORT', NOW() - INTERVAL '8 hours', '{}', NULL), - ('piiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '88888888-8888-8888-8888-888888888888', 'Back to the Future is the perfect time travel movie. Everything about it works.', 'SHORT', NOW() - INTERVAL '9 days', '{}', NULL), - ('pjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', '99999999-9999-9999-9999-999999999999', 'Film noir appreciation post: Double Indemnity, The Maltese Falcon, Touch of Evil. The shadows, the dialogue, the moral ambiguity - this genre defined cinema.', 'LONG', NOW() - INTERVAL '11 days', '{}', NULL), - ('pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '12 Angry Men with just one room and phenomenal acting 👏', 'SHORT', NOW() - INTERVAL '5 hours', '{}', NULL), + ('p1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'tt0111161', 'Just watched Shawshank Redemption again. Never gets old! 🎬', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '2 days', '{}', NULL), + ('p2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'tt0468569', 'The Dark Knight is a masterpiece of modern cinema. Christopher Nolan''s direction, Heath Ledger''s performance, and the moral complexity make it unforgettable.', 'LONG', 10, false, ARRAY['action', 'dark'], NOW() - INTERVAL '5 days', '{}', NULL), + ('p3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'tt1375666', 'Anyone else think Inception is overrated?', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '1 day', '{}', NULL), + ('p4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'tt0068646', 'Film analysis: The use of color grading in The Godfather to represent moral decay is absolutely brilliant. Each scene''s palette tells its own story.', 'LONG', 10, false, ARRAY['crime', 'drama'], NOW() - INTERVAL '7 days', '{}', NULL), + ('p5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'tt0245429', 'Spirited Away on the big screen tonight! Can''t wait! 🍿', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '3 hours', '{}', NULL), + ('p6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'tt0114369', 'Just discovered the indie film scene. Why didn''t anyone tell me about this sooner?!', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '12 hours', '{}', NULL), + ('p7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'tt0137523', 'Hot take: Fight Club aged poorly', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '4 days', '{}', NULL), + ('p8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'tt6751668', 'Parasite winning Best Picture was a watershed moment for international cinema. It opened doors and changed perceptions about what mainstream audiences can appreciate.', 'LONG', 10, false, ARRAY['thriller', 'drama'], NOW() - INTERVAL '10 days', '{}', NULL), + ('p9999999-9999-9999-9999-999999999999', '99999999-9999-9999-9999-999999999999', 'tt0114369', 'Documentary recommendations anyone?', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '6 hours', '{}', NULL), + ('paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0133093', 'The Matrix revolutionized action choreography and visual effects. Its influence can still be seen in films today, 25 years later.', 'LONG', 10, false, ARRAY['action', 'fantasy'], NOW() - INTERVAL '8 days', '{}', NULL), + ('pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'tt0111161', 'Going to the Summer Film Festival next month. Who''s in?', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '1 hour', '{}', NULL), + ('pcccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'tt0167260', 'Rewatching LOTR trilogy this weekend. Epic! 🧙‍♂️', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '2 hours', '{}', NULL), + ('pdddddd-dddd-dddd-dddd-dddddddddddd', '33333333-3333-3333-3333-333333333333', 'tt0110912', 'Pulp Fiction: nonlinear storytelling at its finest', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '3 days', '{}', NULL), + ('peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '44444444-4444-4444-4444-444444444444', 'tt0816692', 'The cinematography in Interstellar is breathtaking. IMAX made all the difference.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '6 days', '{}', NULL), + ('pffffff-ffff-ffff-ffff-ffffffffffff', '55555555-5555-5555-5555-555555555555', 'tt0245429', 'Studio Ghibli marathon happening! Starting with Totoro 🌳', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '4 hours', '{}', NULL), + ('pgggggg-gggg-gggg-gggg-gggggggggggg', '66666666-6666-6666-6666-666666666666', 'tt0114369', 'Se7en still holds up as one of the best thriller endings ever', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '5 days', '{}', NULL), + ('phhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '77777777-7777-7777-7777-777777777777', 'tt0076759', 'Star Wars original trilogy > prequels > sequels. Fight me.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '8 hours', '{}', NULL), + ('piiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '88888888-8888-8888-8888-888888888888', 'tt0088763', 'Back to the Future is the perfect time travel movie. Everything about it works.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '9 days', '{}', NULL), + ('pjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', '99999999-9999-9999-9999-999999999999', 'tt0114369', 'Film noir appreciation post: Double Indemnity, The Maltese Falcon, Touch of Evil. The shadows, the dialogue, the moral ambiguity - this genre defined cinema.', 'LONG', 9, false, ARRAY['classic', 'noir'], NOW() - INTERVAL '11 days', '{}', NULL), + ('pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0050083', '12 Angry Men with just one room and phenomenal acting 👏', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '5 hours', '{}', NULL), -- Picture posts with image URLs (some with multiple images for carousel) - ('plllllll-llll-llll-llll-llllllllllll', '11111111-1111-1111-1111-111111111111', 'My home theater setup for movie night! 🎥', 'SHORT', NOW() - INTERVAL '10 hours', ARRAY['https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800'], NULL), - ('pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '22222222-2222-2222-2222-222222222222', 'Found this incredible vintage poster at the flea market today!', 'SHORT', NOW() - INTERVAL '15 hours', ARRAY['https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), - ('pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '33333333-3333-3333-3333-333333333333', 'Cinema architecture is art 🎭', 'SHORT', NOW() - INTERVAL '1 day', ARRAY['https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), - ('pooooooo-oooo-oooo-oooo-oooooooooooo', '44444444-4444-4444-4444-444444444444', 'My growing Criterion Collection 📚', 'SHORT', NOW() - INTERVAL '2 days', ARRAY['https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800'], NULL), - ('ppppppp-pppp-pppp-pppp-pppppppppppp', '55555555-5555-5555-5555-555555555555', 'Best seat in the house for tonight''s screening!', 'SHORT', NOW() - INTERVAL '30 minutes', ARRAY['https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800', 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800', 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), - - -- Reply posts (threads) - ('pqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '22222222-2222-2222-2222-222222222222', 'Totally agree! That scene gives me chills', 'SHORT', NOW() - INTERVAL '1 day', '{}', 'p1111111-1111-1111-1111-111111111111'), - ('prrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '33333333-3333-3333-3333-333333333333', 'You clearly didn''t understand it then', 'SHORT', NOW() - INTERVAL '20 hours', '{}', 'p3333333-3333-3333-3333-333333333333'), - ('pssssss-ssss-ssss-ssss-ssssssssssss', '44444444-4444-4444-4444-444444444444', 'Me! Been waiting for this all year', 'SHORT', NOW() - INTERVAL '45 minutes', '{}', 'pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb') + ('plllllll-llll-llll-llll-llllllllllll', '11111111-1111-1111-1111-111111111111', 'tt0111161', 'My home theater setup for movie night! 🎥', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '10 hours', ARRAY['https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800'], NULL), + ('pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '22222222-2222-2222-2222-222222222222', 'tt0468569', 'Found this incredible vintage poster at the flea market today!', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '15 hours', ARRAY['https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), + ('pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '33333333-3333-3333-3333-333333333333', 'tt0110912', 'Cinema architecture is art 🎭', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '1 day', ARRAY['https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), + ('pooooooo-oooo-oooo-oooo-oooooooooooo', '44444444-4444-4444-4444-444444444444', 'tt0068646', 'My growing Criterion Collection 📚', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '2 days', ARRAY['https://images.unsplash.com/photo-1536440136628-849c177e76a1?w=800'], NULL), + ('ppppppp-pppp-pppp-pppp-pppppppppppp', '55555555-5555-5555-5555-555555555555', 'tt0245429', 'Best seat in the house for tonight''s screening!', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '30 minutes', ARRAY['https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800', 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800', 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1598899134739-24c46f58b8c0?w=800'], NULL), + + -- Reposts (users sharing others' posts with optional commentary) + ('pqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '22222222-2222-2222-2222-222222222222', 'tt0111161', 'Couldn''t have said it better! Everyone needs to watch this classic.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '1 day', '{}', 'p1111111-1111-1111-1111-111111111111'), + ('prrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '33333333-3333-3333-3333-333333333333', 'tt1375666', 'This take is so accurate. Inception really stands the test of time.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '20 hours', '{}', 'p3333333-3333-3333-3333-333333333333'), + ('pssssss-ssss-ssss-ssss-ssssssssssss', '44444444-4444-4444-4444-444444444444', 'tt0111161', 'Count me in! Summer film festivals are the best way to see classics.', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '45 minutes', '{}', 'pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'), + + -- Additional posts for tt0111161 (Shawshank) to show all post type variations + -- SHORT post with single image + ('pttttttt-tttt-tttt-tttt-tttttttttttt', '33333333-3333-3333-3333-333333333333', 'tt0111161', 'The poster on Andy''s wall that changed everything 🖼️', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '3 hours', ARRAY['https://images.unsplash.com/photo-1594908900066-3f47337549d8?w=800'], NULL), + + -- SHORT post with image only (minimal text/emoji only) + ('puuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', '44444444-4444-4444-4444-444444444444', 'tt0111161', '🎬✨', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '5 hours', ARRAY['https://images.unsplash.com/photo-1440404653325-ab127d49abc1?w=800'], NULL), + + -- LONG post (review) WITH stars + ('pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '55555555-5555-5555-5555-555555555555', 'tt0111161', 'A masterclass in storytelling. The Shawshank Redemption is more than just a prison drama—it''s a testament to hope and friendship. Tim Robbins and Morgan Freeman deliver career-defining performances. The cinematography captures both the bleakness and beauty of the human spirit.', 'LONG', 10, false, ARRAY['inspirational', 'drama'], NOW() - INTERVAL '2 days', '{}', NULL), + + -- LONG post WITHOUT stars (detailed analysis/discussion) + ('pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '66666666-6666-6666-6666-666666666666', 'tt0111161', 'The symbolism in Shawshank is incredible. The rock hammer represents patience and persistence, Rita Hayworth symbolizes hope and escape, and the library renovation shows how knowledge liberates. Every frame has meaning. This is why it remains the top-rated film on IMDb.', 'LONG', NULL, false, ARRAY['thought-provoking'], NOW() - INTERVAL '1 day', '{}', NULL), + + -- SHORT post with multiple images (carousel) - different content + ('pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '77777777-7777-7777-7777-777777777777', 'tt0111161', 'Best scenes from my favorite movie ever! Can''t pick just one 🎥', 'SHORT', NULL, false, '{}', NOW() - INTERVAL '8 hours', ARRAY['https://images.unsplash.com/photo-1485846234645-a62644f84728?w=800', 'https://images.unsplash.com/photo-1489599849927-2ee91cede3ba?w=800', 'https://images.unsplash.com/photo-1478720568477-152d9b164e26?w=800'], NULL), + + -- LONG post with spoiler tag + ('pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '88888888-8888-8888-8888-888888888888', 'tt0111161', 'The ending reveal where we learn Andy has been planning his escape for years while appearing to have accepted his fate is brilliant. The slow reveal of the poster, the discovery of the empty cell, and Red''s realization of what Andy accomplished is peak cinema storytelling.', 'LONG', 10, true, ARRAY['suspense', 'emotional'], NOW() - INTERVAL '6 hours', '{}', NULL) ON CONFLICT ("id") DO NOTHING; -- ============================================ @@ -485,8 +598,8 @@ INSERT INTO "public"."PostReaction" ( ('l000000p-000p-000p-000p-000000000009', 'ppppppp-pppp-pppp-pppp-pppppppppppp', '99999999-9999-9999-9999-999999999999', 'SPICY', NOW() - INTERVAL '20 minutes'), ('l000000p-000p-000p-000p-000000000010', 'ppppppp-pppp-pppp-pppp-pppppppppppp', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'BLOCKBUSTER', NOW() - INTERVAL '20 minutes'), - -- Reply posts - -- pqqqqqqq: 3 reactions (Reply about Shawshank) + -- Reposts + -- pqqqqqqq: 3 reactions (Repost of Shawshank post) ('l000000q-000q-000q-000q-000000000001', 'pqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '33333333-3333-3333-3333-333333333333', 'THOUGHT_PROVOKING', NOW() - INTERVAL '23 hours'), ('l000000q-000q-000q-000q-000000000002', 'pqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '44444444-4444-4444-4444-444444444444', 'SPICY', NOW() - INTERVAL '23 hours'), ('l000000q-000q-000q-000q-000000000003', 'pqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '55555555-5555-5555-5555-555555555555', 'BLOCKBUSTER', NOW() - INTERVAL '23 hours'), @@ -499,51 +612,101 @@ INSERT INTO "public"."PostReaction" ( ('l000000s-000s-000s-000s-000000000002', 'pssssss-ssss-ssss-ssss-ssssssssssss', '22222222-2222-2222-2222-222222222222', 'BLOCKBUSTER', NOW() - INTERVAL '40 minutes'), ('l000000s-000s-000s-000s-000000000003', 'pssssss-ssss-ssss-ssss-ssssssssssss', '33333333-3333-3333-3333-333333333333', 'STAR_STUDDED', NOW() - INTERVAL '40 minutes'), ('l000000s-000s-000s-000s-000000000004', 'pssssss-ssss-ssss-ssss-ssssssssssss', '55555555-5555-5555-5555-555555555555', 'SPICY', NOW() - INTERVAL '40 minutes'), - ('l000000s-000s-000s-000s-000000000005', 'pssssss-ssss-ssss-ssss-ssssssssssss', '66666666-6666-6666-6666-666666666666', 'BLOCKBUSTER', NOW() - INTERVAL '40 minutes') + ('l000000s-000s-000s-000s-000000000005', 'pssssss-ssss-ssss-ssss-ssssssssssss', '66666666-6666-6666-6666-666666666666', 'BLOCKBUSTER', NOW() - INTERVAL '40 minutes'), + + -- Additional Shawshank posts reactions + -- pttttttt: 4 reactions (Single image post about poster) + ('l000000t-000t-000t-000t-000000000001', 'pttttttt-tttt-tttt-tttt-tttttttttttt', '11111111-1111-1111-1111-111111111111', 'THOUGHT_PROVOKING', NOW() - INTERVAL '2 hours'), + ('l000000t-000t-000t-000t-000000000002', 'pttttttt-tttt-tttt-tttt-tttttttttttt', '22222222-2222-2222-2222-222222222222', 'SPICY', NOW() - INTERVAL '2 hours'), + ('l000000t-000t-000t-000t-000000000003', 'pttttttt-tttt-tttt-tttt-tttttttttttt', '55555555-5555-5555-5555-555555555555', 'BLOCKBUSTER', NOW() - INTERVAL '2 hours'), + ('l000000t-000t-000t-000t-000000000004', 'pttttttt-tttt-tttt-tttt-tttttttttttt', '66666666-6666-6666-6666-666666666666', 'STAR_STUDDED', NOW() - INTERVAL '2 hours'), + + -- puuuuuuu: 2 reactions (Image only post with emojis) + ('l000000u-000u-000u-000u-000000000001', 'puuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', '22222222-2222-2222-2222-222222222222', 'STAR_STUDDED', NOW() - INTERVAL '4 hours'), + ('l000000u-000u-000u-000u-000000000002', 'puuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', '77777777-7777-7777-7777-777777777777', 'BLOCKBUSTER', NOW() - INTERVAL '4 hours'), + + -- pvvvvvvv: 8 reactions (LONG review WITH stars) + ('l000000v-000v-000v-000v-000000000001', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '11111111-1111-1111-1111-111111111111', 'THOUGHT_PROVOKING', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000002', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '22222222-2222-2222-2222-222222222222', 'BLOCKBUSTER', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000003', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '33333333-3333-3333-3333-333333333333', 'STAR_STUDDED', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000004', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '44444444-4444-4444-4444-444444444444', 'THOUGHT_PROVOKING', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000005', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '66666666-6666-6666-6666-666666666666', 'BLOCKBUSTER', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000006', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '77777777-7777-7777-7777-777777777777', 'SPICY', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000007', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '88888888-8888-8888-8888-888888888888', 'STAR_STUDDED', NOW() - INTERVAL '1 day'), + ('l000000v-000v-000v-000v-000000000008', 'pvvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '99999999-9999-9999-9999-999999999999', 'THOUGHT_PROVOKING', NOW() - INTERVAL '1 day'), + + -- pwwwwwww: 6 reactions (LONG post WITHOUT stars - analysis) + ('l000000w-000w-000w-000w-000000000001', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '11111111-1111-1111-1111-111111111111', 'THOUGHT_PROVOKING', NOW() - INTERVAL '20 hours'), + ('l000000w-000w-000w-000w-000000000002', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '22222222-2222-2222-2222-222222222222', 'THOUGHT_PROVOKING', NOW() - INTERVAL '20 hours'), + ('l000000w-000w-000w-000w-000000000003', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '44444444-4444-4444-4444-444444444444', 'SPICY', NOW() - INTERVAL '20 hours'), + ('l000000w-000w-000w-000w-000000000004', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '55555555-5555-5555-5555-555555555555', 'THOUGHT_PROVOKING', NOW() - INTERVAL '20 hours'), + ('l000000w-000w-000w-000w-000000000005', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '77777777-7777-7777-7777-777777777777', 'BLOCKBUSTER', NOW() - INTERVAL '20 hours'), + ('l000000w-000w-000w-000w-000000000006', 'pwwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'THOUGHT_PROVOKING', NOW() - INTERVAL '20 hours'), + + -- pxxxxxxx: 5 reactions (Multiple images carousel) + ('l000000x-000x-000x-000x-000000000001', 'pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '11111111-1111-1111-1111-111111111111', 'STAR_STUDDED', NOW() - INTERVAL '7 hours'), + ('l000000x-000x-000x-000x-000000000002', 'pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '22222222-2222-2222-2222-222222222222', 'BLOCKBUSTER', NOW() - INTERVAL '7 hours'), + ('l000000x-000x-000x-000x-000000000003', 'pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '33333333-3333-3333-3333-333333333333', 'SPICY', NOW() - INTERVAL '7 hours'), + ('l000000x-000x-000x-000x-000000000004', 'pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '55555555-5555-5555-5555-555555555555', 'STAR_STUDDED', NOW() - INTERVAL '7 hours'), + ('l000000x-000x-000x-000x-000000000005', 'pxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '66666666-6666-6666-6666-666666666666', 'BLOCKBUSTER', NOW() - INTERVAL '7 hours'), + + -- pyyyyyyy: 7 reactions (LONG post WITH spoiler tag) + ('l000000y-000y-000y-000y-000000000001', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '11111111-1111-1111-1111-111111111111', 'THOUGHT_PROVOKING', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000002', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '22222222-2222-2222-2222-222222222222', 'SPICY', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000003', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '33333333-3333-3333-3333-333333333333', 'BLOCKBUSTER', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000004', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '44444444-4444-4444-4444-444444444444', 'THOUGHT_PROVOKING', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000005', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '55555555-5555-5555-5555-555555555555', 'STAR_STUDDED', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000006', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '77777777-7777-7777-7777-777777777777', 'THOUGHT_PROVOKING', NOW() - INTERVAL '5 hours'), + ('l000000y-000y-000y-000y-000000000007', 'pyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '99999999-9999-9999-9999-999999999999', 'BLOCKBUSTER', NOW() - INTERVAL '5 hours') ON CONFLICT ("id") DO NOTHING; -- ============================================ --- Ratings +-- Long Posts -- ============================================ -INSERT INTO "public"."Rating" ( +-- Movie Reviews as LONG Posts (converted from old Rating model) +INSERT INTO "public"."Post" ( "id", "userId", "movieId", + "content", + "type", "stars", - "comment", + "spoiler", "tags", - "date" + "createdAt", + "imageUrls", + "repostedPostId" ) VALUES - ('r1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'tt0111161', 5, 'Absolute masterpiece. Tim Robbins and Morgan Freeman are perfect.', ARRAY['inspirational', 'emotional'], NOW() - INTERVAL '10 days'), - ('r2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'tt0468569', 5, 'Heath Ledger''s Joker is iconic. Best superhero movie ever made.', ARRAY['dark', 'intense', 'action-packed'], NOW() - INTERVAL '8 days'), - ('r3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'tt1375666', 4, 'Mind-bending but sometimes confusing. Still worth multiple watches.', ARRAY['complex', 'visually-stunning'], NOW() - INTERVAL '5 days'), - ('r4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'tt0068646', 5, 'The definitive gangster film. Brando''s performance is legendary.', ARRAY['classic', 'violent', 'epic'], NOW() - INTERVAL '15 days'), - ('r5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'tt0245429', 5, 'Miyazaki''s imagination knows no bounds. Beautiful and touching.', ARRAY['magical', 'heartwarming', 'animated'], NOW() - INTERVAL '3 days'), - ('r6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'tt0114369', 5, 'Fincher at his best. Dark, twisted, unforgettable ending.', ARRAY['dark', 'psychological', 'thriller'], NOW() - INTERVAL '12 days'), - ('r7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'tt0076759', 5, 'Changed cinema forever. A true cultural phenomenon.', ARRAY['sci-fi', 'adventure', 'iconic'], NOW() - INTERVAL '20 days'), - ('r8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'tt6751668', 5, 'Brilliant social commentary. Every scene is meticulously crafted.', ARRAY['thought-provoking', 'tense', 'satirical'], NOW() - INTERVAL '7 days'), - ('r9999999-9999-9999-9999-999999999999', '99999999-9999-9999-9999-999999999999', 'tt0110912', 5, 'Tarantino''s best work. Dialogue is sharp, structure is perfect.', ARRAY['stylish', 'violent', 'quotable'], NOW() - INTERVAL '18 days'), - ('raaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0133093', 5, 'Revolutionary special effects. The philosophical questions still resonate.', ARRAY['philosophical', 'action', 'groundbreaking'], NOW() - INTERVAL '6 days'), - ('rbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'tt0109830', 5, 'Tom Hanks gives the performance of a lifetime. Emotional journey.', ARRAY['heartwarming', 'emotional', 'inspiring'], NOW() - INTERVAL '14 days'), - ('rcccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'tt0137523', 4, 'Thought-provoking critique of consumerism. Twist is great.', ARRAY['psychological', 'violent', 'subversive'], NOW() - INTERVAL '11 days'), - ('rdddddd-dddd-dddd-dddd-dddddddddddd', '33333333-3333-3333-3333-333333333333', 'tt0816692', 5, 'Christopher Nolan''s most ambitious film. Visually spectacular.', ARRAY['epic', 'emotional', 'sci-fi'], NOW() - INTERVAL '4 days'), - ('reeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '44444444-4444-4444-4444-444444444444', 'tt0099685', 5, 'Scorsese''s masterclass in storytelling. Ray Liotta is perfect.', ARRAY['gangster', 'violent', 'fast-paced'], NOW() - INTERVAL '16 days'), - ('rfffffff-ffff-ffff-ffff-ffffffffffff', '55555555-5555-5555-5555-555555555555', 'tt1853728', 4, 'Tarantino''s revisionist Western. Entertaining but long.', ARRAY['western', 'violent', 'stylized'], NOW() - INTERVAL '9 days'), - ('rgggggg-gggg-gggg-gggg-gggggggggggg', '66666666-6666-6666-6666-666666666666', 'tt0073486', 5, 'Jack Nicholson''s best performance. Powerful and disturbing.', ARRAY['psychological', 'rebellious', 'classic'], NOW() - INTERVAL '13 days'), - ('rhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '77777777-7777-7777-7777-777777777777', 'tt0167260', 5, 'The perfect conclusion to an epic trilogy. Extended edition is a must.', ARRAY['epic', 'fantasy', 'emotional'], NOW() - INTERVAL '22 days'), - ('riiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '88888888-8888-8888-8888-888888888888', 'tt0120737', 5, 'Peter Jackson brought Tolkien''s world to life perfectly.', ARRAY['adventure', 'fantasy', 'epic'], NOW() - INTERVAL '21 days'), - ('rjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', '99999999-9999-9999-9999-999999999999', 'tt0088763', 5, 'The most fun time travel movie ever. Perfect blend of comedy and adventure.', ARRAY['fun', 'nostalgic', 'adventure'], NOW() - INTERVAL '17 days'), - ('rkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0050083', 5, 'Timeless courtroom drama. All takes place in one room but riveting.', ARRAY['tense', 'dialogue-driven', 'classic'], NOW() - INTERVAL '19 days'), - ('rllllll-llll-llll-llll-llllllllllll', '11111111-1111-1111-1111-111111111111', 'tt0068646', 5, 'An offer you can''t refuse. This film is perfection.', ARRAY['classic', 'family-saga'], NOW() - INTERVAL '25 days'), - ('rmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '22222222-2222-2222-2222-222222222222', 'tt0111161', 5, 'Hope is a powerful thing. This movie proves it.', ARRAY['hopeful', 'friendship'], NOW() - INTERVAL '23 days'), - ('rnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '33333333-3333-3333-3333-333333333333', 'tt0245429', 4, 'Beautiful animation but felt long at times.', ARRAY['animated', 'imaginative'], NOW() - INTERVAL '2 days'), - ('rooooooo-oooo-oooo-oooo-oooooooooooo', '44444444-4444-4444-4444-444444444444', 'tt0137523', 5, 'His name was Robert Paulson. Unforgettable.', ARRAY['cult-classic', 'mind-bending'], NOW() - INTERVAL '26 days'), - ('rpppppp-pppp-pppp-pppp-pppppppppppp', '55555555-5555-5555-5555-555555555555', 'tt0076759', 5, 'May the Force be with you. Always.', ARRAY['iconic', 'space-opera'], NOW() - INTERVAL '24 days'), - ('rqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '66666666-6666-6666-6666-666666666666', 'tt0109830', 4, 'Life is like a box of chocolates... sweet but predictable at times.', ARRAY['feel-good', 'heartwarming'], NOW() - INTERVAL '27 days'), - ('rrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '77777777-7777-7777-7777-777777777777', 'tt0110912', 5, 'English, do you speak it?! Iconic quotes galore.', ARRAY['quotable', 'violent'], NOW() - INTERVAL '28 days'), - ('rsssssss-ssss-ssss-ssss-ssssssssssss', '88888888-8888-8888-8888-888888888888', 'tt0133093', 4, 'Take the red pill. Mind-blowing when it came out.', ARRAY['sci-fi', 'philosophical'], NOW() - INTERVAL '29 days'), - ('rttttttt-tttt-tttt-tttt-tttttttttttt', '99999999-9999-9999-9999-999999999999', 'tt1375666', 5, 'We need to go deeper. Every layer is fascinating.', ARRAY['complex', 'thriller'], NOW() - INTERVAL '30 days'), - ('ruuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt6751668', 5, 'The stairs scene alone is worth the price of admission.', ARRAY['social-commentary', 'thriller'], NOW() - INTERVAL '31 days') + ('r1111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', 'tt0111161', 'Absolute masterpiece. Tim Robbins and Morgan Freeman are perfect.', 'LONG', 10, false, ARRAY['inspirational', 'emotional'], NOW() - INTERVAL '10 days', ARRAY[]::text[], NULL), + ('r2222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', 'tt0468569', 'Heath Ledger''s Joker is iconic. Best superhero movie ever made.', 'LONG', 10, false, ARRAY['dark', 'action'], NOW() - INTERVAL '8 days', ARRAY[]::text[], NULL), + ('r3333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', 'tt1375666', 'Mind-bending but sometimes confusing. Still worth multiple watches.', 'LONG', 8, false, ARRAY['thriller'], NOW() - INTERVAL '5 days', ARRAY[]::text[], NULL), + ('r4444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', 'tt0068646', 'The definitive gangster film. Brando''s performance is legendary.', 'LONG', 10, false, ARRAY['crime', 'drama'], NOW() - INTERVAL '15 days', ARRAY[]::text[], NULL), + ('r5555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', 'tt0245429', 'Miyazaki''s imagination knows no bounds. Beautiful and touching.', 'LONG', 10, false, ARRAY['fantasy', 'family'], NOW() - INTERVAL '3 days', ARRAY[]::text[], NULL), + ('r6666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666', 'tt0114369', 'Fincher at his best. Dark, twisted, unforgettable ending.', 'LONG', 10, true, ARRAY['thriller', 'mystery'], NOW() - INTERVAL '12 days', ARRAY[]::text[], NULL), + ('r7777777-7777-7777-7777-777777777777', '77777777-7777-7777-7777-777777777777', 'tt0076759', 'Changed cinema forever. A true cultural phenomenon.', 'LONG', 10, false, ARRAY['fantasy', 'action'], NOW() - INTERVAL '20 days', ARRAY[]::text[], NULL), + ('r8888888-8888-8888-8888-888888888888', '88888888-8888-8888-8888-888888888888', 'tt6751668', 'Brilliant social commentary. Every scene is meticulously crafted.', 'LONG', 10, false, ARRAY['thriller', 'drama'], NOW() - INTERVAL '7 days', ARRAY[]::text[], NULL), + ('r9999999-9999-9999-9999-999999999999', '99999999-9999-9999-9999-999999999999', 'tt0110912', 'Tarantino''s best work. Dialogue is sharp, structure is perfect.', 'LONG', 10, false, ARRAY['crime', 'thriller'], NOW() - INTERVAL '18 days', ARRAY[]::text[], NULL), + ('raaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0133093', 'Revolutionary special effects. The philosophical questions still resonate.', 'LONG', 10, false, ARRAY['action', 'fantasy'], NOW() - INTERVAL '6 days', ARRAY[]::text[], NULL), + ('rbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '11111111-1111-1111-1111-111111111111', 'tt0109830', 'Tom Hanks gives the performance of a lifetime. Emotional journey.', 'LONG', 10, false, ARRAY['drama', 'romance'], NOW() - INTERVAL '14 days', ARRAY[]::text[], NULL), + ('rcccccc-cccc-cccc-cccc-cccccccccccc', '22222222-2222-2222-2222-222222222222', 'tt0137523', 'Thought-provoking critique of consumerism. Twist is great.', 'LONG', 8, true, ARRAY['thriller', 'drama'], NOW() - INTERVAL '11 days', ARRAY[]::text[], NULL), + ('rdddddd-dddd-dddd-dddd-dddddddddddd', '33333333-3333-3333-3333-333333333333', 'tt0816692', 'Christopher Nolan''s most ambitious film. Visually spectacular.', 'LONG', 10, false, ARRAY['drama', 'fantasy'], NOW() - INTERVAL '4 days', ARRAY[]::text[], NULL), + ('reeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '44444444-4444-4444-4444-444444444444', 'tt0099685', 'Scorsese''s masterclass in storytelling. Ray Liotta is perfect.', 'LONG', 10, false, ARRAY['crime', 'drama'], NOW() - INTERVAL '16 days', ARRAY[]::text[], NULL), + ('rfffffff-ffff-ffff-ffff-ffffffffffff', '55555555-5555-5555-5555-555555555555', 'tt1853728', 'Tarantino''s revisionist Western. Entertaining but long.', 'LONG', 8, false, ARRAY['drama'], NOW() - INTERVAL '9 days', ARRAY[]::text[], NULL), + ('rgggggg-gggg-gggg-gggg-gggggggggggg', '66666666-6666-6666-6666-666666666666', 'tt0073486', 'Jack Nicholson''s best performance. Powerful and disturbing.', 'LONG', 10, false, ARRAY['drama'], NOW() - INTERVAL '13 days', ARRAY[]::text[], NULL), + ('rhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '77777777-7777-7777-7777-777777777777', 'tt0167260', 'The perfect conclusion to an epic trilogy. Extended edition is a must.', 'LONG', 10, false, ARRAY['fantasy', 'action'], NOW() - INTERVAL '22 days', ARRAY[]::text[], NULL), + ('riiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '88888888-8888-8888-8888-888888888888', 'tt0120737', 'Peter Jackson brought Tolkien''s world to life perfectly.', 'LONG', 10, false, ARRAY['fantasy', 'action'], NOW() - INTERVAL '21 days', ARRAY[]::text[], NULL), + ('rjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', '99999999-9999-9999-9999-999999999999', 'tt0088763', 'The most fun time travel movie ever. Perfect blend of comedy and adventure.', 'LONG', 10, false, ARRAY['comedy', 'fantasy'], NOW() - INTERVAL '17 days', ARRAY[]::text[], NULL), + ('rkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt0050083', 'Timeless courtroom drama. All takes place in one room but riveting.', 'LONG', 10, false, ARRAY['drama'], NOW() - INTERVAL '19 days', ARRAY[]::text[], NULL), + ('rllllll-llll-llll-llll-llllllllllll', '11111111-1111-1111-1111-111111111111', 'tt0068646', 'An offer you can''t refuse. This film is perfection.', 'LONG', 10, false, ARRAY['crime', 'drama'], NOW() - INTERVAL '25 days', ARRAY[]::text[], NULL), + ('rmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '22222222-2222-2222-2222-222222222222', 'tt0111161', 'Hope is a powerful thing. This movie proves it.', 'LONG', 10, false, ARRAY['drama'], NOW() - INTERVAL '23 days', ARRAY[]::text[], NULL), + ('rnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '33333333-3333-3333-3333-333333333333', 'tt0245429', 'Beautiful animation but felt long at times.', 'LONG', 8, false, ARRAY['fantasy', 'family'], NOW() - INTERVAL '2 days', ARRAY[]::text[], NULL), + ('rooooooo-oooo-oooo-oooo-oooooooooooo', '44444444-4444-4444-4444-444444444444', 'tt0137523', 'His name was Robert Paulson. Unforgettable.', 'LONG', 10, true, ARRAY['thriller', 'drama'], NOW() - INTERVAL '26 days', ARRAY[]::text[], NULL), + ('rpppppp-pppp-pppp-pppp-pppppppppppp', '55555555-5555-5555-5555-555555555555', 'tt0076759', 'May the Force be with you. Always.', 'LONG', 10, false, ARRAY['fantasy', 'action'], NOW() - INTERVAL '24 days', ARRAY[]::text[], NULL), + ('rqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '66666666-6666-6666-6666-666666666666', 'tt0109830', 'Life is like a box of chocolates... sweet but predictable at times.', 'LONG', 8, false, ARRAY['drama', 'romance'], NOW() - INTERVAL '27 days', ARRAY[]::text[], NULL), + ('rrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '77777777-7777-7777-7777-777777777777', 'tt0110912', 'English, do you speak it?! Iconic quotes galore.', 'LONG', 10, false, ARRAY['crime', 'thriller'], NOW() - INTERVAL '28 days', ARRAY[]::text[], NULL), + ('rsssssss-ssss-ssss-ssss-ssssssssssss', '88888888-8888-8888-8888-888888888888', 'tt0133093', 'Take the red pill. Mind-blowing when it came out.', 'LONG', 8, false, ARRAY['action', 'fantasy'], NOW() - INTERVAL '29 days', ARRAY[]::text[], NULL), + ('rttttttt-tttt-tttt-tttt-tttttttttttt', '99999999-9999-9999-9999-999999999999', 'tt1375666', 'We need to go deeper. Every layer is fascinating.', 'LONG', 10, false, ARRAY['thriller', 'fantasy'], NOW() - INTERVAL '30 days', ARRAY[]::text[], NULL), + ('ruuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'tt6751668', 'The stairs scene alone is worth the price of admission.', 'LONG', 10, false, ARRAY['thriller', 'drama'], NOW() - INTERVAL '31 days', ARRAY[]::text[], NULL) ON CONFLICT ("id") DO NOTHING; -- ============================================ @@ -578,86 +741,84 @@ INSERT INTO "public"."Comment" ( "id", "userId", "postId", - "ratingId", "content", "createdAt" ) VALUES - ('c1111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', 'p1111111-1111-1111-1111-111111111111', NULL, 'Totally agree! One of the best films ever made.', NOW() - INTERVAL '1 day'), - ('c2222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333', 'p1111111-1111-1111-1111-111111111111', NULL, 'Morgan Freeman''s narration is iconic', NOW() - INTERVAL '1 day'), - ('c3333333-3333-3333-3333-333333333333', '44444444-4444-4444-4444-444444444444', 'p2222222-2222-2222-2222-222222222222', NULL, 'Heath Ledger deserved that Oscar', NOW() - INTERVAL '4 days'), - ('c4444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555', 'p3333333-3333-3333-3333-333333333333', NULL, 'Disagree! It''s a masterpiece', NOW() - INTERVAL '20 hours'), - ('c5555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'p3333333-3333-3333-3333-333333333333', NULL, 'The ending alone makes it worth it', NOW() - INTERVAL '18 hours'), - ('c6666666-6666-6666-6666-666666666666', '77777777-7777-7777-7777-777777777777', 'p4444444-4444-4444-4444-444444444444', NULL, 'Great analysis! Never noticed that before', NOW() - INTERVAL '6 days'), - ('c7777777-7777-7777-7777-777777777777', '88888888-8888-8888-8888-888888888888', 'p5555555-5555-5555-5555-555555555555', NULL, 'Have fun! It''s magical on the big screen', NOW() - INTERVAL '2 hours'), - ('c8888888-8888-8888-8888-888888888888', '99999999-9999-9999-9999-999999999999', 'p8888888-8888-8888-8888-888888888888', NULL, 'Couldn''t agree more. Historic moment for cinema', NOW() - INTERVAL '9 days'), - ('c9999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'p9999999-9999-9999-9999-999999999999', NULL, 'Check out "13th" and "Won''t You Be My Neighbor"', NOW() - INTERVAL '5 hours'), - ('caaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', NULL, 'Count me in! 🎬', NOW() - INTERVAL '30 minutes'), - ('cbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', NULL, 'r1111111-1111-1111-1111-111111111111', 'The tunnel scene gives me chills every time', NOW() - INTERVAL '9 days'), - ('ccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', NULL, 'r2222222-2222-2222-2222-222222222222', 'RIP Heath Ledger. Gone too soon.', NOW() - INTERVAL '7 days'), - ('cdddddd-dddd-dddd-dddd-dddddddddddd', '44444444-4444-4444-4444-444444444444', NULL, 'r5555555-5555-5555-5555-555555555555', 'Studio Ghibli never misses', NOW() - INTERVAL '2 days'), - ('ceeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '55555555-5555-5555-5555-555555555555', NULL, 'r8888888-8888-8888-8888-888888888888', 'The stairs metaphor is brilliant', NOW() - INTERVAL '6 days'), - ('cfffffff-ffff-ffff-ffff-ffffffffffff', '66666666-6666-6666-6666-666666666666', NULL, 'r9999999-9999-9999-9999-999999999999', 'Royale with cheese 🍔', NOW() - INTERVAL '17 days'), - ('cgggggg-gggg-gggg-gggg-gggggggggggg', '77777777-7777-7777-7777-777777777777', 'pcccccc-cccc-cccc-cccc-cccccccccccc', NULL, 'Extended editions or theatrical?', NOW() - INTERVAL '1 hour'), - ('chhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '88888888-8888-8888-8888-888888888888', 'phhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', NULL, 'Original trilogy forever ⭐', NOW() - INTERVAL '7 hours'), - ('ciiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '99999999-9999-9999-9999-999999999999', NULL, 'rhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', 'The Battle of Pelennor Fields is cinema at its finest', NOW() - INTERVAL '21 days'), - ('cjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'rjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', 'Great Scott! This movie is perfect', NOW() - INTERVAL '16 days'), - ('ckkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', '11111111-1111-1111-1111-111111111111', 'piiiiii-iiii-iiii-iiii-iiiiiiiiiiii', NULL, '1.21 gigawatts!', NOW() - INTERVAL '8 days'), - ('clllllll-llll-llll-llll-llllllllllll', '22222222-2222-2222-2222-222222222222', 'pjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', NULL, 'Great picks! Also check out The Big Sleep', NOW() - INTERVAL '10 days'), - ('cmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '33333333-3333-3333-3333-333333333333', NULL, 'raaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'The Wachowskis were ahead of their time', NOW() - INTERVAL '5 days'), - ('cnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '44444444-4444-4444-4444-444444444444', NULL, 'rdddddd-dddd-dddd-dddd-dddddddddddd', 'That docking scene with the music 🎵', NOW() - INTERVAL '3 days'), - ('cooooooo-oooo-oooo-oooo-oooooooooooo', '55555555-5555-5555-5555-555555555555', 'pgggggg-gggg-gggg-gggg-gggggggggggg', NULL, 'What''s in the box?!', NOW() - INTERVAL '4 days'), - ('cpppppp-pppp-pppp-pppp-pppppppppppp', '66666666-6666-6666-6666-666666666666', NULL, 'rllllll-llll-llll-llll-llllllllllll', 'Leave the gun, take the cannoli', NOW() - INTERVAL '24 days'), + ('c1111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', 'p1111111-1111-1111-1111-111111111111', 'Totally agree! One of the best films ever made.', NOW() - INTERVAL '1 day'), + ('c2222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333', 'p1111111-1111-1111-1111-111111111111', 'Morgan Freeman''s narration is iconic', NOW() - INTERVAL '1 day'), + ('c3333333-3333-3333-3333-333333333333', '44444444-4444-4444-4444-444444444444', 'p2222222-2222-2222-2222-222222222222', 'Heath Ledger deserved that Oscar', NOW() - INTERVAL '4 days'), + ('c4444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555', 'p3333333-3333-3333-3333-333333333333', 'Disagree! It''s a masterpiece', NOW() - INTERVAL '20 hours'), + ('c5555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'p3333333-3333-3333-3333-333333333333', 'The ending alone makes it worth it', NOW() - INTERVAL '18 hours'), + ('c6666666-6666-6666-6666-666666666666', '77777777-7777-7777-7777-777777777777', 'p4444444-4444-4444-4444-444444444444', 'Great analysis! Never noticed that before', NOW() - INTERVAL '6 days'), + ('c7777777-7777-7777-7777-777777777777', '88888888-8888-8888-8888-888888888888', 'p5555555-5555-5555-5555-555555555555', 'Have fun! It''s magical on the big screen', NOW() - INTERVAL '2 hours'), + ('c8888888-8888-8888-8888-888888888888', '99999999-9999-9999-9999-999999999999', 'p8888888-8888-8888-8888-888888888888', 'Couldn''t agree more. Historic moment for cinema', NOW() - INTERVAL '9 days'), + ('c9999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'p9999999-9999-9999-9999-999999999999', 'Check out "13th" and "Won''t You Be My Neighbor"', NOW() - INTERVAL '5 hours'), + ('caaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'pbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Count me in! 🎬', NOW() - INTERVAL '30 minutes'), + ('cbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', 'r1111111-1111-1111-1111-111111111111', 'The tunnel scene gives me chills every time', NOW() - INTERVAL '9 days'), + ('ccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', 'r2222222-2222-2222-2222-222222222222', 'RIP Heath Ledger. Gone too soon.', NOW() - INTERVAL '7 days'), + ('cdddddd-dddd-dddd-dddd-dddddddddddd', '44444444-4444-4444-4444-444444444444', 'r5555555-5555-5555-5555-555555555555', 'Studio Ghibli never misses', NOW() - INTERVAL '2 days'), + ('ceeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', '55555555-5555-5555-5555-555555555555', 'r8888888-8888-8888-8888-888888888888', 'The stairs metaphor is brilliant', NOW() - INTERVAL '6 days'), + ('cfffffff-ffff-ffff-ffff-ffffffffffff', '66666666-6666-6666-6666-666666666666', 'r9999999-9999-9999-9999-999999999999', 'Royale with cheese 🍔', NOW() - INTERVAL '17 days'), + ('cgggggg-gggg-gggg-gggg-gggggggggggg', '77777777-7777-7777-7777-777777777777', 'pcccccc-cccc-cccc-cccc-cccccccccccc', 'Extended editions or theatrical?', NOW() - INTERVAL '1 hour'), + ('chhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', '88888888-8888-8888-8888-888888888888', 'phhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', 'Original trilogy forever ⭐', NOW() - INTERVAL '7 hours'), + ('ciiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '99999999-9999-9999-9999-999999999999', 'rhhhhh-hhhh-hhhh-hhhh-hhhhhhhhhhhh', 'The Battle of Pelennor Fields is cinema at its finest', NOW() - INTERVAL '21 days'), + ('cjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'rjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', 'Great Scott! This movie is perfect', NOW() - INTERVAL '16 days'), + ('ckkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', '11111111-1111-1111-1111-111111111111', 'piiiiii-iiii-iiii-iiii-iiiiiiiiiiii', '1.21 gigawatts!', NOW() - INTERVAL '8 days'), + ('clllllll-llll-llll-llll-llllllllllll', '22222222-2222-2222-2222-222222222222', 'pjjjjjj-jjjj-jjjj-jjjj-jjjjjjjjjjjj', 'Great picks! Also check out The Big Sleep', NOW() - INTERVAL '10 days'), + ('cmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', '33333333-3333-3333-3333-333333333333', 'raaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'The Wachowskis were ahead of their time', NOW() - INTERVAL '5 days'), + ('cnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', '44444444-4444-4444-4444-444444444444', 'rdddddd-dddd-dddd-dddd-dddddddddddd', 'That docking scene with the music 🎵', NOW() - INTERVAL '3 days'), + ('cooooooo-oooo-oooo-oooo-oooooooooooo', '55555555-5555-5555-5555-555555555555', 'pgggggg-gggg-gggg-gggg-gggggggggggg', 'What''s in the box?!', NOW() - INTERVAL '4 days'), + ('cpppppp-pppp-pppp-pppp-pppppppppppp', '66666666-6666-6666-6666-666666666666', 'rllllll-llll-llll-llll-llllllllllll', 'Leave the gun, take the cannoli', NOW() - INTERVAL '24 days'), -- Additional comments for better coverage - ('cqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '11111111-1111-1111-1111-111111111111', 'p6666666-6666-6666-6666-666666666666', NULL, 'Welcome to the indie world! So much hidden talent here', NOW() - INTERVAL '11 hours'), - ('crrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '22222222-2222-2222-2222-222222222222', 'p6666666-6666-6666-6666-666666666666', NULL, 'Check out A24 films, they''re amazing!', NOW() - INTERVAL '10 hours'), - ('cssssss-ssss-ssss-ssss-ssssssssssss', '33333333-3333-3333-3333-333333333333', 'p6666666-6666-6666-6666-666666666666', NULL, 'Indie films have the best storytelling', NOW() - INTERVAL '9 hours'), + ('cqqqqqqq-qqqq-qqqq-qqqq-qqqqqqqqqqqq', '11111111-1111-1111-1111-111111111111', 'p6666666-6666-6666-6666-666666666666', 'Welcome to the indie world! So much hidden talent here', NOW() - INTERVAL '11 hours'), + ('crrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr', '22222222-2222-2222-2222-222222222222', 'p6666666-6666-6666-6666-666666666666', 'Check out A24 films, they''re amazing!', NOW() - INTERVAL '10 hours'), + ('cssssss-ssss-ssss-ssss-ssssssssssss', '33333333-3333-3333-3333-333333333333', 'p6666666-6666-6666-6666-666666666666', 'Indie films have the best storytelling', NOW() - INTERVAL '9 hours'), - ('cttttttt-tttt-tttt-tttt-tttttttttttt', '44444444-4444-4444-4444-444444444444', 'p7777777-7777-7777-7777-777777777777', NULL, 'Strong disagree! It''s still relevant', NOW() - INTERVAL '3 days'), - ('cuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', '55555555-5555-5555-5555-555555555555', 'p7777777-7777-7777-7777-777777777777', NULL, 'The twist still holds up though', NOW() - INTERVAL '3 days'), + ('cttttttt-tttt-tttt-tttt-tttttttttttt', '44444444-4444-4444-4444-444444444444', 'p7777777-7777-7777-7777-777777777777', 'Strong disagree! It''s still relevant', NOW() - INTERVAL '3 days'), + ('cuuuuuu-uuuu-uuuu-uuuu-uuuuuuuuuuuu', '55555555-5555-5555-5555-555555555555', 'p7777777-7777-7777-7777-777777777777', 'The twist still holds up though', NOW() - INTERVAL '3 days'), - ('cvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '66666666-6666-6666-6666-666666666666', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'Bullet time was revolutionary', NOW() - INTERVAL '7 days'), - ('cwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '77777777-7777-7777-7777-777777777777', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'The philosophy still resonates today', NOW() - INTERVAL '7 days'), - ('cxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '88888888-8888-8888-8888-888888888888', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', NULL, 'Red pill or blue pill? 💊', NOW() - INTERVAL '7 days'), + ('cvvvvvv-vvvv-vvvv-vvvv-vvvvvvvvvvvv', '66666666-6666-6666-6666-666666666666', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Bullet time was revolutionary', NOW() - INTERVAL '7 days'), + ('cwwwwww-wwww-wwww-wwww-wwwwwwwwwwww', '77777777-7777-7777-7777-777777777777', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'The philosophy still resonates today', NOW() - INTERVAL '7 days'), + ('cxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', '88888888-8888-8888-8888-888888888888', 'paaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Red pill or blue pill? 💊', NOW() - INTERVAL '7 days'), - ('cyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '99999999-9999-9999-9999-999999999999', 'pdddddd-dddd-dddd-dddd-dddddddddddd', NULL, 'Tarantino is a genius with structure', NOW() - INTERVAL '2 days'), - ('czzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pdddddd-dddd-dddd-dddd-dddddddddddd', NULL, 'The diner scene is perfect', NOW() - INTERVAL '2 days'), + ('cyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', '99999999-9999-9999-9999-999999999999', 'pdddddd-dddd-dddd-dddd-dddddddddddd', 'Tarantino is a genius with structure', NOW() - INTERVAL '2 days'), + ('czzzzzz-zzzz-zzzz-zzzz-zzzzzzzzzzzz', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pdddddd-dddd-dddd-dddd-dddddddddddd', 'The diner scene is perfect', NOW() - INTERVAL '2 days'), - ('c000000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', NULL, 'Seeing it in IMAX was life-changing', NOW() - INTERVAL '5 days'), - ('c111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', NULL, 'That black hole sequence 🤯', NOW() - INTERVAL '5 days'), - ('c222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', NULL, 'Hans Zimmer''s score is incredible', NOW() - INTERVAL '5 days'), + ('c000000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'Seeing it in IMAX was life-changing', NOW() - INTERVAL '5 days'), + ('c111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'That black hole sequence 🤯', NOW() - INTERVAL '5 days'), + ('c222222-2222-2222-2222-222222222222', '33333333-3333-3333-3333-333333333333', 'peeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'Hans Zimmer''s score is incredible', NOW() - INTERVAL '5 days'), - ('c333333-3333-3333-3333-333333333333', '44444444-4444-4444-4444-444444444444', 'pffffff-ffff-ffff-ffff-ffffffffffff', NULL, 'Totoro is the best! 🌳', NOW() - INTERVAL '3 hours'), - ('c444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555', 'pffffff-ffff-ffff-ffff-ffffffffffff', NULL, 'Miyazaki marathons are the best', NOW() - INTERVAL '3 hours'), + ('c333333-3333-3333-3333-333333333333', '44444444-4444-4444-4444-444444444444', 'pffffff-ffff-ffff-ffff-ffffffffffff', 'Totoro is the best! 🌳', NOW() - INTERVAL '3 hours'), + ('c444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555', 'pffffff-ffff-ffff-ffff-ffffffffffff', 'Miyazaki marathons are the best', NOW() - INTERVAL '3 hours'), - ('c555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', NULL, 'Single room, pure acting perfection', NOW() - INTERVAL '4 hours'), - ('c666666-6666-6666-6666-666666666666', '77777777-7777-7777-7777-777777777777', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', NULL, 'Every character has depth', NOW() - INTERVAL '4 hours'), - ('c777777-7777-7777-7777-777777777777', '88888888-8888-8888-8888-888888888888', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', NULL, 'Peak courtroom drama', NOW() - INTERVAL '4 hours'), + ('c555555-5555-5555-5555-555555555555', '66666666-6666-6666-6666-666666666666', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'Single room, pure acting perfection', NOW() - INTERVAL '4 hours'), + ('c666666-6666-6666-6666-666666666666', '77777777-7777-7777-7777-777777777777', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'Every character has depth', NOW() - INTERVAL '4 hours'), + ('c777777-7777-7777-7777-777777777777', '88888888-8888-8888-8888-888888888888', 'pkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkkk', 'Peak courtroom drama', NOW() - INTERVAL '4 hours'), - ('c888888-8888-8888-8888-888888888888', '99999999-9999-9999-9999-999999999999', 'plllllll-llll-llll-llll-llllllllllll', NULL, 'Nice setup! What projector?', NOW() - INTERVAL '9 hours'), - ('c999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'plllllll-llll-llll-llll-llllllllllll', NULL, 'Goals! 🎬', NOW() - INTERVAL '8 hours'), - ('caa0000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'plllllll-llll-llll-llll-llllllllllll', NULL, 'That screen size though 😍', NOW() - INTERVAL '8 hours'), + ('c888888-8888-8888-8888-888888888888', '99999999-9999-9999-9999-999999999999', 'plllllll-llll-llll-llll-llllllllllll', 'Nice setup! What projector?', NOW() - INTERVAL '9 hours'), + ('c999999-9999-9999-9999-999999999999', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'plllllll-llll-llll-llll-llllllllllll', 'Goals! 🎬', NOW() - INTERVAL '8 hours'), + ('caa0000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'plllllll-llll-llll-llll-llllllllllll', 'That screen size though 😍', NOW() - INTERVAL '8 hours'), - ('cbb0000-0000-0000-0000-000000000000', '22222222-2222-2222-2222-222222222222', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', NULL, 'What a find! How much?', NOW() - INTERVAL '14 hours'), - ('ccc0000-0000-0000-0000-000000000000', '33333333-3333-3333-3333-333333333333', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', NULL, 'Vintage posters are so cool', NOW() - INTERVAL '13 hours'), - ('cdd0000-0000-0000-0000-000000000000', '44444444-4444-4444-4444-444444444444', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', NULL, 'I need to hit up flea markets more', NOW() - INTERVAL '13 hours'), - ('cee0000-0000-0000-0000-000000000000', '55555555-5555-5555-5555-555555555555', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', NULL, 'Gorgeous artwork!', NOW() - INTERVAL '12 hours'), + ('cbb0000-0000-0000-0000-000000000000', '22222222-2222-2222-2222-222222222222', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', 'What a find! How much?', NOW() - INTERVAL '14 hours'), + ('ccc0000-0000-0000-0000-000000000000', '33333333-3333-3333-3333-333333333333', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', 'Vintage posters are so cool', NOW() - INTERVAL '13 hours'), + ('cdd0000-0000-0000-0000-000000000000', '44444444-4444-4444-4444-444444444444', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', 'I need to hit up flea markets more', NOW() - INTERVAL '13 hours'), + ('cee0000-0000-0000-0000-000000000000', '55555555-5555-5555-5555-555555555555', 'pmmmmmmm-mmmm-mmmm-mmmm-mmmmmmmmmmmm', 'Gorgeous artwork!', NOW() - INTERVAL '12 hours'), - ('cff0000-0000-0000-0000-000000000000', '66666666-6666-6666-6666-666666666666', 'pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', NULL, 'Old theaters have so much character', NOW() - INTERVAL '22 hours'), - ('cgg0000-0000-0000-0000-000000000000', '77777777-7777-7777-7777-777777777777', 'pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', NULL, 'The architecture is stunning', NOW() - INTERVAL '21 hours'), + ('cff0000-0000-0000-0000-000000000000', '66666666-6666-6666-6666-666666666666', 'pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', 'Old theaters have so much character', NOW() - INTERVAL '22 hours'), + ('cgg0000-0000-0000-0000-000000000000', '77777777-7777-7777-7777-777777777777', 'pnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnnn', 'The architecture is stunning', NOW() - INTERVAL '21 hours'), - ('chh0000-0000-0000-0000-000000000000', '88888888-8888-8888-8888-888888888888', 'pooooooo-oooo-oooo-oooo-oooooooooooo', NULL, 'Criterion Collection is life 📚', NOW() - INTERVAL '1 day'), - ('cii0000-0000-0000-0000-000000000000', '99999999-9999-9999-9999-999999999999', 'pooooooo-oooo-oooo-oooo-oooooooooooo', NULL, 'Which titles do you have?', NOW() - INTERVAL '1 day'), - ('cjj0000-0000-0000-0000-000000000000', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pooooooo-oooo-oooo-oooo-oooooooooooo', NULL, 'My wallet hurts looking at this 😅', NOW() - INTERVAL '1 day'), - ('ckk0000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'pooooooo-oooo-oooo-oooo-oooooooooooo', NULL, 'Beautiful collection!', NOW() - INTERVAL '1 day'), + ('chh0000-0000-0000-0000-000000000000', '88888888-8888-8888-8888-888888888888', 'pooooooo-oooo-oooo-oooo-oooooooooooo', 'Criterion Collection is life 📚', NOW() - INTERVAL '1 day'), + ('cii0000-0000-0000-0000-000000000000', '99999999-9999-9999-9999-999999999999', 'pooooooo-oooo-oooo-oooo-oooooooooooo', 'Which titles do you have?', NOW() - INTERVAL '1 day'), + ('cjj0000-0000-0000-0000-000000000000', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'pooooooo-oooo-oooo-oooo-oooooooooooo', 'My wallet hurts looking at this 😅', NOW() - INTERVAL '1 day'), + ('ckk0000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'pooooooo-oooo-oooo-oooo-oooooooooooo', 'Beautiful collection!', NOW() - INTERVAL '1 day'), - ('cll0000-0000-0000-0000-000000000000', '22222222-2222-2222-2222-222222222222', 'ppppppp-pppp-pppp-pppp-pppppppppppp', NULL, 'Perfect spot! Enjoy the film', NOW() - INTERVAL '25 minutes'), - ('cmm0000-0000-0000-0000-000000000000', '33333333-3333-3333-3333-333333333333', 'ppppppp-pppp-pppp-pppp-pppppppppppp', NULL, 'Center seats are always the best', NOW() - INTERVAL '20 minutes'), - ('cnn0000-0000-0000-0000-000000000000', '44444444-4444-4444-4444-444444444444', 'ppppppp-pppp-pppp-pppp-pppppppppppp', NULL, 'What movie are you seeing?', NOW() - INTERVAL '18 minutes') + ('cll0000-0000-0000-0000-000000000000', '22222222-2222-2222-2222-222222222222', 'ppppppp-pppp-pppp-pppp-pppppppppppp', 'Perfect spot! Enjoy the film', NOW() - INTERVAL '25 minutes'), + ('cmm0000-0000-0000-0000-000000000000', '33333333-3333-3333-3333-333333333333', 'ppppppp-pppp-pppp-pppp-pppppppppppp', 'Center seats are always the best', NOW() - INTERVAL '20 minutes'), + ('cnn0000-0000-0000-0000-000000000000', '44444444-4444-4444-4444-444444444444', 'ppppppp-pppp-pppp-pppp-pppppppppppp', 'What movie are you seeing?', NOW() - INTERVAL '18 minutes') ON CONFLICT ("id") DO NOTHING; -- ============================================ -- Success Message -- ============================================ SELECT 'Seed data inserted successfully!' as message; - diff --git a/backend/scripts/import-tmdb-ids.ts b/backend/scripts/import-tmdb-ids.ts new file mode 100644 index 00000000..9d177abf --- /dev/null +++ b/backend/scripts/import-tmdb-ids.ts @@ -0,0 +1,102 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const BACKEND_URL = 'http://localhost:3001'; +const TMDB_IDS_FILE = path.join(__dirname, 'tmdb_ids.json'); + +interface ImportResult { + tmdbId: number; + success: boolean; + error?: string; +} + +async function importMovie(tmdbId: number): Promise { + try { + const response = await fetch(`${BACKEND_URL}/movies/${tmdbId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + tmdbId, + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + const data = await response.json(); + console.log(`✅ Successfully imported movie: ${data.data?.title || tmdbId}`); + return { tmdbId, success: true }; + } catch (error) { + return { + tmdbId, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +async function importAllMovies() { + console.log('🎬 Starting TMDB movie import...\n'); + + // Read TMDB IDs from JSON file + const tmdbIds: number[] = JSON.parse(fs.readFileSync(TMDB_IDS_FILE, 'utf-8')); + console.log(`📋 Found ${tmdbIds.length} TMDB IDs to import\n`); + + const results: ImportResult[] = []; + let successCount = 0; + let failCount = 0; + + // Import movies sequentially to avoid rate limiting + for (let i = 0; i < tmdbIds.length; i++) { + const tmdbId = tmdbIds[i]; + console.log(`[${i + 1}/${tmdbIds.length}] Importing TMDB ID: ${tmdbId}...`); + + const result = await importMovie(tmdbId); + results.push(result); + + if (result.success) { + successCount++; + } else { + failCount++; + console.error(`❌ Failed to import ${tmdbId}: ${result.error}`); + } + + // Add a small delay to avoid overwhelming the server + if (i < tmdbIds.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + // Print summary + console.log('\n' + '='.repeat(50)); + console.log('📊 Import Summary:'); + console.log('='.repeat(50)); + console.log(`✅ Successful imports: ${successCount}`); + console.log(`❌ Failed imports: ${failCount}`); + console.log(`📝 Total processed: ${tmdbIds.length}`); + console.log('='.repeat(50) + '\n'); + + // Print failed imports if any + const failedImports = results.filter(r => !r.success); + if (failedImports.length > 0) { + console.log('Failed TMDB IDs:'); + failedImports.forEach(f => { + console.log(` - ${f.tmdbId}: ${f.error}`); + }); + } +} + +// Run the import +importAllMovies().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/backend/scripts/tmdb_ids.json b/backend/scripts/tmdb_ids.json new file mode 100644 index 00000000..b3b64590 --- /dev/null +++ b/backend/scripts/tmdb_ids.json @@ -0,0 +1,91 @@ +[ + 1018623, + 849407, + 307456, + 170645, + 786653, + 543715, + 783885, + 383847, + 286179, + 505785, + 1019683, + 725106, + 301063, + 758410, + 509750, + 519444, + 809389, + 46622, + 586462, + 1486161, + 54510, + 309116, + 218111, + 823624, + 336104, + 1371466, + 21607, + 26153, + 306364, + 354630, + 232817, + 25100, + 532017, + 15978, + 41511, + 968460, + 48569, + 819821, + 616434, + 31364, + 97442, + 33067, + 271677, + 181638, + 391730, + 232710, + 375845, + 916355, + 431804, + 302537, + 357133, + 528586, + 19491, + 78701, + 819762, + 152035, + 276286, + 271416, + 540620, + 47655, + 512180, + 312608, + 43216, + 85985, + 72152, + 338065, + 581361, + 44566, + 323426, + 254420, + 196313, + 336211, + 260267, + 48826, + 356773, + 61429, + 582375, + 184793, + 348094, + 168114, + 492436, + 310567, + 342291, + 570910, + 533991, + 418235, + 208036, + 45316, + 10757 +] \ No newline at end of file diff --git a/backend/src/controllers/comment.ts b/backend/src/controllers/comment.ts index f9e59eda..142a686f 100644 --- a/backend/src/controllers/comment.ts +++ b/backend/src/controllers/comment.ts @@ -63,31 +63,46 @@ export const getComment = async (req: AuthenticatedRequest, res: Response) => { }; /** - * GET /api/comments/post/:postId or /api/comments/rating/:ratingId - * Returns a flat list of comments for the post or rating + * GET /api/comments/post/:postId + * Returns a flat list of comments for the post */ export const getCommentsTree = async (req: AuthenticatedRequest, res: Response) => { const timestamp = new Date().toISOString(); - const { postId, ratingId } = req.params; + const { postId } = req.params; + const userId = req.user?.id; - if (!postId && !ratingId) { + if (!postId) { return res.status(400).json({ - message: "Missing postId or ratingId", + message: "Missing postId", timestamp, }); } try { const comments = await prisma.comment.findMany({ - where: postId ? { postId } : { ratingId }, + where: { postId }, orderBy: { createdAt: 'asc' }, include: { - UserProfile: { select: { userId: true, username: true, profilePicture: true } } + UserProfile: { select: { userId: true, username: true, profilePicture: true } }, + CommentLike: true, } }); + // Transform to include like count and whether current user liked + const commentsWithLikes = comments.map((comment) => ({ + id: comment.id, + userId: comment.userId, + postId: comment.postId, + parentId: comment.parentId, + content: comment.content, + createdAt: comment.createdAt, + UserProfile: comment.UserProfile, + likeCount: comment.CommentLike.length, + liked: userId ? comment.CommentLike.some((like) => like.userId === userId) : false, + })); + // Return flat list - client builds tree - res.json({ message: "Comments retrieved", comments }); + res.json({ message: "Comments retrieved", comments: commentsWithLikes }); } catch (error) { console.error(`[${timestamp}] getCommentsTree error:`, error); res.status(500).json({ @@ -113,7 +128,7 @@ export const createComment = async (req: AuthenticatedRequest, res: Response) => }); } - const { content, ratingId, postId, parentId } = req.body; + const { content, postId, parentId } = req.body; if (!content || typeof content !== "string" || content.trim() === "") { return res.status(400).json({ @@ -127,7 +142,6 @@ export const createComment = async (req: AuthenticatedRequest, res: Response) => const newComment = await prisma.comment.create({ data: { userId: req.user.id, - ratingId: ratingId ?? null, postId: postId ?? null, parentId: parentId ?? null, content: content, @@ -270,6 +284,157 @@ export const deleteComment = async (req: AuthenticatedRequest, res: Response) => } }; +/** + * POST /api/comment/:id/like + * Toggles a like on a comment for the authenticated user + */ +export const toggleCommentLike = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] toggleCommentLike called by user: ${req.user?.id || "unknown"}`); + + if (!req.user) { + return res.status(401).json({ + message: "User not authenticated", + timestamp, + endpoint: "/api/comment/:id/like", + }); + } + + const { id: commentId } = req.params; + + if (!commentId) { + return res.status(400).json({ + message: "Missing comment ID", + timestamp, + }); + } + + try { + // Check if comment exists + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!comment) { + return res.status(404).json({ message: "Comment not found", timestamp }); + } + + // Check if user already liked this comment + const existingLike = await prisma.commentLike.findUnique({ + where: { + commentId_userId: { + commentId, + userId: req.user.id, + }, + }, + }); + + if (existingLike) { + // Unlike - remove the like + await prisma.commentLike.delete({ + where: { id: existingLike.id }, + }); + + const likeCount = await prisma.commentLike.count({ + where: { commentId }, + }); + + return res.json({ + message: "Comment unliked successfully", + liked: false, + likeCount, + timestamp, + }); + } else { + // Like - add a new like + await prisma.commentLike.create({ + data: { + commentId, + userId: req.user.id, + }, + }); + + const likeCount = await prisma.commentLike.count({ + where: { commentId }, + }); + + return res.json({ + message: "Comment liked successfully", + liked: true, + likeCount, + timestamp, + }); + } + } catch (error) { + console.error(`[${timestamp}] toggleCommentLike error:`, error); + res.status(500).json({ + message: "Internal server error toggling comment like", + timestamp, + }); + } +}; + +/** + * GET /api/comment/:id/likes + * Returns the like count and whether the current user has liked the comment + */ +export const getCommentLikes = async (req: AuthenticatedRequest, res: Response) => { + const timestamp = new Date().toISOString(); + + if (!req.user) { + return res.status(401).json({ + message: "User not authenticated", + timestamp, + endpoint: "/api/comment/:id/likes", + }); + } + + const { id: commentId } = req.params; + + if (!commentId) { + return res.status(400).json({ + message: "Missing comment ID", + timestamp, + }); + } + + try { + const comment = await prisma.comment.findUnique({ + where: { id: commentId }, + }); + + if (!comment) { + return res.status(404).json({ message: "Comment not found", timestamp }); + } + + const likeCount = await prisma.commentLike.count({ + where: { commentId }, + }); + + const userLike = await prisma.commentLike.findUnique({ + where: { + commentId_userId: { + commentId, + userId: req.user.id, + }, + }, + }); + + res.json({ + message: "Comment likes retrieved successfully", + likeCount, + liked: !!userLike, + timestamp, + }); + } catch (error) { + console.error(`[${timestamp}] getCommentLikes error:`, error); + res.status(500).json({ + message: "Internal server error retrieving comment likes", + timestamp, + }); + } +}; + // backend/src/controllers/comment.ts export async function getMovieComments(req: Request, res: Response) { try { @@ -279,26 +444,21 @@ export async function getMovieComments(req: Request, res: Response) { return res.status(400).json({ message: "movieId is required" }); } - // 1) Find all ratings for this movie - const ratingsForMovie = await prisma.rating.findMany({ + // 1) Find all posts for this movie + const postsForMovie = await prisma.post.findMany({ where: { movieId }, select: { id: true }, }); - const ratingIds = ratingsForMovie.map((r) => r.id); - if (ratingIds.length === 0) { + const postIds = postsForMovie.map((p) => p.id); + if (postIds.length === 0) { return res.status(200).json({ comments: [] }); } - // 2) Find comments that reference those ratings + // 2) Find comments that reference those posts const commentsFromDb = await prisma.comment.findMany({ where: { - ratingId: { in: ratingIds }, - // If you later want to also include post-based comments: - // OR: [ - // { ratingId: { in: ratingIds } }, - // { post: { movieId } } // if you have relation from comment -> post -> movie - // ] + postId: { in: postIds }, }, orderBy: { createdAt: "desc" }, }); @@ -307,7 +467,6 @@ export async function getMovieComments(req: Request, res: Response) { const comments = commentsFromDb.map((c) => ({ id: c.id, userId: c.userId, - ratingId: c.ratingId, postId: c.postId, text: c.content, // frontend uses comment.text date: c.createdAt.toISOString(), // frontend uses comment.date diff --git a/backend/src/controllers/event-rsvp.ts b/backend/src/controllers/event-rsvp.ts index 0c7f328d..ecf5ec59 100644 --- a/backend/src/controllers/event-rsvp.ts +++ b/backend/src/controllers/event-rsvp.ts @@ -1,6 +1,7 @@ import type { Response } from "express"; import { AuthenticatedRequest } from "../middleware/auth.js"; import { prisma } from "../services/db.js"; +import { randomUUID } from "crypto"; /** * Create or update an RSVP for an event @@ -42,9 +43,11 @@ export const createOrUpdateRsvp = async (req: AuthenticatedRequest, res: Respons updatedAt: new Date(), }, create: { + id: randomUUID(), eventId, userId, status, + updatedAt: new Date(), }, include: { UserProfile: { diff --git a/backend/src/controllers/feed.ts b/backend/src/controllers/feed.ts index 3bf80d2d..8502297b 100644 --- a/backend/src/controllers/feed.ts +++ b/backend/src/controllers/feed.ts @@ -1,7 +1,6 @@ import { Request, Response } from 'express'; import { prisma } from '../services/db'; import type { AuthenticatedRequest } from '../middleware/auth'; -import { Post, Rating } from '@prisma/client'; export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { const { user } = req; @@ -33,7 +32,7 @@ export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { userReactionsByPost.set(reaction.postId, existing); }); - const recentRatings = await prisma.rating.findMany({ + const recentPosts = await prisma.post.findMany({ where: { userId: { in: followingIds }, }, @@ -51,22 +50,6 @@ export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { imageUrl: true, }, }, - }, - orderBy: { date: 'desc' }, - take: limit, - }); - - const recentPosts = await prisma.post.findMany({ - where: { - userId: { in: followingIds }, - }, - include: { - UserProfile: { - select: { - userId: true, - username: true, - }, - }, PostReaction: { select: { reactionType: true, @@ -83,10 +66,9 @@ export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { take: limit, }); - - const trendingRatings = await prisma.rating.findMany({ + const trendingPosts = await prisma.post.findMany({ where: { - date: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, }, include: { UserProfile: { @@ -102,22 +84,6 @@ export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { imageUrl: true, }, }, - }, - orderBy: { votes: 'desc' }, - take: 5, - }); - - const trendingPosts = await prisma.post.findMany({ - where: { - createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, - }, - include: { - UserProfile: { - select: { - userId: true, - username: true, - }, - }, PostReaction: { select: { reactionType: true, @@ -166,21 +132,16 @@ export const getHomeFeed = async (req: AuthenticatedRequest, res: Response) => { }; }); + // All feed items are now posts (SHORT and LONG, where LONG with stars are reviews) const feed = [ - ...recentRatings.map((r) => ({ type: 'rating', data: r })), ...recentPostsWithCounts.map((p) => ({ type: 'post', data: p })), - ...trendingRatings.map((r) => ({ type: 'trending_rating', data: r })), ...trendingPostsWithCounts.map((p) => ({ type: 'trending_post', data: p })), ]; - function getTimestamp(item: Rating | Post): Date { - return 'createdAt' in item ? item.createdAt : item.date; - } - // sort by newest const sortedFeed = feed.sort((a, b) => { - const aDate = getTimestamp(a.data); - const bDate = getTimestamp(b.data); + const aDate = a.data.createdAt; + const bDate = b.data.createdAt; return bDate.getTime() - aDate.getTime(); }); diff --git a/backend/src/controllers/local-events.ts b/backend/src/controllers/local-events.ts index b5100324..fb747ad9 100644 --- a/backend/src/controllers/local-events.ts +++ b/backend/src/controllers/local-events.ts @@ -226,6 +226,7 @@ export const getLocalEvents = async (req: Request, res: Response) => { orderBy: { time: 'asc' } }); + // Process events in parallel with optimized geocoding const data = await Promise.all(events.map(async event => { const eventDate = event.time || new Date(); const date = eventDate.toLocaleDateString('en-US', { @@ -238,10 +239,10 @@ export const getLocalEvents = async (req: Request, res: Response) => { minute: '2-digit' }); - return { + // Create a base event object without location + const baseEvent = { id: event.id, title: event.title, - location: await reverseGeocode(event.lat, event.lon), date, time, genre: event.genre, @@ -253,6 +254,23 @@ export const getLocalEvents = async (req: Request, res: Response) => { lon: event.lon, imageUrl: event.imageUrl, }; + + // Add location with timeout to prevent hanging + try { + const locationPromise = reverseGeocode(event.lat, event.lon); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Geocoding timeout')), 3000) + ); + + const location = await Promise.race([locationPromise, timeoutPromise]); + return { ...baseEvent, location }; + } catch (error) { + // Fallback to coordinates if geocoding fails or times out + return { + ...baseEvent, + location: `${(event.lat ?? 0).toFixed(2)}, ${(event.lon ?? 0).toFixed(2)}` + }; + } })); res.status(200).json({ message: "Local events retrieved.", data }); diff --git a/backend/src/controllers/movies.ts b/backend/src/controllers/movies.ts index 81410333..2fe78e95 100644 --- a/backend/src/controllers/movies.ts +++ b/backend/src/controllers/movies.ts @@ -2,6 +2,9 @@ import type { Request, Response } from "express"; import { prisma } from "../services/db.js"; import type { Movie } from "../types/models"; +import type { GetMovieSummaryEnvelope } from '../types/models'; +import { generateMovieSummary } from '../services/summaryService.js'; + function mapPrismaMovie(m: any): Movie { // Normalize languages: JsonValue -> string[] | null @@ -52,4 +55,87 @@ export async function getAllMovies(req: Request, res: Response) { error: "Failed to fetch movies", }); } -} \ No newline at end of file +} + +export async function getMovieSummaryHandler(req: Request, res: Response) { + const movieId = req.params.movieId; + + try { + const summary = await generateMovieSummary(movieId); + return res.status(200).json({ summary }); + } catch (err: any) { + console.error('getMovieSummaryHandler error:', err); // ⬅️ check this in backend logs + return res.status(500).json({ + message: 'Failed to generate AI summary', + error: err?.message, + }); + } +} +// GET /movies/after/:year +export async function getMoviesAfterYear(req: Request, res: Response) { + try { + const year = Number(req.params.year); + + if (isNaN(year)) { + return res.status(400).json({ error: "Invalid year parameter" }); + } + + const moviesFromDb = await prisma.movie.findMany({ + where: { + releaseYear: { + gte: year, + }, + }, + orderBy: { + releaseYear: "asc", + }, + }); + + const movies = moviesFromDb.map(mapPrismaMovie); + + return res.status(200).json({ + movies, + count: movies.length, + afterYear: year, + }); + } catch (err) { + console.error("Error in GET /movies/after/:year:", err); + return res.status(500).json({ error: "Failed to fetch movies" }); + } +} +// GET /movies/random/10 +export async function getRandomTenMovies(req: Request, res: Response) { + try { + const moviesFromDb = await prisma.movie.findMany({ + take: 10, + orderBy: { + // Prisma trick to randomize: + // sqlite doesn't support random() but postgres does + // if sqlite, you must randomize manually after fetch + // Adjust depending on your database. + // For postgres: + // random() works! + // For MySQL: use `RAND()` + // So we detect DB or just do manual shuffle. + // Here is manual shuffle: + }, + }); + + // Manual shuffle so it works across all DB engines: + const shuffled = moviesFromDb + .map((m) => ({ m, rand: Math.random() })) + .sort((a, b) => a.rand - b.rand) + .slice(0, 10) + .map((obj) => obj.m); + + const movies = shuffled.map(mapPrismaMovie); + + return res.status(200).json({ + movies, + count: movies.length, + }); + } catch (err) { + console.error("Error in GET /movies/random/10:", err); + return res.status(500).json({ error: "Failed to fetch movies" }); + } +} diff --git a/backend/src/controllers/post.ts b/backend/src/controllers/post.ts index 054cddd3..6c4ed2b3 100644 --- a/backend/src/controllers/post.ts +++ b/backend/src/controllers/post.ts @@ -5,7 +5,7 @@ import { Prisma } from "@prisma/client"; // CREATE POST export const createPost = async (req: Request, res: Response) => { try { - const { userId, content, type, imageUrls, parentPostId } = req.body; + const { userId, content, type, imageUrls, repostedPostId, movieId, stars, spoiler, tags } = req.body; // Validation if (!userId || !content) { @@ -13,6 +13,12 @@ export const createPost = async (req: Request, res: Response) => { message: "userId and content are required" }); } + + if (!movieId) { + return res.status(400).json({ + message: "movieId is required - all posts must reference a movie" + }); + } if (type && !["LONG", "SHORT"].includes(type)) { return res.status(400).json({ @@ -20,23 +26,44 @@ export const createPost = async (req: Request, res: Response) => { }); } - // If it's a reply, verify parent post exists - if (parentPostId) { - const parentPost = await prisma.post.findUnique({ - where: { id: parentPostId }, + // Validate stars if provided + if (stars !== undefined && stars !== null) { + const starsNum = parseInt(stars, 10); + if (isNaN(starsNum) || starsNum < 0 || starsNum > 10) { + return res.status(400).json({ + message: "Stars must be between 0 and 10" + }); + } + + // SHORT posts cannot have stars + if (type === "SHORT" || (!type && content.length <= 280)) { + return res.status(400).json({ + message: "SHORT posts cannot have star ratings" + }); + } + } + + // If it's a repost, verify original post exists + if (repostedPostId) { + const originalPost = await prisma.post.findUnique({ + where: { id: repostedPostId }, }); - if (!parentPost) { - return res.status(404).json({ message: "Parent post not found" }); + if (!originalPost) { + return res.status(404).json({ message: "Original post not found" }); } } const newPost = await prisma.post.create({ data: { userId, + movieId, content, type: type || "SHORT", + stars: stars ? parseInt(stars, 10) : null, + spoiler: spoiler || false, + tags: tags || [], imageUrls: imageUrls || [], - parentPostId, + repostedPostId, }, include: { UserProfile: { @@ -45,6 +72,13 @@ export const createPost = async (req: Request, res: Response) => { username: true, }, }, + movie: { + select: { + movieId: true, + title: true, + imageUrl: true, + }, + }, }, }); @@ -79,6 +113,13 @@ export const getPostById = async (req: Request, res: Response) => { username: true, }, }, + movie: { + select: { + movieId: true, + title: true, + imageUrl: true, + }, + }, Comment: { include: { UserProfile: { @@ -93,7 +134,7 @@ export const getPostById = async (req: Request, res: Response) => { }, }, PostReaction: true, - Replies: { + other_Post: { include: { UserProfile: { select: { @@ -117,9 +158,10 @@ export const getPostById = async (req: Request, res: Response) => { message: "Post found successfully", data: { ...post, - likeCount: post.votes, + Reposts: post.other_Post, + reactionCount: post.PostReaction.length, commentCount: post.Comment.length, - replyCount: post.Replies.length, + repostCount: post.other_Post.length, }, }); } catch (err) { @@ -135,22 +177,40 @@ export const getPostById = async (req: Request, res: Response) => { // GET POSTS (with filters) export const getPosts = async (req: Request, res: Response) => { try { - const { + const { userId, type, - parentPostId, + movieId, + repostedPostId, limit = "20", - offset = "0" + offset = "0", + currentUserId // Optional: for getting user's reactions } = req.query; const where: Prisma.PostWhereInput = {}; if (userId) where.userId = userId as string; if (type) where.type = type as "LONG" | "SHORT"; - if (parentPostId === "null") { - where.parentPostId = null; // Top-level posts only - } else if (parentPostId) { - where.parentPostId = parentPostId as string; + if (movieId) where.movieId = movieId as string; + if (repostedPostId === "null") { + where.repostedPostId = null; // Original posts only (not reposts) + } else if (repostedPostId) { + where.repostedPostId = repostedPostId as string; // Get reposts of a specific post + } + + // Get current user's reactions if provided + let userReactionsByPost = new Map(); + if (currentUserId) { + const userReactions = await prisma.postReaction.findMany({ + where: { userId: currentUserId as string }, + select: { postId: true, reactionType: true }, + }); + + userReactions.forEach(reaction => { + const existing = userReactionsByPost.get(reaction.postId) || []; + existing.push(reaction.reactionType); + userReactionsByPost.set(reaction.postId, existing); + }); } const posts = await prisma.post.findMany({ @@ -162,13 +222,24 @@ export const getPosts = async (req: Request, res: Response) => { username: true, }, }, - PostReaction: true, + movie: { + select: { + movieId: true, + title: true, + imageUrl: true, + }, + }, + PostReaction: { + select: { + reactionType: true, + }, + }, Comment: { select: { id: true, }, }, - Replies: { + other_Post: { select: { id: true, }, @@ -181,12 +252,23 @@ export const getPosts = async (req: Request, res: Response) => { skip: parseInt(offset as string), }); - const postsWithCounts = posts.map((post) => ({ - ...post, - likeCount: post.votes, - commentCount: post.Comment.length, - replyCount: post.Replies.length, - })); + const postsWithCounts = posts.map((post) => { + // Count reactions by type + const reactionCounts = post.PostReaction.reduce((acc: Record, r: any) => { + acc[r.reactionType] = (acc[r.reactionType] || 0) + 1; + return acc; + }, {}); + + return { + ...post, + Reposts: post.other_Post, + reactionCount: post.PostReaction.length, + reactionCounts, + userReactions: userReactionsByPost.get(post.id) || [], + commentCount: post.Comment.length, + repostCount: post.other_Post.length, + }; + }); res.json({ message: "Posts retrieved successfully", @@ -422,8 +504,8 @@ export const getPostReactions = async (req: Request, res: Response) => { } }; -// GET POST REPLIES -export const getPostReplies = async (req: Request, res: Response) => { +// GET POST REPOSTS - Get all reposts/shares of a specific post +export const getPostReposts = async (req: Request, res: Response) => { try { const { postId } = req.params; @@ -431,8 +513,8 @@ export const getPostReplies = async (req: Request, res: Response) => { return res.status(400).json({ message: "Post ID is required" }); } - const replies = await prisma.post.findMany({ - where: { parentPostId: postId }, + const reposts = await prisma.post.findMany({ + where: { repostedPostId: postId }, include: { UserProfile: { select: { @@ -441,7 +523,7 @@ export const getPostReplies = async (req: Request, res: Response) => { }, }, PostReaction: true, - Replies: { + other_Post: { select: { id: true, }, @@ -452,22 +534,23 @@ export const getPostReplies = async (req: Request, res: Response) => { }, }); - const repliesWithCounts = replies.map((reply) => ({ - ...reply, - likeCount: reply.votes, - replyCount: reply.Replies.length, + const repostsWithCounts = reposts.map((repost) => ({ + ...repost, + Reposts: repost.other_Post, + reactionCount: repost.PostReaction?.length || 0, + repostCount: repost.other_Post.length, })); res.json({ - message: "Replies retrieved successfully", - data: repliesWithCounts, - count: replies.length, + message: "Reposts retrieved successfully", + data: repostsWithCounts, + count: reposts.length, }); } catch (err) { - console.error("getPostReplies error:", err); + console.error("getPostReposts error:", err); res.status(500).json({ - message: "Failed to retrieve replies", + message: "Failed to retrieve reposts", error: err instanceof Error ? err.message : "Unknown error", }); } - }; \ No newline at end of file + }; diff --git a/backend/src/controllers/search.ts b/backend/src/controllers/search.ts index 6d17729c..a15c12af 100644 --- a/backend/src/controllers/search.ts +++ b/backend/src/controllers/search.ts @@ -220,21 +220,59 @@ export const searchUsers = async (req: Request, res: Response) => { } try { - const users = await prisma.userProfile.findMany({ - where: { + const orClauses: any[] = [ + { username: { contains: q, - mode: "insensitive" - } + mode: "insensitive", + }, + }, + ]; + + // If the query looks like a UUID, also match on userId directly (no ILIKE on UUID) + const isUuid = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + if (isUuid.test(q)) { + orClauses.push({ userId: q }); + } + + const users = await prisma.userProfile.findMany({ + where: { + OR: orClauses, }, take: limitNum, }); + const toStrings = (val?: string[] | null) => + Array.isArray(val) ? (val as string[]) : []; + + const normalized = users.map((u) => ({ + userId: u.userId, + username: u.username, + onboardingCompleted: u.onboardingCompleted, + primaryLanguage: u.primaryLanguage, + secondaryLanguage: toStrings(u.secondaryLanguage), + profilePicture: u.profilePicture, + country: u.country, + city: u.city, + displayName: u.displayName, + favoriteGenres: toStrings(u.favoriteGenres), + favoriteMovies: toStrings(u.favoriteMovies), + bio: u.bio, + moviesToWatch: toStrings(u.moviesToWatch), + moviesCompleted: toStrings(u.moviesCompleted), + eventsSaved: toStrings(u.eventsSaved), + eventsAttended: toStrings(u.eventsAttended), + privateAccount: u.privateAccount, + spoiler: u.spoiler, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + })); + return res.json({ type: "users", query: q, - count: users.length, - results: users, + count: normalized.length, + results: normalized, }); } catch (error) { console.error("searchUsers error:", error); diff --git a/backend/src/controllers/user.ts b/backend/src/controllers/user.ts index 9d0f3527..a2dfd3e1 100644 --- a/backend/src/controllers/user.ts +++ b/backend/src/controllers/user.ts @@ -4,48 +4,134 @@ import { prisma } from '../services/db.js'; import { Prisma } from "@prisma/client"; import { UserProfile } from "../types/models"; -export const updateUserProfile = async (req: AuthenticatedRequest, res: Response) => { - const { user } = req; - if (!user) return res.status(401).json({ message: "Unauthorized" }); - - const { - username, - onboardingCompleted, - primaryLanguage, - secondaryLanguage, - profilePicture, - country, - city, - favoriteGenres, - favoriteMovies, - updatedAt, - } = (req.body ?? {}) as Partial; +import { prisma } from '../services/db'; // or wherever yours is +import { Prisma } from '@prisma/client'; +// inside your handler: +export const updateUserProfile = async (req, res) => { try { - const data = mapUserProfilePatchToUpdateData({ - username, - onboardingCompleted, - primaryLanguage, - secondaryLanguage, - profilePicture, - country, - city, - favoriteGenres, - favoriteMovies, - updatedAt, + // however you're getting this – body already validated / normalized + const body = req.body; + + console.log('🟠 [BE] raw body in updateUserProfile:', body); + + const normalized = { + username: body.username ?? null, + displayName: body.displayName ?? null, + onboardingCompleted: body.onboardingCompleted, + primaryLanguage: body.primaryLanguage, + secondaryLanguage: Array.isArray(body.secondaryLanguage) + ? body.secondaryLanguage + : [], + profilePicture: body.profilePicture, + country: body.country, + city: body.city, + favoriteGenres: Array.isArray(body.favoriteGenres) + ? body.favoriteGenres + : [], + favoriteMovies: Array.isArray(body.favoriteMovies) + ? body.favoriteMovies + : [], + bio: body.bio ?? null, + eventsSaved: Array.isArray(body.eventsSaved) + ? Array.from(new Set(body.eventsSaved)) + : undefined, + eventsAttended: Array.isArray(body.eventsAttended) + ? Array.from(new Set(body.eventsAttended)) + : undefined, + privateAccount: + typeof body.privateAccount === 'boolean' + ? body.privateAccount + : undefined, + spoiler: + typeof body.spoiler === 'boolean' ? body.spoiler : undefined, + + // 🔥 IMPORTANT PART: keep arrays exactly as passed, just dedupe + bookmarkedToWatch: Array.isArray(body.bookmarkedToWatch) + ? Array.from(new Set(body.bookmarkedToWatch)) + : undefined, + bookmarkedWatched: Array.isArray(body.bookmarkedWatched) + ? Array.from(new Set(body.bookmarkedWatched)) + : undefined, + }; + + console.log('🔍 [BE] normalized body before prisma:', { + bookmarkedToWatch: normalized.bookmarkedToWatch, + bookmarkedWatched: normalized.bookmarkedWatched, }); - const updated = await prisma.userProfile.update({ - where: { userId: user.id }, - data, + const prismaData: Prisma.UserProfileUpdateInput = { + // only set fields that are explicitly provided (!== undefined) + ...(normalized.username !== undefined && { + username: normalized.username, + }), + ...(normalized.displayName !== undefined) && { + displayName: normalized.displayName, + }, + ...(normalized.onboardingCompleted !== undefined && { + onboardingCompleted: normalized.onboardingCompleted, + }), + ...(normalized.primaryLanguage !== undefined && { + primaryLanguage: normalized.primaryLanguage, + }), + ...(normalized.secondaryLanguage !== undefined && { + secondaryLanguage: normalized.secondaryLanguage, + }), + ...(normalized.profilePicture !== undefined && { + profilePicture: normalized.profilePicture, + }), + ...(normalized.country !== undefined && { country: normalized.country }), + ...(normalized.city !== undefined && { city: normalized.city }), + ...(normalized.favoriteGenres !== undefined && { + favoriteGenres: normalized.favoriteGenres, + }), + ...(normalized.favoriteMovies !== undefined && { + favoriteMovies: normalized.favoriteMovies, + }), + ...(normalized.bio !== undefined && { + bio: normalized.bio, + }), + ...(normalized.eventsSaved !== undefined && { + eventsSaved: normalized.eventsSaved, + }), + ...(normalized.eventsAttended !== undefined && { + eventsAttended: normalized.eventsAttended, + }), + ...(normalized.privateAccount !== undefined && { + privateAccount: normalized.privateAccount, + }), + ...(normalized.spoiler !== undefined && { spoiler: normalized.spoiler }), + + ...(normalized.bookmarkedToWatch !== undefined && { + bookmarkedToWatch: normalized.bookmarkedToWatch, + }), + ...(normalized.bookmarkedWatched !== undefined && { + bookmarkedWatched: normalized.bookmarkedWatched, + }), + + updatedAt: new Date(), + }; + + console.log( + '🟡 [BE] updateUserProfile() prisma update data:', + prismaData + ); + + const userId = req.user.id; // or however you attach auth + const result = await prisma.userProfile.update({ + where: { userId }, + data: prismaData, }); - res.json({ message: "Profile updated", data: mapUserProfileDbToApi(updated) }); - } catch (error) { - console.error("updateUserProfile error:", error); - res.status(500).json({ message: "Failed to update profile" }); + console.log('🟢 [BE] updateUserProfile() prisma result:', result); + + res.json({ userProfile: result }); + } catch (err) { + console.error('🔴 [BE] updateUserProfile() error:', err); + res.status(500).json({ error: 'Failed to update profile' }); } }; + export const deleteUserProfile = async (req: AuthenticatedRequest, res: Response) => { const { user } = req; @@ -84,11 +170,20 @@ export const ensureUserProfile = async (req: AuthenticatedRequest, res: Response favoriteGenres: [], secondaryLanguage: [], profilePicture: null, + displayName: null, country: null, city: null, primaryLanguage: 'English', + privateAccount: false, + spoiler: false, + bio: null, + eventsSaved: [], + eventsAttended: [], updatedAt: new Date(), + bookmarkedToWatch: [], + bookmarkedWatched: [], }, + }); } @@ -141,15 +236,31 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = profilePicture: userProfile.profilePicture, country: userProfile.country, city: userProfile.city, - favoriteGenres: Array.isArray(userProfile.favoriteGenres) - ? userProfile.favoriteGenres as string[] + displayName: userProfile.displayName, + favoriteGenres: Array.isArray(userProfile.favoriteGenres) + ? userProfile.favoriteGenres as string[] + : [], + favoriteMovies: Array.isArray(userProfile.favoriteMovies) + ? userProfile.favoriteMovies as string[] + : [], + bio: userProfile.bio ?? null, + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], + privateAccount: Boolean(userProfile.privateAccount), + spoiler: Boolean(userProfile.spoiler), + createdAt: userProfile.createdAt, + updatedAt: userProfile.updatedAt, + bookmarkedToWatch: Array.isArray(userProfile.bookmarkedToWatch) + ? userProfile.bookmarkedToWatch as string[] : [], - favoriteMovies: Array.isArray(userProfile.favoriteMovies) - ? userProfile.favoriteMovies as string[] + bookmarkedWatched: Array.isArray(userProfile.bookmarkedWatched) + ? userProfile.bookmarkedWatched as string[] : [], - createdAt: userProfile.createdAt, - updatedAt: userProfile.updatedAt, - }); + }); const basicUser = req.user ? { @@ -159,6 +270,17 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = } : undefined; + try { + console.log( + `[${timestamp}] [AccountSettings][backend] profile payload for user ${req.user.id}:`, + JSON.stringify({ userProfile: mappedUserProfile }, null, 2), + ); + } catch { + console.log( + `[${timestamp}] [AccountSettings][backend] profile payload (raw) for user ${req.user.id}:`, + mappedUserProfile, + ); + } console.log(`[${timestamp}] getUserProfile success: Retrieved profile for user ${req.user.id}`); res.json({ @@ -179,6 +301,63 @@ export const getUserProfile = async (req: AuthenticatedRequest, res: Response) = } }; +// Public profile lookup by userId (used when viewing another user's profile) +export const getUserProfileById = async (req: Request, res: Response) => { + const { userId } = req.params; + if (!userId) { + return res.status(400).json({ message: "userId is required" }); + } + + try { + const userProfile = await prisma.userProfile.findUnique({ + where: { userId }, + }); + + if (!userProfile) { + return res.status(404).json({ message: "User profile not found" }); + } + + const mappedUserProfile = mapUserProfileDbToApi({ + userId: userProfile.userId, + username: userProfile.username, + onboardingCompleted: userProfile.onboardingCompleted, + primaryLanguage: userProfile.primaryLanguage, + secondaryLanguage: Array.isArray(userProfile.secondaryLanguage) + ? (userProfile.secondaryLanguage as string[]) + : [], + profilePicture: userProfile.profilePicture, + country: userProfile.country, + city: userProfile.city, + displayName: userProfile.displayName, + favoriteGenres: Array.isArray(userProfile.favoriteGenres) + ? (userProfile.favoriteGenres as string[]) + : [], + favoriteMovies: Array.isArray(userProfile.favoriteMovies) + ? (userProfile.favoriteMovies as string[]) + : [], + bio: userProfile.bio ?? null, + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], + privateAccount: Boolean(userProfile.privateAccount), + spoiler: Boolean(userProfile.spoiler), + createdAt: userProfile.createdAt, + updatedAt: userProfile.updatedAt, + }); + + return res.json({ + message: "User profile retrieved successfully", + userProfile: mappedUserProfile, + }); + } catch (error) { + console.error("getUserProfileById error:", error); + return res.status(500).json({ message: "Failed to retrieve user profile" }); + } +}; + export const getUserRatings = async (req: Request, res: Response): Promise => { const { user_id } = req.query; @@ -191,7 +370,6 @@ export const getUserRatings = async (req: Request, res: Response): Promise const ratings = await prisma.rating.findMany({ where: { userId: user_id }, orderBy: { date: "desc" }, - include: { Comment: true }, }); // Fetch user profile @@ -210,17 +388,33 @@ export const getUserRatings = async (req: Request, res: Response): Promise secondaryLanguage: Array.isArray(userProfile.secondaryLanguage) ? userProfile.secondaryLanguage as string[] : [], - profilePicture: userProfile.profilePicture, - country: userProfile.country, - city: userProfile.city, + profilePicture: userProfile.profilePicture, + country: userProfile.country, + city: userProfile.city, + displayName: userProfile.displayName, favoriteGenres: Array.isArray(userProfile.favoriteGenres) ? userProfile.favoriteGenres as string[] : [], favoriteMovies: Array.isArray(userProfile.favoriteMovies) ? userProfile.favoriteMovies as string[] : [], + bio: userProfile.bio ?? null, + eventsSaved: Array.isArray(userProfile.eventsSaved) + ? (userProfile.eventsSaved as string[]) + : [], + eventsAttended: Array.isArray(userProfile.eventsAttended) + ? (userProfile.eventsAttended as string[]) + : [], + privateAccount: Boolean(userProfile.privateAccount), + spoiler: Boolean(userProfile.spoiler), createdAt: userProfile.createdAt, updatedAt: userProfile.updatedAt, + bookmarkedToWatch: Array.isArray(userProfile.bookmarkedToWatch) + ? userProfile.bookmarkedToWatch as string[] + : [], + bookmarkedWatched: Array.isArray(userProfile.bookmarkedWatched) + ? userProfile.bookmarkedWatched as string[] + : [], }); } @@ -266,17 +460,32 @@ export const getUserComments = async (req: Request, res: Response): Promise> ): Prisma.UserProfileUpdateInput { const data: Prisma.UserProfileUpdateInput = {}; @@ -336,6 +565,9 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "username")) { data.username = patch.username ?? null; } + if (Object.prototype.hasOwnProperty.call(patch, "displayName")) { + data.displayName = patch.displayName ?? null; + } if (Object.prototype.hasOwnProperty.call(patch, "onboardingCompleted")) { data.onboardingCompleted = patch.onboardingCompleted; } @@ -360,6 +592,38 @@ export function mapUserProfilePatchToUpdateData( if (Object.prototype.hasOwnProperty.call(patch, "favoriteMovies")) { data.favoriteMovies = patch.favoriteMovies ?? []; } + if (Object.prototype.hasOwnProperty.call(patch, "bio")) { + data.bio = patch.bio ?? null; + } + if (Object.prototype.hasOwnProperty.call(patch, "eventsSaved")) { + data.eventsSaved = patch.eventsSaved ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "eventsAttended")) { + data.eventsAttended = patch.eventsAttended ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "privateAccount")) { + data.privateAccount = patch.privateAccount ?? false; + } + const spoilerValue = Object.prototype.hasOwnProperty.call(patch, "spoiler") + ? patch.spoiler + : Object.prototype.hasOwnProperty.call(patch as any, "spoilers") + ? (patch as any).spoilers + : undefined; + if (spoilerValue !== undefined) { + data.spoiler = spoilerValue ?? false; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedToWatch")) { + data.bookmarkedToWatch = patch.bookmarkedToWatch ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedWatched")) { + data.bookmarkedWatched = patch.bookmarkedWatched ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedToWatch")) { + data.bookmarkedToWatch = patch.bookmarkedToWatch ?? []; + } + if (Object.prototype.hasOwnProperty.call(patch, "bookmarkedWatched")) { + data.bookmarkedWatched = patch.bookmarkedWatched ?? []; + } // Always refresh updatedAt to now unless caller explicitly provided one data.updatedAt = patch.updatedAt ?? new Date(); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 9e12c4ff..1d395233 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -6,24 +6,27 @@ import { getMovieById, updateMovie, } from "../controllers/tmdb"; -import { deleteUserProfile, ensureUserProfile, getUserComments, getUserProfile, getUserRatings, updateUserProfile } from '../controllers/user'; +import { deleteUserProfile, ensureUserProfile, getUserComments, getUserProfile, getUserProfileById, getUserRatings, updateUserProfile } from '../controllers/user'; import { authenticateUser } from '../middleware/auth'; import { protect } from "../controllers/protected"; import { getLocalEvent, createLocalEvent, updateLocalEvent, deleteLocalEvent, getLocalEvents } from "../controllers/local-events" import { createOrUpdateRsvp, getUserRsvp, deleteRsvp, getEventAttendees } from "../controllers/event-rsvp" import { followUser, unfollowUser, getFollowers, getFollowing } from "../controllers/userFollows"; -import { getComment, createComment, updateComment, deleteComment, getMovieComments, getCommentsTree} from "../controllers/comment" +import { getComment, createComment, updateComment, deleteComment, getMovieComments, getCommentsTree, toggleCommentLike, getCommentLikes } from "../controllers/comment" import { createRating, getRatings, getRatingById, deleteRating, updateRating,getMovieRatings } from "../controllers/ratings"; -import { getAllMovies } from "../controllers/movies"; -import { createPost, getPostById, getPosts, updatePost, deletePost, getPostReplies, toggleReaction, getPostReactions } from "../controllers/post.js"; +import { getAllMovies, getMoviesAfterYear, getRandomTenMovies } from "../controllers/movies"; +import { createPost, getPostById, getPosts, updatePost, deletePost, getPostReposts, toggleReaction, getPostReactions } from "../controllers/post.js"; import { searchMovies, searchUsers, searchReviews, searchPosts } from "../controllers/search.js"; import { getHomeFeed } from "../controllers/feed"; +import { getMovieSummaryHandler } from "../controllers/movies.js"; // backend/src/routes/index.ts const router = Router(); router.get("/api/ping", ping); router.get("/api/db-test", dbTest); +router.get('/movies/:movieId/summary', getMovieSummaryHandler); + // Legacy endpoint router.get("/swagger-output.json", serveSwagger); @@ -41,6 +44,7 @@ router.get('/api/protected', protect); // User Profile Routes router.get('/api/user/profile', getUserProfile); +router.get('/api/user/profile/:userId', getUserProfileById); router.put("/api/user/profile", updateUserProfile); router.delete("/api/user/profile", deleteUserProfile); @@ -57,6 +61,8 @@ router.get("/api/user/comments", getUserComments); router.get("/movies/:movieId", getMovie); router.get("/movies/cinecircle/:movieId", getMovieById); router.put("/movies/cinecircle/:movieId", updateMovie); +router.get("/movies/after/:year", getMoviesAfterYear); +router.get("/movies/random/10", getRandomTenMovies); router.delete("/movies/:movieId", deleteMovie); router.get("/api/feed", getHomeFeed); @@ -66,9 +72,10 @@ router.post("/api/comment", createComment); router.get("/api/comment/:id", getComment) router.put("/api/comment/:id", updateComment); router.delete("/api/comment/:id", deleteComment); +router.post("/api/comment/:id/like", toggleCommentLike); +router.get("/api/comment/:id/likes", getCommentLikes); router.get("/api/:movieId/comments", getMovieComments); router.get("/api/comments/post/:postId", getCommentsTree); -router.get("/api/comments/rating/:ratingId", getCommentsTree); // Ratings routes router.post('/api/ratings', createRating); @@ -109,7 +116,7 @@ router.delete("/api/post/:postId", deletePost); // Reaction routes router.post("/api/post/:postId/reaction", toggleReaction); router.get("/api/post/:postId/reactions", getPostReactions); -router.get("/api/post/:postId/replies", getPostReplies); +router.get("/api/post/:postId/reposts", getPostReposts); // Search routes router.get("/api/search/movies", searchMovies) diff --git a/backend/src/services/geocoding.ts b/backend/src/services/geocoding.ts index 807776a2..768a5e83 100644 --- a/backend/src/services/geocoding.ts +++ b/backend/src/services/geocoding.ts @@ -1,5 +1,20 @@ -// Simple in-memory cache to avoid repeated geocoding calls -const locationCache = new Map(); +// Enhanced in-memory cache with TTL +interface CacheEntry { + location: string; + timestamp: number; +} +const locationCache = new Map(); +const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours + +// Clean up expired cache entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of locationCache.entries()) { + if (now - entry.timestamp > CACHE_TTL) { + locationCache.delete(key); + } + } +}, 60 * 60 * 1000); // Clean up every hour /** * Reverse geocode lat/lon coordinates to a human-readable location string @@ -15,17 +30,28 @@ export async function reverseGeocode( ): Promise { if (!lat || !lon) return 'Location TBD'; - const cacheKey = `${lat},${lon}`; - if (locationCache.has(cacheKey)) { - return locationCache.get(cacheKey)!; + const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`; + const cached = locationCache.get(cacheKey); + + // Return cached location if still valid + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.location; } try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 second timeout + const response = await fetch( `https://nominatim.openstreetmap.org/reverse?format=json&lat=${lat}&lon=${lon}`, - { headers: { 'User-Agent': 'CineCircle/1.0' } } + { + headers: { 'User-Agent': 'CineCircle/1.0' }, + signal: controller.signal + } ); + clearTimeout(timeoutId); + if (!response.ok) throw new Error('Geocoding failed'); const data = await response.json(); @@ -36,11 +62,38 @@ export async function reverseGeocode( const region = address.state || address.country; const location = city && region ? `${city}, ${region}` : `${lat.toFixed(2)}, ${lon.toFixed(2)}`; - locationCache.set(cacheKey, location); + // Cache the result + locationCache.set(cacheKey, { location, timestamp: Date.now() }); return location; } catch (error) { // Fallback to coordinates if geocoding fails - return `${lat.toFixed(2)}, ${lon.toFixed(2)}`; + const fallback = `${lat.toFixed(2)}, ${lon.toFixed(2)}`; + // Cache fallback for a shorter period + locationCache.set(cacheKey, { location: fallback, timestamp: Date.now() }); + return fallback; + } +} + +/** + * Batch reverse geocode multiple coordinates efficiently + * @param coordinates - Array of {lat, lon} objects + * @returns Promise resolving to array of location strings + */ +export async function batchReverseGeocode( + coordinates: Array<{lat: number, lon: number}> +): Promise { + // Process in parallel with concurrency limit to avoid overwhelming the API + const BATCH_SIZE = 5; + const results: string[] = []; + + for (let i = 0; i < coordinates.length; i += BATCH_SIZE) { + const batch = coordinates.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(({lat, lon}) => reverseGeocode(lat, lon)) + ); + results.push(...batchResults); } + + return results; } diff --git a/backend/src/services/summaryService.ts b/backend/src/services/summaryService.ts new file mode 100644 index 00000000..9c9b01fe --- /dev/null +++ b/backend/src/services/summaryService.ts @@ -0,0 +1,337 @@ +// backend/src/services/summaryService.ts +import { prisma } from '../services/db.js'; +import OpenAI from 'openai'; +import type { + MovieSummary, + SentimentStats, + ChunkSummary, +} from '../types/models'; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY!, +}); + +// Tiny in-memory cache: movieId -> { summary, expiresAt } +const summaryCache = new Map(); +const SUMMARY_TTL_MS = 60 * 60 * 1000; // 1 hour + +// Approximate chunk size: chars, not real tokens but good enough +const CHUNK_MAX_CHARS = 4000; + +/** + * Fetch all posts for a movie and extract their textual content. + * No images or metadata - just the text content of posts. + */ + +async function getReviewTextsForMovie(movieId: string): Promise { + // Fetch all posts for this movie (both LONG and SHORT) + const posts = await prisma.post.findMany({ + where: { movieId }, + select: { + id: true, + content: true, + stars: true, + type: true, + }, + }); + + const texts: string[] = []; + + // Extract textual content from posts + for (const post of posts) { + if (post.content) { + // If post has stars (it's a review), include the rating in context + if (post.stars !== null && post.stars !== undefined) { + texts.push(`Review (${post.stars}/10): ${post.content}`); + } else { + // Regular post without stars + texts.push(`Post: ${post.content}`); + } + } + } + + return texts; +} + + +/** + * Simple char-based chunking: group lines into chunks up to CHUNK_MAX_CHARS. + */ +function chunkTexts(texts: string[], maxChars = CHUNK_MAX_CHARS): string[] { + const chunks: string[] = []; + let current = ''; + + for (const t of texts) { + const addition = (current ? '\n\n' : '') + t; + if (current.length + addition.length > maxChars) { + if (current) chunks.push(current); + current = t; // start new chunk with current text + } else { + current += addition; + } + } + + if (current) { + chunks.push(current); + } + + return chunks; +} + + +/** + * Analyze a single chunk of reviews using the LLM and return a ChunkSummary. + */ +async function summarizeChunk(movieId: string, chunkText: string): Promise { + console.log(`Summarizing movie ${movieId} with ${chunkText.length} characters in chunk`); + + const systemPrompt = ` +You are an assistant that analyzes user posts about a movie and produces a SHORT, structured summary for this chunk only. + +You MUST return ONLY valid JSON with this exact shape: +{ + "pros": string[], // bullet-style things people liked in this chunk + "cons": string[], // bullet-style complaints in this chunk + "stats": { + "positive": number, // # of clearly positive items in this chunk + "neutral": number, // # of mixed / neutral in this chunk + "negative": number, // # of clearly negative in this chunk + "total": number // total # of items in this chunk + }, + "quotes": string[] // 1–3 short representative quotes (avoid major spoilers) +} +Do NOT include any extra keys or commentary. +Keep spoilers to a minimum if possible. +`.trim(); + + const userPrompt = ` +Movie ID: ${movieId} + +Here is one CHUNK of user posts about this movie: + +${chunkText} +`.trim(); + + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + response_format: { type: 'json_object' }, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }); + + const content = completion.choices[0]?.message?.content; + if (!content) { + throw new Error('Empty response from OpenAI for chunk summary'); + } + + let parsed: { + pros: string[]; + cons: string[]; + stats: SentimentStats; + quotes: string[]; + }; + + try { + parsed = JSON.parse(content); + } catch (e) { + console.error('Failed to parse chunk JSON from LLM:', e, content); + throw e; + } + + const stats = parsed.stats ?? { + positive: 0, + neutral: 0, + negative: 0, + total: 0, + }; + + return { + pros: parsed.pros ?? [], + cons: parsed.cons ?? [], + stats, + quotes: parsed.quotes ?? [], + }; +} + +/** + * Aggregate multiple ChunkSummary objects into one combined structure. + */ +function aggregateChunkSummaries( + movieId: string, + chunkSummaries: ChunkSummary[], + totalReviews: number, +): Omit { + // Sum stats + const stats: SentimentStats = { + positive: 0, + neutral: 0, + negative: 0, + total: 0, + positivePercent: 0, + neutralPercent: 0, + negativePercent: 0, + }; + + const prosSet = new Set(); + const consSet = new Set(); + const quotes: string[] = []; + + for (const cs of chunkSummaries) { + stats.positive += cs.stats.positive; + stats.neutral += cs.stats.neutral; + stats.negative += cs.stats.negative; + stats.total += cs.stats.total; + + for (const p of cs.pros) { + const normalized = p.trim(); + if (normalized) prosSet.add(normalized); + } + + for (const c of cs.cons) { + const normalized = c.trim(); + if (normalized) consSet.add(normalized); + } + + for (const q of cs.quotes) { + if (quotes.length < 5) { + quotes.push(q); + } + } + } + + // If the model didn't fill stats.total correctly, fall back to the total # of lines + if (!stats.total) { + stats.total = totalReviews; + } + + // Calculate percentages + if (stats.total > 0) { + stats.positivePercent = Math.round((stats.positive / stats.total) * 100); + stats.neutralPercent = Math.round((stats.neutral / stats.total) * 100); + stats.negativePercent = Math.round((stats.negative / stats.total) * 100); + } + + const pros = Array.from(prosSet).slice(0, 8); + const cons = Array.from(consSet).slice(0, 8); + + return { + movieId, + pros, + cons, + stats, + quotes, + }; +} + +/** + * Final pass: take aggregated pros/cons/stats/quotes and ask the LLM to write a concise "overall". + */ +async function generateOverallFromAggregates( + aggregated: Omit, +): Promise { + const { movieId, pros, cons, stats, quotes } = aggregated; + + const systemPrompt = ` +You are an assistant that writes a SHORT "overall" paragraph summarizing the sentiment of user posts about a movie. + +You will receive aggregated pros, cons, sentiment stats, and a few sample quotes. +Return ONLY a concise paragraph (2–4 sentences). +Avoid major spoilers. +`.trim(); + + const userPrompt = ` +Movie ID: ${movieId} + +Sentiment stats: +- Positive: ${stats.positive} +- Neutral: ${stats.neutral} +- Negative: ${stats.negative} +- Total: ${stats.total} + +Pros (things people liked): +${pros.map(p => `- ${p}`).join('\n') || '(none)'} + +Cons (common complaints): +${cons.map(c => `- ${c}`).join('\n') || '(none)'} + +Representative quotes: +${quotes.map(q => `- "${q}"`).join('\n') || '(none)'} +`.trim(); + + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + }); + + const content = completion.choices[0]?.message?.content?.trim() ?? ''; + return content || 'Unable to generate an overall summary at this time.'; +} + +/** + * Main entry point: chunked, cached movie summary. + */ +export async function generateMovieSummary(movieId: string): Promise { + // 1) Check cache + const now = Date.now(); + const cached = summaryCache.get(movieId); + if (cached && cached.expiresAt > now) { + return cached.summary; + } + + // 2) Fetch post texts + const texts = await getReviewTextsForMovie(movieId); + + if (texts.length === 0) { + const summary: MovieSummary = { + movieId, + overall: 'There are no posts yet for this movie.', + pros: [], + cons: [], + stats: { + positive: 0, + neutral: 0, + negative: 0, + total: 0, + positivePercent: 0, + neutralPercent: 0, + negativePercent: 0, + }, + quotes: [], + }; + summaryCache.set(movieId, { summary, expiresAt: now + SUMMARY_TTL_MS }); + return summary; + } + + // 3) Chunk them + const chunks = chunkTexts(texts); + + // 4) Summarize each chunk in parallel (map step) + const chunkSummaries = await Promise.all( + chunks.map(chunk => summarizeChunk(movieId, chunk)), + ); + + // 5) Aggregate (reduce step) + const aggregatedWithoutOverall = aggregateChunkSummaries( + movieId, + chunkSummaries, + texts.length, + ); + + // 6) Final overall paragraph + const overall = await generateOverallFromAggregates(aggregatedWithoutOverall); + + const summary: MovieSummary = { + ...aggregatedWithoutOverall, + overall, + }; + + // 7) Cache & return + summaryCache.set(movieId, { summary, expiresAt: now + SUMMARY_TTL_MS }); + + return summary; +} diff --git a/backend/src/tests/api/comment.api.test.ts b/backend/src/tests/api/comment.api.test.ts index 4a8d17fd..ec4c9e25 100644 --- a/backend/src/tests/api/comment.api.test.ts +++ b/backend/src/tests/api/comment.api.test.ts @@ -11,6 +11,7 @@ jest.mock("../../services/db", () => { const ratings = new Map(); const posts = new Map(); const comments = new Map(); + const commentLikes = new Map(); const clone = (record: any) => (record ? { ...record } : record); @@ -164,6 +165,42 @@ jest.mock("../../services/db", () => { } }; + const commentLikeModel = { + create: jest.fn(async ({ data }: any) => { + const id = data.id ?? crypto.randomUUID(); + const record = { ...data, id }; + commentLikes.set(id, record); + return clone(record); + }), + findUnique: jest.fn(async ({ where }: any) => { + if (where.commentId_userId) { + const { commentId, userId } = where.commentId_userId; + const like = Array.from(commentLikes.values()).find( + (l) => l.commentId === commentId && l.userId === userId + ); + return like ? clone(like) : null; + } + if (where.id) { + return clone(commentLikes.get(where.id) ?? null); + } + return null; + }), + delete: jest.fn(async ({ where: { id } }: any) => { + const record = commentLikes.get(id); + ensureRecordExists(record); + commentLikes.delete(id); + return clone(record); + }), + count: jest.fn(async ({ where }: any = {}) => { + if (where?.commentId) { + return Array.from(commentLikes.values()).filter( + (like) => like.commentId === where.commentId + ).length; + } + return commentLikes.size; + }), + }; + const commentModel = { create: jest.fn(async ({ data }: any) => { const id = data.id ?? crypto.randomUUID(); @@ -197,10 +234,12 @@ jest.mock("../../services/db", () => { ); } - // Handle includes (UserProfile) - if (include?.UserProfile) { - const selectFields = include.UserProfile.select; - results = results.map((record) => { + // Handle includes (UserProfile and CommentLike) + results = results.map((record) => { + const result = { ...record }; + + if (include?.UserProfile) { + const selectFields = include.UserProfile.select; const userProfile = userProfiles.get(record.userId); const profileData: any = {}; if (selectFields) { @@ -210,9 +249,19 @@ jest.mock("../../services/db", () => { } } } - return { ...record, UserProfile: userProfile ? profileData : null }; - }); - } + result.UserProfile = userProfile ? profileData : null; + } + + if (include?.CommentLike) { + // Return all likes for this comment + const likes = Array.from(commentLikes.values()).filter( + (like) => like.commentId === record.id + ); + result.CommentLike = likes; + } + + return result; + }); return results.map(clone); }), @@ -249,11 +298,13 @@ jest.mock("../../services/db", () => { rating: ratingModel, post: postModel, comment: commentModel, + commentLike: commentLikeModel, $disconnect: jest.fn(async () => { userProfiles.clear(); ratings.clear(); posts.clear(); comments.clear(); + commentLikes.clear(); }), }, }; @@ -273,7 +324,6 @@ jest.mock("../../middleware/auth", () => ({ describe("Comment API Tests", () => { let app: express.Express; let testCommentId: string; - let testRatingId: string; let testPostId: string; const TEST_USER_ID = "123e4567-e89b-12d3-a456-426614174000"; @@ -307,7 +357,6 @@ describe("Comment API Tests", () => { } }); await prisma.post.deleteMany({ where: { userId: TEST_USER_ID } }); - await prisma.rating.deleteMany({ where: { userId: TEST_USER_ID } }); // Create test user profile await prisma.userProfile.upsert({ @@ -334,21 +383,11 @@ describe("Comment API Tests", () => { // Create a fresh comment for each test beforeEach(async () => { - // Create a new rating for each test - const rating = await prisma.rating.create({ - data: { - userId: TEST_USER_ID, - movieId: "test-movie-555", - stars: 5, - date: new Date(), - }, - }); - testRatingId = rating.id; - // Create a new post for each test const post = await prisma.post.create({ data: { userId: TEST_USER_ID, + movieId: "test-movie-555", type: "SHORT", content: "This is a test post!", createdAt: new Date(), @@ -360,7 +399,6 @@ describe("Comment API Tests", () => { const comment = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "This is a test comment!", createdAt: new Date(), @@ -371,26 +409,22 @@ describe("Comment API Tests", () => { // Clean up after each test afterEach(async () => { - // Delete comments first (they reference ratings and posts) + // Delete comments first (they reference posts) if (testCommentId) { await prisma.comment.deleteMany({ where: { OR: [ { id: testCommentId }, - { ratingId: testRatingId }, { postId: testPostId } ] } }).catch(() => {}); } - // Then delete posts and ratings + // Then delete posts if (testPostId) { await prisma.post.delete({ where: { id: testPostId } }).catch(() => {}); } - if (testRatingId) { - await prisma.rating.delete({ where: { id: testRatingId } }).catch(() => {}); - } }); // Clean up at the end @@ -404,7 +438,6 @@ describe("Comment API Tests", () => { } }); await prisma.post.deleteMany({ where: { userId: TEST_USER_ID } }); - await prisma.rating.deleteMany({ where: { userId: TEST_USER_ID } }); await prisma.$disconnect(); }); @@ -424,7 +457,6 @@ describe("Comment API Tests", () => { id: testCommentId, userId: TEST_USER_ID, content: "This is a test comment!", - ratingId: testRatingId, postId: testPostId, }); }); @@ -470,7 +502,6 @@ describe("Comment API Tests", () => { const otherUserComment = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Another user's comment", createdAt: new Date(), @@ -561,7 +592,6 @@ describe("Comment API Tests", () => { const otherUserComment = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Another user's comment", createdAt: new Date(), @@ -593,7 +623,6 @@ describe("Comment API Tests", () => { const childComment1 = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "First child comment", parentId: testCommentId, @@ -604,7 +633,6 @@ describe("Comment API Tests", () => { const childComment2 = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Second child comment", parentId: testCommentId, @@ -640,7 +668,6 @@ describe("Comment API Tests", () => { const payload = { content: "This is a reply to the parent comment", parentId: testCommentId, - ratingId: testRatingId, postId: testPostId, }; @@ -673,7 +700,6 @@ describe("Comment API Tests", () => { const reply1 = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "First reply", parentId: testCommentId, @@ -684,7 +710,6 @@ describe("Comment API Tests", () => { const reply2 = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Second reply", parentId: testCommentId, @@ -739,7 +764,6 @@ describe("Comment API Tests", () => { const reply = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "A reply", parentId: testCommentId, @@ -765,7 +789,6 @@ describe("Comment API Tests", () => { const childComment = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Child comment", parentId: testCommentId, @@ -777,7 +800,6 @@ describe("Comment API Tests", () => { const grandchildComment = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Grandchild comment", parentId: childComment.id, @@ -812,7 +834,6 @@ describe("Comment API Tests", () => { const childComment = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Child comment", parentId: testCommentId, @@ -842,7 +863,6 @@ describe("Comment API Tests", () => { const childComment = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Child comment", parentId: testCommentId, @@ -886,7 +906,6 @@ describe("Comment API Tests", () => { const separateParent = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Separate parent comment", createdAt: new Date(), @@ -897,7 +916,6 @@ describe("Comment API Tests", () => { const child1 = await prisma.comment.create({ data: { userId: TEST_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Child 1", parentId: separateParent.id, @@ -908,7 +926,6 @@ describe("Comment API Tests", () => { const child2 = await prisma.comment.create({ data: { userId: OTHER_USER_ID, - ratingId: testRatingId, postId: testPostId, content: "Child 2", parentId: separateParent.id, @@ -1056,82 +1073,4 @@ describe("Comment API Tests", () => { await prisma.comment.delete({ where: { id: replyComment.id } }); }); }); - - describe("GET /api/comments/rating/:ratingId (getCommentsTree)", () => { - it("should retrieve all comments for a rating with user profile info", async () => { - const res = await request(app) - .get(`/api/comments/rating/${testRatingId}`) - .set(authHeader()) - .expect(HTTP_STATUS.OK) - .expect("Content-Type", /json/); - - expect(res.body).toHaveProperty("message", "Comments retrieved"); - expect(res.body).toHaveProperty("comments"); - expect(Array.isArray(res.body.comments)).toBe(true); - - // The test comment is associated with both the post and rating - const testComment = res.body.comments.find((c: any) => c.id === testCommentId); - expect(testComment).toBeDefined(); - expect(testComment.ratingId).toBe(testRatingId); - }); - - it("should return empty array for rating with no comments", async () => { - // Create a new rating with no comments - const emptyRating = await prisma.rating.create({ - data: { - userId: TEST_USER_ID, - movieId: "empty-rating-movie", - stars: 4, - date: new Date(), - }, - }); - - const res = await request(app) - .get(`/api/comments/rating/${emptyRating.id}`) - .set(authHeader()) - .expect(HTTP_STATUS.OK); - - expect(res.body.comments).toEqual([]); - - // Clean up - await prisma.rating.delete({ where: { id: emptyRating.id } }); - }); - - it("should only return comments for the specific rating", async () => { - // Create another rating with its own comment - const otherRating = await prisma.rating.create({ - data: { - userId: TEST_USER_ID, - movieId: "other-movie", - stars: 3, - date: new Date(), - }, - }); - - const otherComment = await prisma.comment.create({ - data: { - userId: TEST_USER_ID, - ratingId: otherRating.id, - content: "Comment on other rating", - createdAt: new Date(), - }, - }); - - // Fetch comments for original rating - const res = await request(app) - .get(`/api/comments/rating/${testRatingId}`) - .set(authHeader()) - .expect(HTTP_STATUS.OK); - - // Should not include the other rating's comment - const otherCommentInResponse = res.body.comments.find( - (c: any) => c.id === otherComment.id - ); - expect(otherCommentInResponse).toBeUndefined(); - - // Clean up - await prisma.comment.delete({ where: { id: otherComment.id } }); - await prisma.rating.delete({ where: { id: otherRating.id } }); - }); - }); }); diff --git a/backend/src/tests/api/feed.api.test.ts b/backend/src/tests/api/feed.api.test.ts index b5978300..1b7554e5 100644 --- a/backend/src/tests/api/feed.api.test.ts +++ b/backend/src/tests/api/feed.api.test.ts @@ -100,6 +100,7 @@ describe("GET /api/feed", () => { data: { id: "post-feed-1", userId: FOLLOWED_USER_ID, + movieId: "movie-10", // All posts must reference a movie content: "New post from followed user", type: "SHORT", createdAt: new Date(), diff --git a/backend/src/tests/api/users.api.test.ts b/backend/src/tests/api/users.api.test.ts index 9eb95f6d..684da104 100644 --- a/backend/src/tests/api/users.api.test.ts +++ b/backend/src/tests/api/users.api.test.ts @@ -218,7 +218,7 @@ describe("User Profile API Tests", () => { .expect(HTTP_STATUS.OK); // Verify profile was created and updated - expect(res.body.data).toMatchObject({ + expect(res.body.userProfile).toMatchObject({ username: "newuser", country: "USA", onboardingCompleted: true, @@ -250,9 +250,13 @@ describe("User Profile API Tests", () => { .set(authHeader()) .expect(HTTP_STATUS.OK); - expect(res.body).toHaveProperty("message", "Profile updated"); - expect(res.body).toHaveProperty("data"); - expect(res.body.data).toMatchObject(payload); + expect(res.body).toHaveProperty("userProfile"); + expect(res.body.userProfile).toMatchObject({ + username: payload.username, + secondaryLanguage: payload.secondaryLanguage, + favoriteGenres: payload.favoriteGenres, + favoriteMovies: payload.favoriteMovies + }); }); }); diff --git a/backend/src/tests/unit/post.unit.test.ts b/backend/src/tests/unit/post.unit.test.ts index 96bbaab5..c2fa2893 100644 --- a/backend/src/tests/unit/post.unit.test.ts +++ b/backend/src/tests/unit/post.unit.test.ts @@ -42,8 +42,10 @@ describe("Post Controller Unit Tests", () => { it("should create a post successfully", async () => { const mockUserId = "user-123"; + const mockMovieId = "tt1234567"; const mockPostData = { userId: mockUserId, + movieId: mockMovieId, // Required field content: "This is a test post", type: "SHORT", }; @@ -53,17 +55,24 @@ describe("Post Controller Unit Tests", () => { const mockCreatedPost = { id: "post-123", userId: mockUserId, + movieId: mockMovieId, content: "This is a test post", type: "SHORT", - votes: 0, - parentPostId: null, + stars: null, + spoiler: false, + tags: [], + repostedPostId: null, imageUrls: [], createdAt: new Date(), - updatedAt: new Date(), UserProfile: { userId: mockUserId, username: "testuser", }, + movie: { + movieId: mockMovieId, + title: "Test Movie", + imageUrl: null, + }, }; jest.spyOn(prisma.post, "create").mockResolvedValueOnce(mockCreatedPost as any); @@ -81,6 +90,7 @@ describe("Post Controller Unit Tests", () => { it("should return 400 for invalid postType", async () => { mockRequest.body = { userId: "user-123", + movieId: "tt1234567", // Required field content: "Test", type: "INVALID_TYPE", }; @@ -126,18 +136,25 @@ describe("Post Controller Unit Tests", () => { const mockPost = { id: mockPostId, userId: "user-123", + movieId: "tt1234567", content: "Test post", type: "SHORT", - votes: 5, - parentPostId: null, + stars: null, + spoiler: false, + tags: [], + repostedPostId: null, imageUrls: [], createdAt: new Date(), - updatedAt: new Date(), UserProfile: { userId: "user-123", username: "testuser", }, - Replies: [{ postId: "reply-1" }, { postId: "reply-2" }], + movie: { + movieId: "tt1234567", + title: "Test Movie", + imageUrl: null, + }, + other_Post: [{ id: "repost-1" }, { id: "repost-2" }], PostReaction: [ { id: "reaction-1", reactionType: "SPICY" }, { id: "reaction-2", reactionType: "BLOCKBUSTER" }, @@ -152,12 +169,29 @@ describe("Post Controller Unit Tests", () => { expect(responseObject.json).toHaveBeenCalledWith({ message: "Post found successfully", - data: { - ...mockPost, - likeCount: 5, + data: expect.objectContaining({ + id: mockPostId, + userId: "user-123", + content: "Test post", + type: "SHORT", + UserProfile: expect.objectContaining({ + userId: "user-123", + username: "testuser", + }), + Comment: [], + PostReaction: expect.arrayContaining([ + expect.objectContaining({ reactionType: "SPICY" }), + expect.objectContaining({ reactionType: "BLOCKBUSTER" }), + expect.objectContaining({ reactionType: "STAR_STUDDED" }), + ]), + Reposts: expect.arrayContaining([ + expect.objectContaining({ id: "repost-1" }), + expect.objectContaining({ id: "repost-2" }), + ]), + reactionCount: 3, commentCount: 0, - replyCount: 2, - }, + repostCount: 2, + }), }); }); }); @@ -183,17 +217,24 @@ describe("Post Controller Unit Tests", () => { const mockUpdatedPost = { id: mockPostId, userId: "user-123", + movieId: "tt1234567", content: "Updated content", type: "SHORT", - votes: 0, - parentPostId: null, + stars: null, + spoiler: false, + tags: [], + repostedPostId: null, imageUrls: [], createdAt: new Date(), - updatedAt: new Date(), UserProfile: { userId: "user-123", username: "testuser", }, + movie: { + movieId: "tt1234567", + title: "Test Movie", + imageUrl: null, + }, }; jest.spyOn(prisma.post, "update").mockResolvedValueOnce(mockUpdatedPost as any); diff --git a/backend/src/tests/unit/search.unit.test.ts b/backend/src/tests/unit/search.unit.test.ts index 918e744a..7d40c955 100644 --- a/backend/src/tests/unit/search.unit.test.ts +++ b/backend/src/tests/unit/search.unit.test.ts @@ -174,10 +174,24 @@ describe("Search Controller Unit Tests", () => { { userId: "user-uuid", username: "john_doe", - preferredCategories: ["Action"], - preferredLanguages: ["English"], + onboardingCompleted: null, + primaryLanguage: null, + secondaryLanguage: [], + profilePicture: null, + country: null, + city: null, + displayName: null, + favoriteGenres: [], favoriteMovies: [], + bio: null, + moviesToWatch: [], + moviesCompleted: [], + eventsSaved: [], + eventsAttended: [], + privateAccount: null, + spoiler: null, createdAt: new Date(), + updatedAt: null, }, ]; @@ -191,7 +205,13 @@ describe("Search Controller Unit Tests", () => { type: "users", query: "john", count: 1, - results: mockUsers, + results: expect.arrayContaining([ + expect.objectContaining({ + userId: "user-uuid", + username: "john_doe", + favoriteMovies: [], + }) + ]), }) ); }); diff --git a/backend/src/tests/unit/userProfile.unit.test.ts b/backend/src/tests/unit/userProfile.unit.test.ts new file mode 100644 index 00000000..72a8f250 --- /dev/null +++ b/backend/src/tests/unit/userProfile.unit.test.ts @@ -0,0 +1,51 @@ +import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user'; + +describe('User profile mapping', () => { + it('maps DB payload to API shape including displayName/bio', () => { + const now = new Date(); + const api = mapUserProfileDbToApi({ + userId: 'u-1', + username: 'user1', + onboardingCompleted: true, + primaryLanguage: 'English', + secondaryLanguage: ['Spanish'], + profilePicture: null, + country: 'USA', + city: 'NYC', + favoriteGenres: ['Drama'], + favoriteMovies: ['tt0111161'], + displayName: 'User One', + bio: 'Cinephile', + privateAccount: false, + spoiler: false, + createdAt: now, + updatedAt: now, + }); + + expect(api.displayName).toBe('User One'); + expect(api.bio).toBe('Cinephile'); + }); + + it('builds patch only for provided fields, preserving displayName when not sent', () => { + const data = mapUserProfilePatchToUpdateData({ + username: 'newUser', + favoriteGenres: ['Action'], + }); + + expect(data).toHaveProperty('username', 'newUser'); + expect(data).toHaveProperty('favoriteGenres', ['Action']); + expect(data).not.toHaveProperty('displayName'); + }); + + it('sets displayName and event lists when provided in patch', () => { + const data = mapUserProfilePatchToUpdateData({ + displayName: 'Shown Name', + eventsSaved: ['a', 'b'], + eventsAttended: ['c'], + }); + + expect(data).toHaveProperty('displayName', 'Shown Name'); + expect(data).toHaveProperty('eventsSaved', ['a', 'b']); + expect(data).toHaveProperty('eventsAttended', ['c']); + }); +}); diff --git a/backend/src/tests/unit/userProfileEvents.test.ts b/backend/src/tests/unit/userProfileEvents.test.ts new file mode 100644 index 00000000..1aa6f07c --- /dev/null +++ b/backend/src/tests/unit/userProfileEvents.test.ts @@ -0,0 +1,58 @@ +import { mapUserProfileDbToApi, mapUserProfilePatchToUpdateData } from '../../controllers/user.js'; + +describe('UserProfile events fields', () => { + const baseProfile = { + userId: 'user-123', + username: 'tester', + onboardingCompleted: true, + primaryLanguage: 'English', + secondaryLanguage: ['English'], + profilePicture: null, + country: null, + city: null, + favoriteGenres: [], + favoriteMovies: [], + displayName: null, + bio: null, + eventsSaved: ['event-a', 'event-b'], + eventsAttended: ['event-c'], + privateAccount: false, + spoiler: false, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + }; + + it('maps saved and attended events through to API shape', () => { + const result = mapUserProfileDbToApi(baseProfile); + expect(result.eventsSaved).toEqual(['event-a', 'event-b']); + expect(result.eventsAttended).toEqual(['event-c']); + }); + + it('defaults missing events arrays to empty arrays', () => { + const result = mapUserProfileDbToApi({ + ...baseProfile, + eventsSaved: null, + eventsAttended: undefined, + }); + expect(result.eventsSaved).toEqual([]); + expect(result.eventsAttended).toEqual([]); + }); + + it('applies patch updates for eventsSaved/eventsAttended', () => { + const patch = mapUserProfilePatchToUpdateData({ + eventsSaved: ['one', 'two'], + eventsAttended: ['three'], + }); + expect(patch.eventsSaved).toEqual(['one', 'two']); + expect(patch.eventsAttended).toEqual(['three']); + }); + + it('clears events arrays when patch sets them to null', () => { + const patch = mapUserProfilePatchToUpdateData({ + eventsSaved: null, + eventsAttended: null, + }); + expect(patch.eventsSaved).toEqual([]); + expect(patch.eventsAttended).toEqual([]); + }); +}); diff --git a/backend/src/types/apiTypes.ts b/backend/src/types/apiTypes.ts index c83421d5..8db09d81 100644 --- a/backend/src/types/apiTypes.ts +++ b/backend/src/types/apiTypes.ts @@ -51,8 +51,16 @@ export type UpdateUserProfileInput = { secondaryLanguage?: string[]; country?: string; city?: string; + displayName?: string | null; favoriteGenres?: string[]; favoriteMovies?: string[]; + bio?: string | null; + privateAccount?: boolean; + spoiler?: boolean; + bookmarkedToWatch?: string[]; + bookmarkedWatched?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; }; export type UpdateUserProfileResponse = { message: string; data: UserProfile }; @@ -103,10 +111,14 @@ export type GetPostByIdResponse = { export type CreatePostInput = { userId: string; + movieId: string; content: string; type?: 'SHORT' | 'LONG'; + stars?: number; + spoiler?: boolean; + tags?: string[]; imageUrls?: string[]; - parentPostId?: string; + repostedPostId?: string; // Optional reference to original post if this is a repost }; export type CreatePostResponse = { @@ -117,6 +129,9 @@ export type CreatePostResponse = { export type UpdatePostInput = { content?: string; type?: 'SHORT' | 'LONG'; + stars?: number; + spoiler?: boolean; + tags?: string[]; imageUrls?: string[]; }; @@ -206,4 +221,4 @@ export type GetEventAttendeesResponse = { }>; counts: RsvpCounts; }; -}; \ No newline at end of file +}; diff --git a/backend/src/types/models.ts b/backend/src/types/models.ts index 5bc7e1ee..37c188f5 100644 --- a/backend/src/types/models.ts +++ b/backend/src/types/models.ts @@ -26,10 +26,18 @@ export type UserProfile = { profilePicture: string | null; country: string | null; city: string | null; + displayName?: string | null; favoriteGenres: string[]; favoriteMovies: string[]; + bio?: string | null; + eventsSaved: string[]; + eventsAttended: string[]; + privateAccount: boolean; + spoiler: boolean; createdAt: Date; updatedAt: Date; + bookmarkedToWatch: string[]; + bookmarkedWatched: string[]; }; export type Rating = { @@ -41,11 +49,6 @@ export type Rating = { tags: string[]; date: string; votes: number; - UserProfile?: { - userId: string; - username: string | null; - }; - threadedComments?: unknown[]; }; export type Comment = { @@ -126,21 +129,31 @@ export type ReactionType = 'SPICY' | 'STAR_STUDDED' | 'THOUGHT_PROVOKING' | 'BLO export type Post = { id: string; userId: string; + movieId: string; content: string; type: 'SHORT' | 'LONG'; + stars: number | null; + spoiler: boolean; + tags: string[]; createdAt: string; imageUrls: string[]; - parentPostId: string | null; + repostedPostId: string | null; // References the original post being shared UserProfile?: { userId: string; username: string | null; }; + movie?: { + movieId: string; + title: string | null; + imageUrl: string | null; + }; + OriginalPost?: Post; // The post that was reposted (if this is a repost) PostReaction?: Array<{ id: string; userId: string; reactionType: ReactionType }>; Comment?: Array<{ id: string }>; - Replies?: Array<{ id: string }>; + Reposts?: Array<{ id: string }>; // Posts that have reposted this one // Computed fields commentCount?: number; - replyCount?: number; + repostCount?: number; reactionCount?: number; reactionCounts?: Record; userReactions?: ReactionType[]; @@ -157,3 +170,65 @@ export type PostReaction = { username: string | null; }; }; + +// backend/src/types/summary.ts + +export type SentimentStats = { + positive: number; + neutral: number; + negative: number; + total: number; + positivePercent: number; + neutralPercent: number; + negativePercent: number; +}; + +export type MovieSummary = { + movieId: string; + overall: string; + pros: string[]; + cons: string[]; + stats: SentimentStats; + quotes: string[]; +}; + +export type GetMovieSummaryEnvelope = { + summary: MovieSummary; +}; + +export type PostFormData = { + movieId: string; + content: string; + type: 'SHORT' | 'LONG'; + stars?: number | null; + spoiler?: boolean; + tags?: string[]; + imageUrls?: string[]; + repostedPostId?: string | null; +}; + +export type LongPostFormData = { + movieId: string; + content: string; + rating?: number; + title?: string; + subtitle?: string; + tags?: string[]; + spoiler?: boolean; + imageUrls?: string[]; +}; + +export type ShortPostFormData = { + movieId: string; + content: string; + spoiler?: boolean; + imageUrls?: string[]; +}; + +// For chunk-level aggregation +export type ChunkSummary = { + pros: string[]; + cons: string[]; + stats: SentimentStats; + quotes: string[]; +}; diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml index 546c7133..08f2f6d5 100644 --- a/docker-compose.ci.yml +++ b/docker-compose.ci.yml @@ -14,6 +14,7 @@ services: - AWS_SECRET_ACCESS_KEY=dummy-secret-access-key-for-ci-tests - AWS_REGION=dummy-region-for-ci-tests - AWS_BUCKET_NAME=dummy-bucket-name-for-ci-tests + - OPENAI_API_KEY=dummy-openai-api-key-for-ci-tests depends_on: postgres: condition: service_healthy diff --git a/eas.json b/eas.json new file mode 100644 index 00000000..f4001c54 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 16.28.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/frontend/app.config.js b/frontend/app.config.js index d29763f8..f8282862 100644 --- a/frontend/app.config.js +++ b/frontend/app.config.js @@ -1,9 +1,12 @@ export default { expo: { + scheme: 'cinecircle', + deepLinking: true, extra: { supabaseUrl: process.env.SUPABASE_URL, supabaseAnonKey: process.env.SUPABASE_ANON_KEY, apiBaseUrl: process.env.API_BASE_URL, + supabaseRedirectUrl: "cinecircle://username", eas: { projectId: '6a01b6dd-ef8f-47b0-b777-9a03d0ed0453', }, diff --git a/frontend/app/(auth)/login.tsx b/frontend/app/(auth)/login.tsx index 2dd69240..ff71fd0c 100644 --- a/frontend/app/(auth)/login.tsx +++ b/frontend/app/(auth)/login.tsx @@ -1,10 +1,12 @@ import NextButton from "../../components/NextButton"; import TextInputComponent from "../../components/TextInputComponent"; import { useState } from 'react'; -import { Text, View, StyleSheet, Dimensions } from "react-native"; +import { Text, View, StyleSheet, Dimensions, TouchableOpacity } from "react-native"; import { supabase } from '../../lib/supabase'; +import BackButton from "../../components/BackButton"; +import { router } from "expo-router"; -const { height } = Dimensions.get('window'); +const { width, height } = Dimensions.get('window'); const LoginForm = () => { const [email, setEmail] = useState(''); @@ -25,7 +27,6 @@ const LoginForm = () => { }; const handleLogin = async () => { - // Validate inputs first const validation = validateInputs(); if (!validation.valid) { setMessage(validation.error || 'Invalid input'); @@ -34,26 +35,28 @@ const LoginForm = () => { setLoading(true); setMessage(''); - + try { const { data, error } = await supabase.auth.signInWithPassword({ - email, + email: email.trim(), password, }); if (error) { + console.error('Full error object:', JSON.stringify(error, null, 2)); + console.error('Error name:', error.name); + console.error('Error status:', error.status); setMessage(`Login error: ${error.message}`); return; } if (data.user) { + console.log('Login successful:', data.user.id); setMessage('Signed in successfully!'); - // OnboardingGuard will handle navigation } - } catch (error) { + console.error('Caught exception:', error); setMessage('An unexpected error occurred'); - console.error('Login error:', error); } finally { setLoading(false); } @@ -61,6 +64,10 @@ const LoginForm = () => { return ( + + router.back()}/> + + { onChangeText={setEmail} value={email} keyboardType="email-address" + autoComplete="email" + textContentType="emailAddress" /> { onChangeText={setPassword} value={password} secureTextEntry={true} + autoComplete="current-password" + textContentType="password" /> {message || ' '} @@ -98,6 +109,12 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: 20, }, + backButtonContainer: { + position: 'absolute', + top: height * 0.06, + left: width * 0.05, + zIndex: 10, + }, inputWrapper: { width: '100%', marginTop: '30%', diff --git a/frontend/app/(auth)/signup.tsx b/frontend/app/(auth)/signup.tsx index bbd15ee8..116f91a6 100644 --- a/frontend/app/(auth)/signup.tsx +++ b/frontend/app/(auth)/signup.tsx @@ -2,8 +2,9 @@ import NextButton from "../../components/NextButton"; import TextInputComponent from "../../components/TextInputComponent"; import { router } from "expo-router" import { useState } from 'react'; -import { Text, View, StyleSheet, Dimensions } from "react-native"; +import { Text, View, StyleSheet, Dimensions, TouchableOpacity } from "react-native"; import { supabase } from '../../lib/supabase'; +import BackButton from "../../components/BackButton"; const { width, height } = Dimensions.get('window'); @@ -46,7 +47,6 @@ const SignUp = () => { } if (data.user) { - // OnboardingGuard will handle navigation return { success: true }; } @@ -61,6 +61,9 @@ const SignUp = () => { const { data, error } = await supabase.auth.signUp({ email, password, + options: { + emailRedirectTo: "cinecircle://username", + } }); if (error) { @@ -106,7 +109,6 @@ const SignUp = () => { if (signInResult.success) { setMessage('Signed in successfully!'); - // OnboardingGuard will redirect based on profile.onboardingCompleted return; } @@ -116,11 +118,9 @@ const SignUp = () => { if (signUpResult.success) { if (signUpResult.emailConfirmed) { setMessage('Account created successfully!'); - // OnboardingGuard will redirect to onboarding } else { // Need email confirmation setMessage('Please check your email to confirm your account'); - router.replace("/(auth)/confirmEmail"); } } else { // Sign up failed @@ -142,6 +142,9 @@ const SignUp = () => { return ( + + router.back()}/> + { onChangeText={setEmail} value={email} keyboardType="email-address" + autoComplete="off" + textContentType="none" /> { onChangeText={setPassword} value={password} secureTextEntry={true} + autoComplete="off" + textContentType="none" /> { onChangeText={setConfirmPassword} value={confirmPassword} secureTextEntry={true} + autoComplete="off" + textContentType="none" /> {'*'+ message|| ' '} @@ -189,6 +198,12 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingHorizontal: width * 0.05, }, + backButtonContainer: { + position: 'absolute', + top: height * 0.06, + left: width * 0.05, + zIndex: 10, + }, inputWrapper: { width: '100%', marginTop: '15%', diff --git a/frontend/app/(auth)/splash.tsx b/frontend/app/(auth)/splash.tsx new file mode 100644 index 00000000..10cab5ae --- /dev/null +++ b/frontend/app/(auth)/splash.tsx @@ -0,0 +1,104 @@ +// app/(auth)/splash.tsx (or app/index.tsx if you want this as the very first screen) + +import React, { useEffect, useRef, useState } from 'react'; +import { + View, + Animated, + StyleSheet, + useWindowDimensions, + ImageSourcePropType, +} from 'react-native'; +import { router } from 'expo-router'; +import { Easing } from 'react-native'; // or from 'react-native' if you prefer + +// 👇 Replace these with your real 12 PNG imports +const SLIDES: ImageSourcePropType[] = [ + require('../../assets/SplashScreen09.png'), + require('../../assets/SplashScreen08.png'), + require('../../assets/SplashScreen07.png'), + require('../../assets/SplashScreen06.png'), + require('../../assets/SplashScreen05.png'), + require('../../assets/SplashScreen04.png'), + require('../../assets/SplashScreen03.png'), + require('../../assets/SplashScreen02.png'), + require('../../assets/SplashScreen01.png'), +]; + +const FADE_DURATION_MS = 700; // how long each fade takes +const HOLD_DURATION_MS = 600; // how long each slide stays fully visible + +export default function SplashSequenceScreen() { + const { width, height } = useWindowDimensions(); + const [index, setIndex] = useState(0); + const opacity = useRef(new Animated.Value(0)).current; + + useEffect(() => { + let isMounted = true; + + const runSequence = async () => { + // show each slide one by one + for (let i = 0; i < SLIDES.length; i++) { + if (!isMounted) return; + + setIndex(i); + opacity.setValue(0); + + // fade in + await new Promise(resolve => { + Animated.timing(opacity, { + toValue: 1, + duration: FADE_DURATION_MS, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(() => resolve()); + }); + + // hold fully visible + await new Promise(resolve => + setTimeout(resolve, HOLD_DURATION_MS) + ); + } + + // done → go to welcome + if (isMounted) { + router.replace('/(auth)/welcome'); + } + }; + + runSequence(); + + return () => { + isMounted = false; + opacity.stopAnimation(); + }; + }, [opacity]); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', // match your welcome screen bg + justifyContent: 'center', + alignItems: 'center', + }, + image: { + // width/height are set dynamically from useWindowDimensions + }, +}); diff --git a/frontend/app/(auth)/welcome.tsx b/frontend/app/(auth)/welcome.tsx index 845ae5c7..0afff7cf 100644 --- a/frontend/app/(auth)/welcome.tsx +++ b/frontend/app/(auth)/welcome.tsx @@ -1,41 +1,116 @@ -import { Text, Image, View, useWindowDimensions } from 'react-native' -import { router } from 'expo-router' -import logo from '../../assets/icon.png' -import NextButton from '../../components/NextButton'; -import { styles } from '../../styles/Welcome.styles' - -export default function welcome () { - const { width, height } = useWindowDimensions(); - const go = (to: string) => router.push(to); - - return ( - - - - CineCircle - - - - go("/(auth)/login")} - size="large" - variation='variation1' - /> - go("/(auth)/signup")} - size="large" - /> - - - ) -} \ No newline at end of file +import React from 'react'; +import { + Text, + Image, + View, + SafeAreaView, + StyleSheet, + TouchableOpacity, + useWindowDimensions, +} from 'react-native'; +import { router } from 'expo-router'; +import logo from '../../assets/Logo2x.png'; + +export default function Welcome() { + const { width, height } = useWindowDimensions(); + const go = (to: string) => router.push(to); + + return ( + + {/* Logo + title */} + + + + CineCircle + + + + {/* Smaller buttons, higher up */} + + go('/(auth)/login')} + style={styles.primaryButton} + > + Log-In + + + go('/(auth)/signup')} + style={styles.secondaryButton} + > + Sign Up + + + + ); +} + +const PRIMARY = '#A53A1A'; +const PRIMARY_LIGHT = '#F7D5CD'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + content: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + + title: { + fontWeight: '700', + color: PRIMARY, + textAlign: 'center', + }, + + buttonContainer: { + width: '100%', + alignItems: 'center', + marginBottom: 80, // ← raises buttons higher + gap: 14, + }, + + primaryButton: { + width: '75%', // ← smaller width + paddingVertical: 14, + borderRadius: 14, + backgroundColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + }, + + primaryButtonText: { + color: '#FFFFFF', + fontSize: 18, + fontWeight: '600', + }, + + secondaryButton: { + width: '75%', // ← smaller width matches log-in + paddingVertical: 14, + borderRadius: 14, + backgroundColor: PRIMARY_LIGHT, + borderWidth: 2, + borderColor: PRIMARY, + alignItems: 'center', + justifyContent: 'center', + }, + + secondaryButtonText: { + color: PRIMARY, + fontSize: 18, + fontWeight: '600', + }, +}); diff --git a/frontend/app/(onboarding)/citySelect.tsx b/frontend/app/(onboarding)/citySelect.tsx index 220337f3..3f27d2b5 100644 --- a/frontend/app/(onboarding)/citySelect.tsx +++ b/frontend/app/(onboarding)/citySelect.tsx @@ -1,8 +1,9 @@ -import { View, StyleSheet, Dimensions } from 'react-native'; +import { View, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; import { useState } from 'react'; import TextInputComponent from '../../components/TextInputComponent'; import NextButton from '../../components/NextButton'; +import BackButton from '../../components/BackButton'; import { useOnboarding } from './_layout'; const { width, height } = Dimensions.get('window'); @@ -18,6 +19,9 @@ export default function CitySelect() { return ( + + router.back()}/> + + + router.back()}/> + + + router.back()}/> + What genres do you like? Select at least one @@ -71,6 +75,12 @@ const styles = StyleSheet.create({ width: '100%', flex: 1, }, + backButtonContainer: { + position: 'absolute', + top: height * 0.06, + left: width * 0.05, + zIndex: 10, + }, buttonContainer: { width: '100%', marginTop: 'auto', diff --git a/frontend/app/(onboarding)/primaryLanguageSelect.tsx b/frontend/app/(onboarding)/primaryLanguageSelect.tsx index 4d24aaf9..efd0e00d 100644 --- a/frontend/app/(onboarding)/primaryLanguageSelect.tsx +++ b/frontend/app/(onboarding)/primaryLanguageSelect.tsx @@ -1,7 +1,8 @@ -import { View, StyleSheet, Dimensions } from 'react-native'; +import { View, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; import { useState } from 'react'; import NextButton from '../../components/NextButton'; +import BackButton from '../../components/BackButton'; import { useOnboarding } from './_layout'; import DropdownSelect from '../../components/dropdownSelect'; @@ -25,6 +26,9 @@ export default function PrimaryLanguageSelect() { return ( + + router.back()}/> + + + router.back()}/> + Profile picture coming soon! We'll add this feature later @@ -46,6 +50,12 @@ const styles = StyleSheet.create({ marginTop: 'auto', alignItems: 'center', }, + backButtonContainer: { + position: 'absolute', + top: height * 0.06, + left: width * 0.05, + zIndex: 10, + }, title: { fontSize: width * 0.06, fontWeight: '500', diff --git a/frontend/app/(onboarding)/secondaryLanguageSelect.tsx b/frontend/app/(onboarding)/secondaryLanguageSelect.tsx index 1f369f7c..0deb27b1 100644 --- a/frontend/app/(onboarding)/secondaryLanguageSelect.tsx +++ b/frontend/app/(onboarding)/secondaryLanguageSelect.tsx @@ -1,7 +1,8 @@ -import { View, Text, ScrollView, StyleSheet, Dimensions } from 'react-native'; +import { View, Text, ScrollView, StyleSheet, Dimensions, TouchableOpacity } from 'react-native'; import { router } from 'expo-router'; import { useState } from 'react'; import NextButton from '../../components/NextButton'; +import BackButton from '../../components/BackButton'; import Tag from '../../components/Tag'; import { useOnboarding } from './_layout'; @@ -35,6 +36,9 @@ export default function SecondaryLanguageSelect() { return ( + + router.back()}/> + Any other languages? (Optional) @@ -75,6 +79,12 @@ const styles = StyleSheet.create({ marginTop: 'auto', alignItems: 'center', }, + backButtonContainer: { + position: 'absolute', + top: height * 0.06, + left: width * 0.05, + zIndex: 10, + }, title: { fontSize: width * 0.06, fontWeight: '500', diff --git a/frontend/app/(tabs)/post.tsx b/frontend/app/(tabs)/post.tsx index e79cf8be..63774d8e 100644 --- a/frontend/app/(tabs)/post.tsx +++ b/frontend/app/(tabs)/post.tsx @@ -1,4 +1,17 @@ -import PostScreen from '../../screen/PostScreen'; +import React from "react"; +import { router } from "expo-router"; +import CreatePostModal from "../../screen/createPostModal"; + export default function PostRoute() { - return ; + return ( + { + router.push({ + pathname: "/form", + params: { type }, + }); + }} + onClose={() => router.back()} + /> + ); } \ No newline at end of file diff --git a/frontend/app/_layout.tsx b/frontend/app/_layout.tsx index ef2c6d95..c7e87a2c 100644 --- a/frontend/app/_layout.tsx +++ b/frontend/app/_layout.tsx @@ -4,9 +4,48 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import { AuthProvider, useAuth } from '../context/AuthContext'; import { View, Text, ActivityIndicator } from 'react-native'; import tw from 'twrnc'; +import { supabase } from '../lib/supabase'; +import { useURL } from 'expo-linking'; +import { useEffect } from 'react'; function RootNavigator() { const { user, profile, loading, profileLoading } = useAuth(); + const url = useURL(); + + useEffect(() => { + if (!url) return; + + console.log("Deep link received:", url); + + const hashIndex = url.indexOf('#'); + + if (hashIndex === -1) { + console.log("No hash detected in URL."); + return; + } + + // get everything after # + const hash = url.substring(hashIndex + 1); + const params = Object.fromEntries(new URLSearchParams(hash)); + + const access_token = params.access_token; + const refresh_token = params.refresh_token; + + + if (access_token && refresh_token) { + supabase.auth + .setSession({ access_token, refresh_token }) + .then(({ data, error }) => { + if (error) console.error("Session set error:", error); + else console.log("Session restored:", data.session); + + }); + } else { + console.log("No tokens found in hash params."); + } + }, [url]); + + // Show loading screen while checking auth if (loading || profileLoading) { @@ -51,6 +90,7 @@ function RootNavigator() { + diff --git a/frontend/app/commentSection/_layout.tsx b/frontend/app/commentSection/_layout.tsx new file mode 100644 index 00000000..a3210ca7 --- /dev/null +++ b/frontend/app/commentSection/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from 'expo-router'; + +export default function CommentSectionLayout() { + return ( + + + + ); +} diff --git a/frontend/app/commentSection/_types.ts b/frontend/app/commentSection/_types.ts new file mode 100644 index 00000000..356aa10b --- /dev/null +++ b/frontend/app/commentSection/_types.ts @@ -0,0 +1,16 @@ +export type ApiComment = { + id: string; + userId: string; + ratingId?: string | null; + postId?: string | null; + parentId?: string | null; + content: string; + createdAt: string; + likeCount: number; + liked: boolean; + UserProfile?: { + userId: string; + username: string | null; + profilePicture: string | null; + } | null; + }; \ No newline at end of file diff --git a/frontend/app/commentSection/_utils.ts b/frontend/app/commentSection/_utils.ts new file mode 100644 index 00000000..f61fe55c --- /dev/null +++ b/frontend/app/commentSection/_utils.ts @@ -0,0 +1,102 @@ +import type { ApiComment } from './_types'; + +export type CommentNode = ApiComment & { replies: CommentNode[] }; + +export function buildCommentTree(flat: ApiComment[]): CommentNode[] { + const map = new Map(); + const roots: CommentNode[] = []; + + flat.forEach((c) => map.set(c.id, { ...c, replies: [] })); + + flat.forEach((c) => { + const node = map.get(c.id)!; + if (c.parentId && map.has(c.parentId)) { + map.get(c.parentId)!.replies.push(node); + } else { + roots.push(node); + } + }); + + return roots; +} + +/** + * Find a comment node by ID within a tree structure. + * Searches recursively through all nodes and their replies. + */ +export function findCommentById(nodes: CommentNode[], id: string): CommentNode | null { + for (const node of nodes) { + if (node.id === id) { + return node; + } + const found = findCommentById(node.replies, id); + if (found) { + return found; + } + } + return null; +} + +/** + * Find the root ancestor of a comment in the tree. + * Traverses up the tree using parentId references. + */ +export function findRootAncestor(flat: ApiComment[], commentId: string): string { + const map = new Map(); + flat.forEach((c) => map.set(c.id, c)); + + let current = map.get(commentId); + while (current?.parentId && map.has(current.parentId)) { + current = map.get(current.parentId); + } + return current?.id ?? commentId; +} + +/** + * Format a Date or ISO string into a short relative time label. + * Examples: "now", "5m", "3h", "2d", "1w", "3mo", "2y". + */ +export function formatRelativeTime(input: string | Date): string { + const rawDate = typeof input === 'string' ? new Date(input) : input; + const time = rawDate.getTime(); + + // If the date string is not parseable, fall back to the raw string + if (!isFinite(time)) { + return typeof input === 'string' ? input : 'now'; + } + + const now = new Date(); + const diffMs = now.getTime() - time; + + // Future dates – treat as "now" + if (diffMs <= 0) return 'now'; + + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + // Within 5 minutes + if (diffMinutes < 5) return 'now'; + + // 5 minutes up to < 1 hour + if (diffHours < 1) return `${diffMinutes}m`; + + // < 24 hours – hours + if (diffHours < 24) return `${diffHours}h`; + + // 1–6 days -> days + if (diffDays < 7) return `${diffDays}d`; + + // 1–3 weeks -> weeks + if (diffWeeks < 4) return `${diffWeeks}w`; + + // 1–11 months -> months + if (diffMonths < 12) return `${diffMonths}mo`; + + // 1+ years + return `${diffYears}y`; +} diff --git a/frontend/app/commentSection/commentSection.tsx b/frontend/app/commentSection/commentSection.tsx new file mode 100644 index 00000000..623d3393 --- /dev/null +++ b/frontend/app/commentSection/commentSection.tsx @@ -0,0 +1,223 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { useFocusEffect } from 'expo-router'; +import { api } from '../../services/apiClient'; +import { useAuth } from '../../context/AuthContext'; +import type { ApiComment } from './_types'; +import { buildCommentTree, type CommentNode } from './_utils'; +import CommentThread from './components/CommentThread'; +import CommentInput from './components/CommentInput'; +import { commentSectionStyles } from './styles/CommentSection.styles'; + +interface CommentSectionProps { + targetType: 'post' | 'rating'; + targetId: string; + /** When true, renders CommentInput at the bottom. When false, parent handles input rendering. */ + renderInput?: boolean; + /** Called with input props when renderInput is false, so parent can render input elsewhere */ + onInputPropsReady?: (inputProps: CommentInputRenderProps) => void; +} + +export interface CommentInputRenderProps { + onSubmit: (content: string) => Promise; + replyingTo: string | null; + onCancelReply: () => void; + userProfilePicture?: string | null; + username?: string | null; +} + +interface GetCommentsResponse { + message?: string; + comments: ApiComment[]; +} + +interface CreateCommentResponse { + message: string; + comment: ApiComment; +} + +const MAX_INITIAL_THREADS = 4; +const THREAD_INCREMENT = 8; + +const CommentSection = ({ targetType, targetId, renderInput = true, onInputPropsReady }: CommentSectionProps) => { + const { profile } = useAuth(); + const [comments, setComments] = useState([]); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [replyTarget, setReplyTarget] = useState(null); + const [visibleThreadCount, setVisibleThreadCount] = useState(MAX_INITIAL_THREADS); + + const endpoint = useMemo( + () => + targetType === 'post' + ? `/api/comments/post/${targetId}` + : `/api/comments/rating/${targetId}`, + [targetId, targetType] + ); + + const loadComments = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await api.get(endpoint); + setComments(response.comments ?? []); + setVisibleThreadCount(MAX_INITIAL_THREADS); + } catch (err) { + console.error('Failed to load comments', err); + setError('Failed to load comments'); + } finally { + setLoading(false); + } + }, [endpoint]); + + // Re-fetch comments every time this screen/component gains focus + useFocusEffect( + useCallback(() => { + loadComments(); + return undefined; + }, [loadComments]) + ); + + const tree = useMemo(() => buildCommentTree(comments), [comments]); + const visibleThreads = tree.slice(0, visibleThreadCount); + const hasMoreThreads = tree.length > visibleThreadCount; + + const handleSubmitComment = useCallback(async (content: string) => { + const body: { content: string; postId?: string; ratingId?: string; parentId?: string } = { + content, + }; + + if (targetType === 'post') { + body.postId = targetId; + } else { + body.ratingId = targetId; + } + + if (replyTarget) { + body.parentId = replyTarget.id; + } + + try { + const response = await api.post('/api/comment', body); + setReplyTarget(null); + + // Optimistically add the new comment to the state without showing loading + if (response.comment) { + setComments((prevComments) => [...prevComments, response.comment]); + } else { + // Fallback: reload if response doesn't include the comment + await loadComments(); + } + } catch (error) { + console.error('Failed to submit comment:', error); + // Reload on error to ensure consistency + await loadComments(); + } + }, [targetType, targetId, replyTarget, loadComments]); + + const handleCancelReply = useCallback(() => { + setReplyTarget(null); + }, []); + + // Provide input props to parent when not rendering input internally + const inputProps: CommentInputRenderProps = useMemo(() => ({ + onSubmit: handleSubmitComment, + replyingTo: replyTarget?.UserProfile?.username ?? (replyTarget ? 'Anonymous' : null), + onCancelReply: handleCancelReply, + userProfilePicture: profile?.profilePicture, + username: profile?.username, + }), [handleSubmitComment, replyTarget, handleCancelReply, profile?.profilePicture, profile?.username]); + + // Notify parent of input props when they change - this runs before any early returns + // so the input is always available even during loading + useEffect(() => { + if (!renderInput && onInputPropsReady) { + onInputPropsReady(inputProps); + } + }, [renderInput, onInputPropsReady, inputProps]); + + if (loading) { + return ( + <> + + Loading comments… + + {renderInput && ( + + )} + + ); + } + + if (error) { + return ( + <> + + {error} + + {renderInput && ( + + )} + + ); + } + + return ( + + + {comments.length} Comments + + + + {visibleThreads.map((node) => ( + + ))} + + {hasMoreThreads && ( + + setVisibleThreadCount((prev) => Math.min(prev + THREAD_INCREMENT, tree.length)) + } + style={commentSectionStyles.viewMoreButton} + > + + View more comments ({tree.length - visibleThreadCount} more) + + + )} + + + {renderInput && ( + + )} + + ); +}; + +export default CommentSection; +export { CommentInput }; diff --git a/frontend/app/commentSection/components/Comment.tsx b/frontend/app/commentSection/components/Comment.tsx new file mode 100644 index 00000000..3cd1271f --- /dev/null +++ b/frontend/app/commentSection/components/Comment.tsx @@ -0,0 +1,133 @@ +import React, { useState, useCallback } from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import type { CommentNode } from '../_utils'; +import CommentUserRow from './CommentUserRow'; +import CommentInteractionBar from './CommentInteractionBar'; +import { commentStyles, INDENT_PER_LEVEL } from '../styles/Comment.styles'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { api } from '../../../services/apiClient'; + +type CommentProps = { + comment: CommentNode; + depth: number; + onReply?: (comment: CommentNode) => void; + onContinueThread?: () => void; +}; + +interface ToggleLikeResponse { + message: string; + liked: boolean; + likeCount: number; +} + +const MAX_PREVIEW_CHARS = 400; + +const Comment: React.FC = ({ + comment, + depth, + onReply, + onContinueThread, +}) => { + const username = comment.UserProfile?.username ?? 'Anonymous'; + const profilePicture = comment.UserProfile?.profilePicture ?? null; + const [isExpanded, setIsExpanded] = useState(false); + const [liked, setLiked] = useState(comment.liked); + const [likeCount, setLikeCount] = useState(comment.likeCount); + const [isLiking, setIsLiking] = useState(false); + + const isLong = comment.content.length > MAX_PREVIEW_CHARS; + const previewText = isLong + ? comment.content.slice(0, MAX_PREVIEW_CHARS).trimEnd() + '…' + : comment.content; + const displayText = isExpanded || !isLong ? comment.content : previewText; + + const handleLikePress = useCallback(async () => { + if (isLiking) return; + + // Optimistic update + const previousLiked = liked; + const previousCount = likeCount; + setLiked(!liked); + setLikeCount(liked ? likeCount - 1 : likeCount + 1); + setIsLiking(true); + + try { + const response = await api.post(`/api/comment/${comment.id}/like`); + // Sync with server response + setLiked(response.liked); + setLikeCount(response.likeCount); + } catch (error) { + // Revert on error + console.error('Failed to toggle like:', error); + setLiked(previousLiked); + setLikeCount(previousCount); + } finally { + setIsLiking(false); + } + }, [comment.id, liked, likeCount, isLiking]); + + return ( + + + + + + + + + {displayText} + {isLong && ( + + setIsExpanded((prev) => !prev)}> + + {isExpanded ? 'Show less' : 'Expand Comment'} + + + + )} + {isLong && !isExpanded && ( + + )} + + + {(isExpanded || !isLong) && ( + + + { }} + onReplyPress={() => onReply?.(comment)} + /> + + + {onContinueThread && ( + + + Continue Thread + + + + )} + + + )} + + + ); +}; + +export default Comment; \ No newline at end of file diff --git a/frontend/app/commentSection/components/CommentInput.tsx b/frontend/app/commentSection/components/CommentInput.tsx new file mode 100644 index 00000000..21471f1b --- /dev/null +++ b/frontend/app/commentSection/components/CommentInput.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import { View, TextInput, TouchableOpacity, Text, ActivityIndicator, Image } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { commentInputStyles, AVATAR_SIZE } from '../styles/CommentInput.styles'; + +type CommentInputProps = { + placeholder?: string; + replyingTo?: string | null; + onCancelReply?: () => void; + onSubmit: (content: string) => Promise; + userProfilePicture?: string | null; + username?: string | null; +}; + +const CommentInput: React.FC = ({ + placeholder = 'Add to the discussion...', + replyingTo, + onCancelReply, + onSubmit, + userProfilePicture, + username, +}) => { + const [text, setText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + const trimmedText = text.trim(); + if (!trimmedText || isSubmitting) return; + + setIsSubmitting(true); + try { + await onSubmit(trimmedText); + setText(''); + } catch (error) { + console.error('Failed to submit comment:', error); + } finally { + setIsSubmitting(false); + } + }; + + const canSubmit = text.trim().length > 0 && !isSubmitting; + const displayName = username || 'Anonymous'; + const initials = displayName.charAt(0).toUpperCase(); + + return ( + + {replyingTo && ( + + + Replying to {replyingTo} + + + + + + )} + + {userProfilePicture ? ( + + ) : ( + + {initials} + + )} + + + {isSubmitting ? ( + + ) : ( + + )} + + + + ); +}; + +export default CommentInput; diff --git a/frontend/app/commentSection/components/CommentInteractionBar.tsx b/frontend/app/commentSection/components/CommentInteractionBar.tsx new file mode 100644 index 00000000..9da87a37 --- /dev/null +++ b/frontend/app/commentSection/components/CommentInteractionBar.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { View, TouchableOpacity, Text } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { commentInteractionBarStyles } from '../styles/CommentInteractionBar.styles'; + +type CommentInteractionBarProps = { + likeCount: number; + liked: boolean; + onLikePress?: () => void; + onTranslatePress?: () => void; + onReplyPress?: () => void; +}; + +const CommentInteractionBar: React.FC = ({ + likeCount, + liked, + onLikePress, + onTranslatePress, + onReplyPress, +}) => { + return ( + + {/* Translate */} + + + + + {/* Reply */} + + + + + {/* Like */} + + + + {likeCount > 0 ? likeCount : ' '} + + + + ); +}; + +export default CommentInteractionBar; diff --git a/frontend/app/commentSection/components/CommentThread.tsx b/frontend/app/commentSection/components/CommentThread.tsx new file mode 100644 index 00000000..773e956d --- /dev/null +++ b/frontend/app/commentSection/components/CommentThread.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { View, TouchableOpacity, Text, Dimensions } from 'react-native'; +import { router } from 'expo-router'; +import { CommentNode } from '../_utils'; +import Comment from './Comment'; +import { commentThreadStyles } from '../styles/CommentThread.styles'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; + +const { width } = Dimensions.get('window'); +const GUTTER_WIDTH = width * 0.0475; + +interface CommentThreadProps { + node: CommentNode; + depth: number; + onReply: (node: CommentNode) => void; + targetType: 'post' | 'rating'; + targetId: string; +} + +const INITIAL_VISIBLE_REPLIES = 4; +const MAX_DEPTH = 1; // only render one level of replies under each root + +function CommentThread({ node, depth, onReply, targetType, targetId }: CommentThreadProps) { + const totalReplies = node.replies.length; + const isAtMaxDepth = depth >= MAX_DEPTH; + const shouldShowContinueThread = depth === 1 && totalReplies > 0; + + const handleContinueThread = () => { + router.push({ + pathname: '/commentSection/thread', + params: { + type: targetType, + targetId, + commentId: node.id, + }, + }); + }; + + // At max depth: render the comment itself and, if it has deeper replies, + // show a "Continue Thread" link that navigates to the dedicated page. + const isRoot = depth === 0; + + if (isAtMaxDepth) { + return ( + + + + ); + } + + // Depth 0: render up to four immediate replies with a "view more" expander. + const [visibleReplies, setVisibleReplies] = useState(INITIAL_VISIBLE_REPLIES); + const repliesToRender = node.replies.slice(0, visibleReplies); + const hasMoreReplies = totalReplies > visibleReplies; + + return ( + + {node.replies.length > 0 && } + + + + {repliesToRender.map((child) => ( + + ))} + + {hasMoreReplies && ( + setVisibleReplies(totalReplies)} + > + + + View {totalReplies - visibleReplies} Repl{totalReplies - visibleReplies === 1 ? 'y' : 'es'} + + + + + )} + + + ); +} + +export default CommentThread; \ No newline at end of file diff --git a/frontend/app/commentSection/components/CommentUserRow.tsx b/frontend/app/commentSection/components/CommentUserRow.tsx new file mode 100644 index 00000000..e91c4b1b --- /dev/null +++ b/frontend/app/commentSection/components/CommentUserRow.tsx @@ -0,0 +1,52 @@ +import { View, Text, Image } from 'react-native'; +import { formatRelativeTime } from '../_utils'; +import { commentUserRowStyles, DEFAULT_AVATAR_SIZE } from '../styles/CommentUserRow.styles'; + +type CommentUserRowProps = { + username: string | null | undefined; + profilePicture?: string | null; + timestamp?: string; +}; + +export default function CommentUserRow({ + username, + profilePicture, + timestamp, +}: CommentUserRowProps) { + const name = username || 'Anonymous'; + const initials = name.charAt(0).toUpperCase(); + const avatarSize = DEFAULT_AVATAR_SIZE; + + const avatarDynamicStyle = { + width: avatarSize, + height: avatarSize, + borderRadius: avatarSize / 2, + }; + + const formattedTimestamp = timestamp ? formatRelativeTime(timestamp) : null; + + return ( + + {profilePicture ? ( + + ) : ( + + {initials} + + )} + + {name} + {timestamp && {formattedTimestamp}} + + + ); +} diff --git a/frontend/app/commentSection/styles/Comment.styles.ts b/frontend/app/commentSection/styles/Comment.styles.ts new file mode 100644 index 00000000..c2aba64a --- /dev/null +++ b/frontend/app/commentSection/styles/Comment.styles.ts @@ -0,0 +1,72 @@ +import { Dimensions, StyleSheet } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const INDENT_PER_LEVEL = width * 0.05; + +export const commentStyles = StyleSheet.create({ + container: { + flexDirection: 'row', + marginVertical: height * 0.005, + }, + content: { + flex: 1, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: height * 0.005, + }, + bodyContainer: { + marginLeft: width * 0.07, + flexDirection: 'column', + position: 'relative', + }, + body: { + fontSize: width * 0.035, + color: '#222', + fontWeight: '400', + marginLeft: width * 0.025, + }, + fadeOverlay: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + height: height * 0.05, + pointerEvents: 'none', + }, + expandTextContainer: { + alignSelf: 'center', + position: 'absolute', + marginBottom: height * 0.01, + bottom: 0, + zIndex: 1, + }, + expandText: { + fontSize: width * 0.03, + color: '#D62E05', + fontWeight: '500', + }, + interactionsBar: { + flexDirection: 'row', + justifyContent: 'flex-end', + }, + viewMoreTextContainer: { + paddingVertical: height * 0.002, + flexDirection: 'row', + alignItems: 'center', + gap: width * 0.01, + justifyContent: 'flex-end', + }, + viewMoreText: { + fontSize: width * 0.03, + color: '#D62E05', + fontWeight: '400', + }, + viewMoreIcon: { + fontSize: width * 0.025, + color: '#D62E05', + fontWeight: '400', + }, +}); diff --git a/frontend/app/commentSection/styles/CommentInput.styles.ts b/frontend/app/commentSection/styles/CommentInput.styles.ts new file mode 100644 index 00000000..fc43ba63 --- /dev/null +++ b/frontend/app/commentSection/styles/CommentInput.styles.ts @@ -0,0 +1,81 @@ +import { StyleSheet, Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const AVATAR_SIZE = width * 0.09; + +export const commentInputStyles = StyleSheet.create({ + container: { + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E5E5', + paddingHorizontal: width * 0.04, + paddingVertical: height * 0.012, + }, + replyingToContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: '#F5F5F5', + paddingHorizontal: width * 0.03, + paddingVertical: height * 0.008, + borderRadius: 8, + marginBottom: height * 0.01, + marginLeft: AVATAR_SIZE + width * 0.025, + }, + replyingToText: { + fontSize: width * 0.032, + color: '#666', + flex: 1, + }, + cancelIcon: { + fontSize: width * 0.045, + color: '#999', + }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + avatar: { + width: AVATAR_SIZE, + height: AVATAR_SIZE, + borderRadius: AVATAR_SIZE / 2, + marginRight: width * 0.025, + }, + avatarPlaceholder: { + backgroundColor: '#444', + justifyContent: 'center', + alignItems: 'center', + }, + avatarText: { + color: '#FFFFFF', + fontSize: AVATAR_SIZE * 0.45, + fontWeight: '600', + }, + input: { + flex: 1, + backgroundColor: 'transparent', + paddingHorizontal: 0, + paddingVertical: height * 0.01, + fontSize: width * 0.035, + color: '#333', + maxHeight: height * 0.12, + minHeight: height * 0.045, + }, + sendButton: { + backgroundColor: '#D62E05', + width: width * 0.085, + height: width * 0.085, + borderRadius: width * 0.0425, + justifyContent: 'center', + alignItems: 'center', + marginLeft: width * 0.025, + }, + sendButtonDisabled: { + backgroundColor: '#CCCCCC', + }, + sendIcon: { + fontSize: width * 0.045, + color: '#FFFFFF', + }, +}); diff --git a/frontend/app/commentSection/styles/CommentInteractionBar.styles.ts b/frontend/app/commentSection/styles/CommentInteractionBar.styles.ts new file mode 100644 index 00000000..19d02ae6 --- /dev/null +++ b/frontend/app/commentSection/styles/CommentInteractionBar.styles.ts @@ -0,0 +1,39 @@ +import { StyleSheet } from 'react-native'; +import { Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const commentInteractionBarStyles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingTop: height * 0.01, + gap: width * 0.05, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + gap: width * 0.01, + }, + likeButton: { + flexDirection: 'row', + alignItems: 'center', + gap: width * 0.01, + minWidth: width * 0.08, + }, + icon: { + fontSize: width * 0.04, + color: '#777', + }, + likedIcon: { + color: '#e74c3c', + }, + likeCount: { + fontSize: width * 0.032, + color: '#777', + minWidth: width * 0.025, + }, + likedText: { + color: '#e74c3c', + }, +}); diff --git a/frontend/app/commentSection/styles/CommentSection.styles.ts b/frontend/app/commentSection/styles/CommentSection.styles.ts new file mode 100644 index 00000000..2835ba5d --- /dev/null +++ b/frontend/app/commentSection/styles/CommentSection.styles.ts @@ -0,0 +1,42 @@ +import { StyleSheet, Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const commentSectionStyles = StyleSheet.create({ + container: { + marginHorizontal: width * 0.025, + }, + header: { + fontWeight: '400', + marginBottom: height * 0.01, + }, + threadContainer: { + paddingRight: width * 0.04, + paddingBottom: height * 0.02, + }, + viewMoreButton: { + marginTop: height * 0.01, + }, + viewMoreText: { + fontSize: width * 0.03, + color: '#D62E05', + fontWeight: '400', + alignSelf: 'center', + }, + loadingContainer: { + paddingVertical: height * 0.04, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + color: '#555', + }, + errorContainer: { + paddingVertical: height * 0.04, + justifyContent: 'center', + alignItems: 'center', + }, + errorText: { + color: '#D62E05', + }, +}); diff --git a/frontend/app/commentSection/styles/CommentThread.styles.ts b/frontend/app/commentSection/styles/CommentThread.styles.ts new file mode 100644 index 00000000..a64d6563 --- /dev/null +++ b/frontend/app/commentSection/styles/CommentThread.styles.ts @@ -0,0 +1,41 @@ +import { StyleSheet, Dimensions } from "react-native"; + +const { width, height } = Dimensions.get('window'); + +export const commentThreadStyles = StyleSheet.create({ + container: { + position: 'relative', + }, + threadContainer: { + flex: 1, + }, + threadLine: { + position: 'absolute', + width: 1, + top: 0, + bottom: 0, + backgroundColor: 'lightgrey', + borderRadius: 2, + marginLeft: width * 0.03, + marginTop: height * 0.04, + marginBottom: height * 0.03, + }, + viewMoreTextContainer: { + paddingVertical: height * 0.002, + flexDirection: 'row', + alignItems: 'center', + gap: width * 0.01, + marginBottom: height * 0.01, + }, + viewMoreText: { + fontSize: width * 0.03, + color: '#D62E05', + fontWeight: '400', + alignSelf: 'flex-start', + }, + viewMoreIcon: { + fontSize: width * 0.03, + color: '#D62E05', + fontWeight: '400', + }, +}); diff --git a/frontend/app/commentSection/styles/CommentUserRow.styles.ts b/frontend/app/commentSection/styles/CommentUserRow.styles.ts new file mode 100644 index 00000000..35d9c8e9 --- /dev/null +++ b/frontend/app/commentSection/styles/CommentUserRow.styles.ts @@ -0,0 +1,36 @@ +import { StyleSheet, Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const DEFAULT_AVATAR_SIZE = width * 0.06; + +export const commentUserRowStyles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + avatar: { + justifyContent: 'center', + alignItems: 'center', + marginRight: width * 0.0125, + }, + avatarText: { + color: '#fff', + fontSize: width * 0.035, + fontWeight: '600', + }, + textContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: width * 0.01, + }, + username: { + fontSize: width * 0.035, + fontWeight: '400', + color: '#111', + }, + timestamp: { + fontSize: width * 0.03, + color: '#888', + }, +}); diff --git a/frontend/app/commentSection/styles/Thread.styles.ts b/frontend/app/commentSection/styles/Thread.styles.ts new file mode 100644 index 00000000..fb52959f --- /dev/null +++ b/frontend/app/commentSection/styles/Thread.styles.ts @@ -0,0 +1,97 @@ +import { StyleSheet, Dimensions } from 'react-native'; + +const { width, height } = Dimensions.get('window'); + +export const threadStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFFFFF', + }, + keyboardAvoidingContainer: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: width * 0.04, + borderBottomWidth: 1, + borderBottomColor: '#E5E5E5', + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: width * 0.02, + marginRight: width * 0.02, + }, + backIcon: { + fontSize: width * 0.06, + color: '#333', + }, + headerTitle: { + fontSize: width * 0.045, + fontWeight: '600', + color: '#333', + }, + scrollContainer: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: width * 0.04, + paddingTop: height * 0.015, + paddingBottom: height * 0.02, + }, + footerContainer: { + backgroundColor: '#FFFFFF', + borderTopWidth: 1, + borderTopColor: '#E5E5E5', + paddingTop: height * 0.01, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + color: '#555', + fontSize: width * 0.04, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: width * 0.08, + }, + errorText: { + color: '#D62E05', + fontSize: width * 0.04, + textAlign: 'center', + marginBottom: height * 0.02, + }, + retryButton: { + paddingHorizontal: width * 0.06, + paddingVertical: height * 0.015, + backgroundColor: '#D62E05', + borderRadius: 8, + }, + retryText: { + color: '#FFFFFF', + fontWeight: '600', + }, + threadLine: { + position: 'absolute', + width: 1, + top: 0, + bottom: 0, + backgroundColor: 'lightgrey', + borderRadius: 2, + marginTop: height * 0.04, + marginBottom: height * 0.015, + }, + replyContainer: { + position: 'relative', + marginLeft: width * 0.045, + }, + rootCommentContainer: { + marginBottom: height * 0.01, + }, +}); diff --git a/frontend/app/commentSection/thread.tsx b/frontend/app/commentSection/thread.tsx new file mode 100644 index 00000000..24b2495b --- /dev/null +++ b/frontend/app/commentSection/thread.tsx @@ -0,0 +1,238 @@ +import { useCallback, useMemo, useState } from 'react'; +import { View, Text, ScrollView, TouchableOpacity, SafeAreaView, KeyboardAvoidingView, Platform } from 'react-native'; +import { router, useLocalSearchParams, useFocusEffect } from 'expo-router'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; +import { api } from '../../services/apiClient'; +import { useAuth } from '../../context/AuthContext'; +import type { ApiComment } from './_types'; +import { buildCommentTree, findCommentById, findRootAncestor, type CommentNode } from './_utils'; +import Comment from './components/Comment'; +import CommentInput from './components/CommentInput'; +import { threadStyles } from './styles/Thread.styles'; + +interface GetCommentsResponse { + message?: string; + comments: ApiComment[]; +} + +interface CreateCommentResponse { + message: string; + comment: ApiComment; +} + +/** + * Recursive component to render a comment and all its replies without depth limit. + */ +function ThreadComment({ + node, + depth, + onReply, +}: { + node: CommentNode; + depth: number; + onReply: (node: CommentNode) => void; +}) { + const hasReplies = node.replies.length > 0; + + return ( + 0 ? threadStyles.replyContainer : threadStyles.rootCommentContainer}> + {hasReplies && depth > 0 && } + + {node.replies.map((reply) => ( + + ))} + + ); +} + +const Thread = () => { + const { profile } = useAuth(); + const params = useLocalSearchParams<{ + type: 'post' | 'rating'; + targetId: string; + commentId: string; + }>(); + + const { type, targetId, commentId } = params; + + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [replyTarget, setReplyTarget] = useState(null); + + const endpoint = useMemo( + () => + type === 'post' + ? `/api/comments/post/${targetId}` + : `/api/comments/rating/${targetId}`, + [targetId, type] + ); + + const loadComments = useCallback(async () => { + if (!type || !targetId || !commentId) { + setError('Missing required parameters'); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await api.get(endpoint); + setComments(response.comments ?? []); + } catch (err) { + console.error('Failed to load thread comments', err); + setError('Failed to load comments'); + } finally { + setLoading(false); + } + }, [endpoint, type, targetId, commentId]); + + // Re-fetch comments every time this screen gains focus + useFocusEffect( + useCallback(() => { + loadComments(); + return undefined; + }, [loadComments]) + ); + + // Find the root of the thread containing the selected comment + const threadRoot = useMemo(() => { + if (comments.length === 0 || !commentId) return null; + + // Find the root ancestor of the clicked comment + const rootId = findRootAncestor(comments, commentId); + + // Build the tree and find the root node + const tree = buildCommentTree(comments); + return findCommentById(tree, rootId); + }, [comments, commentId]); + + const handleSubmitComment = useCallback(async (content: string) => { + const body: { content: string; postId?: string; ratingId?: string; parentId?: string } = { + content, + }; + + if (type === 'post') { + body.postId = targetId; + } else { + body.ratingId = targetId; + } + + if (replyTarget) { + body.parentId = replyTarget.id; + } else if (threadRoot) { + // If no specific reply target, reply to the root comment of this thread + body.parentId = threadRoot.id; + } + + try { + const response = await api.post('/api/comment', body); + setReplyTarget(null); + + // Optimistically add the new comment to the state without showing loading + if (response.comment) { + setComments((prevComments) => [...prevComments, response.comment]); + } else { + // Fallback: reload if response doesn't include the comment + await loadComments(); + } + } catch (error) { + console.error('Failed to submit comment:', error); + // Reload on error to ensure consistency + await loadComments(); + } + }, [type, targetId, replyTarget, threadRoot, loadComments]); + + const handleCancelReply = useCallback(() => { + setReplyTarget(null); + }, []); + + const handleBack = () => { + router.back(); + }; + + if (loading) { + return ( + + + + + + Thread + + + Loading thread… + + + ); + } + + if (error || !threadRoot) { + return ( + + + + + + Thread + + + + {error ?? 'Thread not found'} + + + Retry + + + + ); + } + + return ( + + + + + + + Thread + + + + + + + + + + ); +}; + +export default Thread; diff --git a/frontend/app/events/components/EventCard.tsx b/frontend/app/events/components/EventCard.tsx index 35a2bc1c..9ec2bdd0 100644 --- a/frontend/app/events/components/EventCard.tsx +++ b/frontend/app/events/components/EventCard.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, ImageBackground } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { styles } from '../styles/EventCard.styles'; +import { styles } from '../../../styles/events/EventCard.styles'; import type { LocalEvent } from '../../../services/eventsService'; interface EventCardProps { diff --git a/frontend/app/events/components/RecommendedEventCard.tsx b/frontend/app/events/components/RecommendedEventCard.tsx index 9e22eab8..9617e039 100644 --- a/frontend/app/events/components/RecommendedEventCard.tsx +++ b/frontend/app/events/components/RecommendedEventCard.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { View, Text, TouchableOpacity, ImageBackground } from 'react-native'; -import { styles } from '../styles/RecommendedEventCard.styles'; +import { styles } from '../../../styles/events/RecommendedEventCard.styles'; import type { LocalEvent } from '../../../services/eventsService'; interface RecommendedEventCardProps { diff --git a/frontend/app/events/components/UpcomingEventCard.tsx b/frontend/app/events/components/UpcomingEventCard.tsx index 3a81ad5d..059d56b5 100644 --- a/frontend/app/events/components/UpcomingEventCard.tsx +++ b/frontend/app/events/components/UpcomingEventCard.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { View, Text, TouchableOpacity, ImageBackground } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { styles } from '../styles/UpcomingEventCard.styles'; +import { styles } from '../../../styles/events/UpcomingEventCard.styles'; import type { LocalEvent } from '../../../services/eventsService'; interface UpcomingEventCardProps { diff --git a/frontend/app/events/eventDetail.tsx b/frontend/app/events/eventDetail.tsx index f109abdb..f2d3c556 100644 --- a/frontend/app/events/eventDetail.tsx +++ b/frontend/app/events/eventDetail.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; import { StyleSheet, View, @@ -8,17 +9,20 @@ import { ActivityIndicator, Modal, Image, + Alert, } from 'react-native'; import RsvpNotification from '../../components/RsvpNotification'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { CircleDollarSign, MapPin, Calendar } from 'lucide-react-native'; +import { CircleDollarSign, MapPin, Calendar, Bookmark, BookmarkCheck } from 'lucide-react-native'; import Entypo from '@expo/vector-icons/Entypo'; import Rsvp from '../../components/Rsvp'; import { router, useLocalSearchParams } from 'expo-router'; import { getLocalEvent, type LocalEvent } from '../../services/eventsService'; import { createOrUpdateRsvp, getUserRsvp } from '../../services/rsvpService'; +import { getUserProfile, updateUserProfile } from '../../services/userService'; import LocationSection from '../../components/LocationSection'; +import { useAuth } from '../../context/AuthContext'; export default function EventDetailScreen() { const { eventId } = useLocalSearchParams<{ eventId: string }>(); @@ -29,13 +33,43 @@ export default function EventDetailScreen() { const [showRsvpModal, setShowRsvpModal] = useState(false); const [userRsvp, setUserRsvp] = useState<'yes' | 'maybe' | 'no' | null>(null); const [showNotification, setShowNotification] = useState(false); + const [isBookmarked, setIsBookmarked] = useState(false); + const [savedEvents, setSavedEvents] = useState([]); + const { user } = useAuth(); + + // Load event details, user RSVP, and saved events + const loadUserData = useCallback(async () => { + if (!user?.id) return; + + try { + const profile = await getUserProfile(); + if (profile?.userProfile) { + const { eventsSaved = [] } = profile.userProfile; + setSavedEvents(eventsSaved); + setIsBookmarked(eventId ? eventsSaved.includes(eventId) : false); + } + } catch (error) { + console.error('Failed to load user profile:', error); + } + }, [user, eventId]); + // Load data when component mounts or eventId changes useEffect(() => { if (eventId) { loadEventDetails(); loadUserRsvp(); + loadUserData(); } - }, [eventId]); + }, [eventId, loadUserData]); + + // Refresh data when screen comes into focus + useFocusEffect( + useCallback(() => { + if (eventId) { + loadUserData(); + } + }, [eventId, loadUserData]) + ); const loadEventDetails = async () => { if (!eventId) return; @@ -98,6 +132,31 @@ export default function EventDetailScreen() { setShowNotification(false); }; + const toggleBookmark = async () => { + if (!user?.id || !eventId) return; + + try { + const updatedSavedEvents = isBookmarked + ? savedEvents.filter(id => id !== eventId) + : [...savedEvents, eventId]; + + // Optimistic UI update + setIsBookmarked(!isBookmarked); + setSavedEvents(updatedSavedEvents); + + // Update the backend + await updateUserProfile({ + eventsSaved: updatedSavedEvents + }); + + } catch (error) { + // Revert on error + setIsBookmarked(!isBookmarked); + console.error('Failed to update bookmarks:', error); + Alert.alert('Error', 'Failed to update bookmarks. Please try again.'); + } + }; + if (loading) { return ( @@ -159,12 +218,15 @@ export default function EventDetailScreen() { - - + + {isBookmarked ? ( + + ) : ( + + )} diff --git a/frontend/app/events/index.tsx b/frontend/app/events/index.tsx index d5a2aec0..a0b35e8b 100644 --- a/frontend/app/events/index.tsx +++ b/frontend/app/events/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { StyleSheet, View, @@ -7,6 +7,7 @@ import { TouchableOpacity, ActivityIndicator, SafeAreaView, + RefreshControl, } from 'react-native'; import { router } from 'expo-router'; import { getLocalEvents, type LocalEvent } from '../../services/eventsService'; @@ -34,24 +35,31 @@ export default function Events() { dates: [], eventType: [], }); + const [refreshing, setRefreshing] = useState(false); - useEffect(() => { - loadEvents(); - }, []); - - const loadEvents = async () => { + // Simple in-memory cache with 5-minute TTL + const loadEvents = useCallback(async (forceRefresh = false) => { try { - setLoading(true); + if (!forceRefresh) { + setLoading(true); + } else { + setRefreshing(true); + } setError(null); - const response = await getLocalEvents(); + const response = await getLocalEvents(forceRefresh); setEvents(response.data ?? []); } catch (err) { console.error('Failed to load events:', err); setError('Failed to load events. Please try again.'); } finally { setLoading(false); + setRefreshing(false); } - }; + }, []); + + useEffect(() => { + loadEvents(); + }, [loadEvents]); const handleEventPress = (eventId: string) => { router.push(`/events/eventDetail?eventId=${eventId}`); @@ -74,7 +82,7 @@ export default function Events() { [filterKey]: selectedValues, }); // TODO: Call backend API with filters - loadEvents(); + loadEvents(true); // Force refresh when filters change }; if (loading) { @@ -90,7 +98,7 @@ export default function Events() { return ( {error} - + loadEvents(true)} style={styles.retryButton}> Retry @@ -158,6 +166,14 @@ export default function Events() { loadEvents(true)} + tintColor="#333" + colors={['#333']} + /> + } > (); + + const preselectedMovie = movieId && movieTitle ? { id: movieId, title: movieTitle } : null; + + return ; +} \ No newline at end of file diff --git a/frontend/app/index.tsx b/frontend/app/index.tsx index 0d2d7267..1be0fbb6 100644 --- a/frontend/app/index.tsx +++ b/frontend/app/index.tsx @@ -1,28 +1,213 @@ -import { Redirect } from 'expo-router'; -import { useAuth } from '../context/AuthContext'; -import { View, ActivityIndicator } from 'react-native'; - -export default function Index() { - const { user, profile, loading, profileLoading } = useAuth(); - - if (loading || profileLoading) { - return ( - - - - ); - } - - // Not authenticated -> go to auth - if (!user) { - return ; - } - - // Authenticated but needs onboarding - if (profile && !profile.onboardingCompleted) { - return ; - } - - // Fully onboarded -> go to tabs - return ; -} \ No newline at end of file +// app/(auth)/splash.tsx + +import React, { useEffect, useRef, useState } from 'react'; +import { + View, + Animated, + StyleSheet, + useWindowDimensions, + ImageSourcePropType, +} from 'react-native'; +import { router } from 'expo-router'; +import { Easing } from 'react-native'; + +const SLIDES: ImageSourcePropType[] = [ + require('../assets/SplashScreen09.png'), + require('../assets/SplashScreen08.png'), + require('../assets/SplashScreen07.png'), + require('../assets/SplashScreen06.png'), + require('../assets/SplashScreen05.png'), + require('../assets/SplashScreen04.png'), + require('../assets/SplashScreen03.png'), + require('../assets/SplashScreen02.png'), + // add the remaining up to 12 if you have them +]; + +const FADE_DURATION_MS = 700; +const HOLD_DURATION_MS = 600; +const FINAL_HOLD_MS = 500; +// how many slides at the **end** should slide horizontally instead of fading +const SLIDING_COUNT = 1; // last 3 slides will slide; change as you like + +export default function SplashSequenceScreen() { + const { width, height } = useWindowDimensions(); + + // the slide currently visible underneath + const [baseIndex, setBaseIndex] = useState(0); + // the slide we are fading/sliding in on top + const [frontIndex, setFrontIndex] = useState(0); + // are we in the sliding phase (last few slides)? + const [isSlidingPhase, setIsSlidingPhase] = useState(false); + + const overlayOpacity = useRef(new Animated.Value(0)).current; + const slideProgress = useRef(new Animated.Value(0)).current; + + useEffect(() => { + let isMounted = true; + + const runSequence = async () => { + if (SLIDES.length === 0) { + router.replace('/(auth)/welcome'); + return; + } + + const fadeSlidesEndIndex = Math.max( + 0, + SLIDES.length - SLIDING_COUNT - 1 + ); // index up to which we fade + + // --- First slide: simple fade in from black --- + setBaseIndex(0); + setFrontIndex(0); + setIsSlidingPhase(false); + overlayOpacity.setValue(0); + slideProgress.setValue(0); + + await new Promise(resolve => { + Animated.timing(overlayOpacity, { + toValue: 1, + duration: FADE_DURATION_MS, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(() => resolve()); + }); + + await new Promise(resolve => + setTimeout(resolve, HOLD_DURATION_MS) + ); + + // --- Remaining slides --- + for (let i = 1; i < SLIDES.length; i++) { + if (!isMounted) return; + + const isSliding = i > fadeSlidesEndIndex; + setIsSlidingPhase(isSliding); + setFrontIndex(i); + + if (!isSliding) { + // === FADE PHASE === + overlayOpacity.setValue(0); + slideProgress.setValue(0); + + await new Promise(resolve => { + Animated.timing(overlayOpacity, { + toValue: 1, + duration: FADE_DURATION_MS, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }).start(() => resolve()); + }); + + // after fade, promote front to base + setBaseIndex(i); + overlayOpacity.setValue(1); + + await new Promise(resolve => + setTimeout(resolve, HOLD_DURATION_MS) + ); + } else { + // === SLIDE PHASE === + // base starts at 0, front starts off-screen to the right + slideProgress.setValue(0); + // keep both fully opaque during the slide + overlayOpacity.setValue(1); + + await new Promise(resolve => { + Animated.timing(slideProgress, { + toValue: 1, + duration: FADE_DURATION_MS, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(() => resolve()); + }); + + // after sliding, the front becomes the new base + setBaseIndex(i); + slideProgress.setValue(0); + + await new Promise(resolve => + setTimeout(resolve, HOLD_DURATION_MS) + ); + } + } + + if (isMounted) { + await new Promise(resolve => + setTimeout(resolve, FINAL_HOLD_MS) + ); + router.replace('/(auth)/welcome'); + } + }; + + runSequence(); + + return () => { + isMounted = false; + overlayOpacity.stopAnimation(); + slideProgress.stopAnimation(); + }; + }, [overlayOpacity, slideProgress]); + + // Interpolated translation for sliding phase + const baseTranslateX = slideProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0, -width], // current slide moves left + }); + + const frontTranslateX = slideProgress.interpolate({ + inputRange: [0, 1], + outputRange: [width, 0], // next slide comes from the right + }); + + return ( + + {/* Base image (always there underneath) */} + + + {/* Overlay / incoming image */} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000000', // avoid white flash + justifyContent: 'center', + alignItems: 'center', + }, + image: { + // width/height are set dynamically from useWindowDimensions + }, +}); diff --git a/frontend/app/postDetail/[postId].tsx b/frontend/app/postDetail/[postId].tsx new file mode 100644 index 00000000..c3a27147 --- /dev/null +++ b/frontend/app/postDetail/[postId].tsx @@ -0,0 +1,55 @@ +import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; +import { useLocalSearchParams, router } from 'expo-router'; +import PostDetails from '../../screen/PostDetails'; + +export default function PostDetailPage() { + const { postId } = useLocalSearchParams<{ postId: string }>(); + + if (!postId) { + return ( + + + No post ID provided + router.back()} + style={styles.backButtonError} + > + Go Back + + + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + backButtonError: { + marginTop: 20, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + errorText: { + fontSize: 16, + color: '#FF3B30', + marginBottom: 20, + }, + backButtonText: { + fontSize: 16, + color: '#007AFF', + fontWeight: '600', + }, +}); diff --git a/frontend/app/postDetail/_layout.tsx b/frontend/app/postDetail/_layout.tsx new file mode 100644 index 00000000..84c1633c --- /dev/null +++ b/frontend/app/postDetail/_layout.tsx @@ -0,0 +1,16 @@ +import { Stack } from 'expo-router'; + +export default function PostDetailLayout() { + return ( + + + + ); +} diff --git a/frontend/app/profilePage/accountSettings.tsx b/frontend/app/profilePage/accountSettings.tsx index 7cb92785..409a57ec 100644 --- a/frontend/app/profilePage/accountSettings.tsx +++ b/frontend/app/profilePage/accountSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { View, Text, @@ -13,7 +13,7 @@ import { Ionicons, MaterialIcons } from '@expo/vector-icons'; import { router, useNavigation } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { styles as bottomNavStyles } from '../../styles/BottomNavBar.styles'; -import { deleteUserProfile } from '../../services/userService'; +import { deleteUserProfile, getUserProfile, updateUserProfile } from '../../services/userService'; import { useAuth } from '../../context/AuthContext'; type SectionKey = 'personal' | 'privacy' | 'language'; @@ -47,23 +47,75 @@ export default function AccountSettings() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [privateAccount, setPrivateAccount] = useState(false); - const [allowWhatsApp, setAllowWhatsApp] = useState(false); const [allowSpoilers, setAllowSpoilers] = useState(false); const [selectedLanguages, setSelectedLanguages] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); const navigation = useNavigation(); const insets = useSafeAreaInsets(); const { signOut } = useAuth(); + const isBusy = isSaving || isLoading; + + useEffect(() => { + const loadProfile = async () => { + try { + setIsLoading(true); + const res = await getUserProfile(); + try { + console.log( + '[AccountSettings] loaded profile payload:', + JSON.stringify(res, null, 2), + ); + } catch { + console.log('[AccountSettings] loaded profile payload (raw):', res); + } + if (res?.userProfile) { + setUsername(res.userProfile.username ?? ''); + setSelectedLanguages(res.userProfile.secondaryLanguage ?? []); + setPrivateAccount(Boolean(res.userProfile.privateAccount)); + setAllowSpoilers(Boolean(res.userProfile.spoiler)); + } + } catch (err) { + console.error('Failed to load user profile', err); + } finally { + setIsLoading(false); + } + }; + + loadProfile(); + }, []); const toggle = (key: SectionKey) => { setOpenSection((prev) => (prev === key ? null : key)); }; - const handleSavePersonal = () => { + const handleSavePersonal = async () => { if (password && password !== confirmPassword) { Alert.alert('Passwords do not match', 'Please ensure both passwords are identical.'); return; } - Alert.alert('Saved', 'Personal details have been saved.'); + + try { + setIsSaving(true); + const res = await updateUserProfile({ + username, + secondaryLanguage: selectedLanguages, + privateAccount, + spoiler: allowSpoilers, + }); + if (res?.data) { + setUsername(res.data.username ?? ''); + setPrivateAccount(Boolean(res.data.privateAccount)); + setAllowSpoilers(Boolean(res.data.spoiler)); + setSelectedLanguages(res.data.secondaryLanguage ?? []); + } + Alert.alert('Saved', 'Personal details have been saved.'); + } catch (err) { + console.error('Failed to save personal details', err); + Alert.alert('Unable to save', 'Please try again.'); + } finally { + setIsSaving(false); + } }; const inputBorder = { borderColor: '#AB2504', borderWidth: 1 }; @@ -86,8 +138,51 @@ export default function AccountSettings() { ); }; - const handleSaveLanguages = () => { - Alert.alert('Saved', 'Language preferences have been saved.'); + const handleSaveLanguages = async () => { + try { + setIsSaving(true); + const res = await updateUserProfile({ + username, + secondaryLanguage: selectedLanguages, + privateAccount, + spoiler: allowSpoilers, + }); + if (res?.data) { + setUsername(res.data.username ?? ''); + setPrivateAccount(Boolean(res.data.privateAccount)); + setAllowSpoilers(Boolean(res.data.spoiler)); + setSelectedLanguages(res.data.secondaryLanguage ?? []); + } + Alert.alert('Saved', 'Language preferences have been saved.'); + } catch (err) { + console.error('Failed to save languages', err); + Alert.alert('Unable to save', 'Please try again.'); + } finally { + setIsSaving(false); + } + }; + + const handleSavePrivacy = async () => { + try { + setIsSaving(true); + const res = await updateUserProfile({ + username, + secondaryLanguage: selectedLanguages, + privateAccount, + spoiler: allowSpoilers, + }); + if (res?.data) { + setUsername(res.data.username ?? ''); + setPrivateAccount(Boolean(res.data.privateAccount)); + setAllowSpoilers(Boolean(res.data.spoiler)); + } + Alert.alert('Saved', 'Privacy preferences have been saved.'); + } catch (err) { + console.error('Failed to save privacy settings', err); + Alert.alert('Unable to save', 'Please try again.'); + } finally { + setIsSaving(false); + } }; const handleDeleteProfile = () => { @@ -249,12 +344,14 @@ export default function AccountSettings() { )} ))} + + + Save + + ) : section.key === 'language' ? ( @@ -349,12 +458,14 @@ export default function AccountSettings() { { +const EMPTY_IDS: string[] = []; + +const EventsList = ({ userId, eventsSaved, eventsAttended }: Props) => { + const router = useRouter(); + // Normalize to stable references to avoid re-running effects on every render + const savedIds = useMemo(() => eventsSaved ?? EMPTY_IDS, [eventsSaved]); + const attendedIds = useMemo(() => eventsAttended ?? EMPTY_IDS, [eventsAttended]); const [activeSubTab, setActiveSubTab] = useState<'saved' | 'attended'>( 'saved' ); const [savedEvents, setSavedEvents] = useState([]); + const [attendedEvents, setAttendedEvents] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const events = useMemo( - () => (activeSubTab === 'saved' ? savedEvents : []), - [activeSubTab, savedEvents] + () => (activeSubTab === 'saved' ? savedEvents : attendedEvents), + [activeSubTab, savedEvents, attendedEvents] ); const loadEvents = useCallback(async () => { - if (!userId) { + // If no ids and no user, nothing to fetch + if ((savedIds.length === 0 && attendedIds.length === 0) && !userId) { setSavedEvents([]); + setAttendedEvents([]); setError(null); setLoading(false); return; @@ -38,15 +50,27 @@ const EventsList = ({ userId }: Props) => { try { setLoading(true); setError(null); - const fetched = await getUserEvents(userId); - setSavedEvents(fetched); + // fetch saved (create arrays of promises) + const fetchSaved: Promise[] = savedIds.length + ? savedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent)) + : []; + const fetchAttended: Promise[] = attendedIds.length + ? attendedIds.map(id => getLocalEvent(id).then(r => r.data as LocalEvent)) + : []; + + const [saved, attended] = await Promise.all([ + Promise.all(fetchSaved), + Promise.all(fetchAttended), + ]); + setSavedEvents(saved.filter((e): e is LocalEvent => Boolean(e))); + setAttendedEvents(attended.filter((e): e is LocalEvent => Boolean(e))); } catch (err: any) { console.error('Failed to load user events', err); setError(err?.message || 'Failed to load events'); } finally { setLoading(false); } - }, [userId]); + }, [userId, savedIds, attendedIds]); useEffect(() => { loadEvents(); @@ -127,13 +151,7 @@ const EventsList = ({ userId }: Props) => { Retry - ) : events.length === 0 ? ( - - {activeSubTab === 'saved' - ? 'No saved events yet.' - : 'No attended events yet.'} - - ) : ( + ) : events.length === 0 ? null : ( item.id} @@ -142,7 +160,11 @@ const EventsList = ({ userId }: Props) => { showsVerticalScrollIndicator={false} renderItem={({ item }) => ( - + router.push(`/events/eventDetail?eventId=${item.id}`)} + /> )} ListFooterComponent={} diff --git a/frontend/app/profilePage/components/MoviesGrid.tsx b/frontend/app/profilePage/components/MoviesGrid.tsx index f76f48ce..3e8a9a63 100644 --- a/frontend/app/profilePage/components/MoviesGrid.tsx +++ b/frontend/app/profilePage/components/MoviesGrid.tsx @@ -9,9 +9,13 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import tw from 'twrnc'; -import { getUserRatings } from '../../../services/userService'; +import { router } from 'expo-router'; + +import { getUserProfile } from '../../../services/userService'; import { getMovieByCinecircleId } from '../../../services/moviesService'; +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/w185'; + type MovieListItem = { id: string; title: string; @@ -20,11 +24,20 @@ type MovieListItem = { type Props = { userId?: string | null; + moviesToWatch?: string[] | null; + moviesCompleted?: string[] | null; }; -const MoviesGrid = ({ userId }: Props) => { - const [activeSubTab, setActiveSubTab] = useState<'toWatch' | 'completed'>('completed'); - const [moviesByStatus, setMoviesByStatus] = useState>({ +const MoviesGrid = (props: Props | undefined) => { + // ✅ safely read userId even if props is undefined + const userId = props?.userId ?? null; + + const [activeSubTab, setActiveSubTab] = useState<'toWatch' | 'completed'>( + 'toWatch' + ); + const [moviesByStatus, setMoviesByStatus] = useState< + Record<'toWatch' | 'completed', MovieListItem[]> + >({ toWatch: [], completed: [], }); @@ -32,107 +45,138 @@ const MoviesGrid = ({ userId }: Props) => { const [error, setError] = useState(null); const movies = moviesByStatus[activeSubTab]; - const showBookmark = activeSubTab === 'toWatch'; - const isValidUuid = (val: string | null | undefined) => - !!val && - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( - val - ); const fetchMoviesForUser = useCallback(async () => { - if (!userId || !isValidUuid(userId)) { - setMoviesByStatus({ toWatch: [], completed: [] }); - setLoading(false); - setError(null); - return; - } try { setLoading(true); setError(null); - const ratingsRes = await getUserRatings(userId); - const ratings = ratingsRes?.ratings ?? []; - - const movieDetails = await Promise.all( - ratings.map(async (rating) => { - try { - const envelope = await getMovieByCinecircleId(rating.movieId); - const movie = (envelope as any)?.data ?? (envelope as any)?.movie ?? null; - - return { - id: rating.movieId, - title: movie?.title || `Movie ${rating.movieId}`, - poster: movie?.imageUrl ?? null, - } as MovieListItem; - } catch (err) { - console.error('Failed to fetch movie detail:', err); - return { - id: rating.movieId, - title: `Movie ${rating.movieId}`, - poster: null, - } as MovieListItem; - } - }) - ); - - const deduped = movieDetails.filter( - (movie, index, self) => self.findIndex((m) => m.id === movie.id) === index - ); + const profileRes = await getUserProfile(); + const profile = profileRes?.userProfile; + if (!profile) { + setMoviesByStatus({ toWatch: [], completed: [] }); + return; + } + + const toWatchIds: string[] = profile.bookmarkedToWatch ?? []; + const completedIds: string[] = profile.bookmarkedWatched ?? []; + + const fetchMovie = async (id: string): Promise => { + try { + const envelope = await getMovieByCinecircleId(id); + const movie = + (envelope as any)?.data ?? (envelope as any)?.movie ?? null; + + const title = movie?.title ?? `Movie ${id}`; + const imagePath: string = movie?.imageUrl ?? ''; + + const poster = + imagePath && imagePath.trim().length > 0 + ? `${TMDB_IMAGE_BASE_URL}${ + imagePath.startsWith('/') ? '' : '/' + }${imagePath}` + : `https://via.placeholder.com/150x220/667eea/ffffff?text=${encodeURIComponent( + title + )}`; + + return { id, title, poster }; + } catch (err) { + console.error('Failed to fetch movie detail:', err); + return { + id, + title: `Movie ${id}`, + poster: null, + }; + } + }; + + const [toWatchMovies, completedMovies] = await Promise.all([ + Promise.all(toWatchIds.map(fetchMovie)), + Promise.all(completedIds.map(fetchMovie)), + ]); + + const dedupe = (arr: MovieListItem[]) => + arr.filter( + (m, idx, self) => self.findIndex(x => x.id === m.id) === idx + ); setMoviesByStatus({ - toWatch: [], - completed: deduped, + toWatch: dedupe(toWatchMovies), + completed: dedupe(completedMovies), }); - if (deduped.length > 0) { + if (toWatchMovies.length > 0) { + setActiveSubTab('toWatch'); + } else if (completedMovies.length > 0) { setActiveSubTab('completed'); + } else { + setActiveSubTab('toWatch'); } } catch (err: any) { console.error('Failed to load movies for user:', err); setError(err?.message || 'Failed to load movies'); + setMoviesByStatus({ toWatch: [], completed: [] }); } finally { setLoading(false); } - }, [userId]); + }, []); - useEffect(() => { - fetchMoviesForUser(); + const hydrateFromProfile = useCallback(async () => { + try { + await fetchMoviesForUser(); + } catch (error) { + console.error('Error hydrating profile:', error); + } }, [fetchMoviesForUser]); + useEffect(() => { + hydrateFromProfile(); + }, [hydrateFromProfile]); + const emptyMessage = useMemo(() => { - if (!userId) return 'Sign in to see your movies.'; if (activeSubTab === 'toWatch') return 'No watchlist movies yet.'; return 'No movies found for this user.'; }, [activeSubTab, userId]); + const handleMoviePress = (movieId: string) => { + router.push({ + pathname: '/movies/[movieId]', + params: { movieId }, + }); + }; + + /** 🎬 Poster-only grid item */ const renderMovie = ({ item }: { item: MovieListItem }) => ( - + handleMoviePress(item.id)} + activeOpacity={0.85} + style={tw`w-1/3 p-1`} + > {item.poster ? ( ) : ( )} - - {item.title} - - - + ); return ( + {/* Sub-tabs */} { paddingVertical: 8, borderRadius: 8, marginHorizontal: 2, - backgroundColor: activeSubTab === 'toWatch' ? '#D62E05' : 'transparent', + backgroundColor: + activeSubTab === 'toWatch' ? '#D62E05' : 'transparent', }, ]} onPress={() => setActiveSubTab('toWatch')} @@ -164,6 +209,7 @@ const MoviesGrid = ({ userId }: Props) => { To Be Watched + { paddingVertical: 8, borderRadius: 8, marginHorizontal: 2, - backgroundColor: activeSubTab === 'completed' ? '#D62E05' : 'transparent', + backgroundColor: + activeSubTab === 'completed' ? '#D62E05' : 'transparent', }, ]} onPress={() => setActiveSubTab('completed')} @@ -179,7 +226,9 @@ const MoviesGrid = ({ userId }: Props) => { Completed @@ -196,7 +245,7 @@ const MoviesGrid = ({ userId }: Props) => { {error} Retry @@ -207,15 +256,16 @@ const MoviesGrid = ({ userId }: Props) => { ) : ( item.id} + keyExtractor={item => item.id} renderItem={renderMovie} + numColumns={3} scrollEnabled={false} removeClippedSubviews={false} - initialNumToRender={movies.length || 10} - maxToRenderPerBatch={movies.length || 10} + initialNumToRender={movies.length || 9} + maxToRenderPerBatch={movies.length || 9} windowSize={Math.max(5, movies.length || 5)} showsVerticalScrollIndicator={false} - ItemSeparatorComponent={() => } + columnWrapperStyle={tw`justify-start`} ListFooterComponent={} /> )} diff --git a/frontend/app/profilePage/components/PostsList.tsx b/frontend/app/profilePage/components/PostsList.tsx index 213efc24..c5d52f51 100644 --- a/frontend/app/profilePage/components/PostsList.tsx +++ b/frontend/app/profilePage/components/PostsList.tsx @@ -8,11 +8,11 @@ import { FlatList, } from 'react-native'; import tw from 'twrnc'; -import { User } from '../_types'; +import { User } from '../../../lib/profilePage/_types'; import { Feather } from '@expo/vector-icons'; import { getPosts } from '../../../services/postsService'; import type { components } from '../../../types/api-generated'; -import { formatCount } from '../_utils'; +import { formatCount } from '../../../lib/profilePage/_utils'; type Props = { user: User; @@ -53,7 +53,7 @@ const PostsList = ({ user, userId }: Props) => { setError(null); const res = await getPosts({ userId: resolvedUserId, - parentPostId: null, + repostedPostId: null, // Only show original posts, not reposts limit: 50, }); setPosts(res); @@ -115,7 +115,7 @@ const PostsList = ({ user, userId }: Props) => { return ( item.id} + keyExtractor={item => item.id} scrollEnabled={false} removeClippedSubviews={false} initialNumToRender={posts.length || 10} @@ -125,7 +125,7 @@ const PostsList = ({ user, userId }: Props) => { renderItem={({ item: p }) => { const likeCount = formatCount(p.reactionCount ?? 0); const commentCount = formatCount(p.commentCount ?? 0); - const replyCount = formatCount(p.replyCount ?? 0); + const repostCount = formatCount(p.repostCount ?? 0); const avatar = user.profilePic; const displayName = p.UserProfile?.username?.trim() || @@ -146,15 +146,21 @@ const PostsList = ({ user, userId }: Props) => { - {likeCount} + + {likeCount} + - {commentCount} + + {commentCount} + - {replyCount} + + {repostCount} + diff --git a/frontend/app/profilePage/index.tsx b/frontend/app/profilePage/index.tsx index e3f7ea97..bd84cf18 100644 --- a/frontend/app/profilePage/index.tsx +++ b/frontend/app/profilePage/index.tsx @@ -13,8 +13,8 @@ import { router, useFocusEffect, useNavigation } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { styles as bottomNavStyles } from '../../styles/BottomNavBar.styles'; import tw from 'twrnc'; -import { User, TabKey } from './_types'; -import { formatCount } from './_utils'; +import { User, TabKey } from '../../lib/profilePage/_types'; +import { formatCount } from '../../lib/profilePage/_utils'; import MoviesGrid from './components/MoviesGrid'; import PostsList from './components/PostsList'; import EventsList from './components/EventsList'; @@ -25,7 +25,12 @@ import { getFollowers, getFollowing } from '../../services/followService'; import type { components } from '../../types/api-generated'; import { getUserProfile } from '../../services/userService'; -type UserProfile = components['schemas']['UserProfile']; +type UserProfile = components['schemas']['UserProfile'] & { + moviesToWatch?: string[]; + moviesCompleted?: string[]; + eventsSaved?: string[]; + eventsAttended?: string[]; +}; type Props = { user?: User; @@ -34,6 +39,7 @@ type Props = { onUnfollow?: () => Promise | void; isFollowing?: boolean; profileUserId?: string; + profileData?: UserProfile | null; }; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -55,9 +61,10 @@ const ProfilePage = ({ onUnfollow, isFollowing = false, profileUserId, + profileData = null, }: Props) => { const [activeTab, setActiveTab] = useState('movies'); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(profileData); const [followersCount, setFollowersCount] = useState(0); const [followingCount, setFollowingCount] = useState(0); const [loading, setLoading] = useState(isMe); @@ -132,43 +139,51 @@ const ProfilePage = ({ }; }, [fetchProfileData, isMe]); - const resolvedUsername = isMe - ? profile?.username && profile.username.trim().length > 0 - ? profile.username - : 'user' - : userProp?.username && userProp.username.trim().length > 0 - ? userProp.username - : 'user'; + useEffect(() => { + if (profileData) { + setProfile(profileData); + } + }, [profileData]); + + const resolvedDisplayName = isMe + ? profile?.displayName?.trim() || + profile?.username?.trim() || + 'user' + : userProp?.name?.trim() || + userProp?.username?.trim() || + 'user'; const derivedBio = isMe - ? profile?.favoriteMovies?.[0]?.trim() || 'No Bio' + ? profile?.bio?.trim() || + profile?.favoriteMovies?.[0]?.trim() || + 'No Bio' : userProp?.bio || 'No Bio'; const displayUser: User = isMe && profile ? { - name: resolvedUsername || 'User', - username: resolvedUsername || 'user', + name: resolvedDisplayName || 'User', + username: profile.username || 'user', bio: derivedBio, followers: followersCount, following: followingCount, profilePic: profile.profilePicture || `https://ui-avatars.com/api/?name=${encodeURIComponent( - resolvedUsername || 'User' + resolvedDisplayName || 'User' )}&size=200&background=667eea&color=fff`, } : userProp ? { ...userProp, - name: userProp.name || resolvedUsername || 'User', - username: resolvedUsername || 'user', + name: userProp.name || resolvedDisplayName || 'User', + username: userProp.username || resolvedDisplayName || 'user', bio: derivedBio, followers: userProp.followers ?? 0, following: userProp.following ?? 0, profilePic: userProp.profilePic || `https://ui-avatars.com/api/?name=${encodeURIComponent( - resolvedUsername || 'User' + resolvedDisplayName || 'User' )}&size=200&background=667eea&color=fff`, } : { @@ -437,11 +452,6 @@ const ProfilePage = ({ - {/* Activity header */} - - - - {/* Tabs row */} - {activeTab === 'movies' && } + {activeTab === 'movies' && ( + + )} {activeTab === 'posts' && } - {activeTab === 'events' && } + {activeTab === 'events' && ( + + )} {activeTab === 'badges' && } diff --git a/frontend/app/profilePage/settings.tsx b/frontend/app/profilePage/settings.tsx index 7a9e633e..553306db 100644 --- a/frontend/app/profilePage/settings.tsx +++ b/frontend/app/profilePage/settings.tsx @@ -19,8 +19,7 @@ import { styles as bottomNavStyles } from '../../styles/BottomNavBar.styles'; export default function Settings() { const [displayName, setDisplayName] = useState(''); - const [bio, setBio] = useState('South Asian cinema enthusiast 🎬 | SRK forever ❤️'); - const [whatsapp, setWhatsapp] = useState('+1 (555) 555-5555'); + const [bio, setBio] = useState(''); const [photoUri, setPhotoUri] = useState('https://i.pravatar.cc/150?img=3'); const [hasCustomPhoto, setHasCustomPhoto] = useState(false); const [loading, setLoading] = useState(true); @@ -38,10 +37,12 @@ export default function Settings() { setLoading(true); const res = await getUserProfile(); const username = res.userProfile?.username?.trim() || 'user'; - setDisplayName(username); + const profileDisplayName = res.userProfile?.displayName?.trim() || ''; + setDisplayName(profileDisplayName); const storedBio = - res.userProfile?.favoriteMovies?.[0] || - 'South Asian cinema enthusiast 🎬 | SRK forever ❤️'; + res.userProfile?.bio ?? + res.userProfile?.favoriteMovies?.[0] ?? + ''; setBio(storedBio); const customPhoto = !!res.userProfile?.profilePicture; setHasCustomPhoto(customPhoto); @@ -79,15 +80,16 @@ export default function Settings() { if (saving) return; try { setSaving(true); - const normalizedName = displayName.trim() || 'user'; + const normalizedDisplayName = displayName.trim() || null; await updateUserProfile({ - username: normalizedName, - favoriteMovies: bio.trim() ? [bio.trim()] : [], + displayName: normalizedDisplayName, + bio: bio.trim() || null, }); + const nameForAvatar = normalizedDisplayName || 'user'; if (!hasCustomPhoto) { setPhotoUri( `https://ui-avatars.com/api/?name=${encodeURIComponent( - normalizedName + nameForAvatar )}&size=200&background=667eea&color=fff` ); } @@ -245,21 +247,6 @@ export default function Settings() { ]} /> - - {/* WhatsApp Section */} - - WhatsApp Number - - )} diff --git a/frontend/app/profilePage/user/[userId].tsx b/frontend/app/profilePage/user/[userId].tsx index 8e81b9c6..6b23e15a 100644 --- a/frontend/app/profilePage/user/[userId].tsx +++ b/frontend/app/profilePage/user/[userId].tsx @@ -2,10 +2,11 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { useLocalSearchParams } from 'expo-router'; import { DeviceEventEmitter } from 'react-native'; import ProfilePage from '../index'; -import { followUser, unfollowUser, getFollowers, getFollowing } from './followServiceProxy'; -import { getUserProfile } from '../../../services/userService'; +import { followUser, unfollowUser, getFollowers, getFollowing } from '../../../lib/profilePage/followServiceProxy'; +import { getUserProfile, getUserProfileById } from '../../../services/userService'; import { searchUsers } from '../../../services/searchService'; -import type { User } from '../_types'; +import type { User } from '../../../lib/profilePage/_types'; +import type { components } from '../../../types/api-generated'; /** * Standalone profile screen for viewing another user's page. @@ -28,6 +29,7 @@ export default function OtherUserProfile() { const [currentUserId, setCurrentUserId] = useState(null); const initialUserId = params.userId ?? 'demo-user'; const [resolvedUserId, setResolvedUserId] = useState(initialUserId); + const [profileData, setProfileData] = useState(null); const isValidUuid = (val: string | null | undefined) => !!val && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test( @@ -38,29 +40,85 @@ export default function OtherUserProfile() { params.username?.trim() || params.userId || params.name || 'user'; useEffect(() => { - const maybeResolve = async () => { - if (isValidUuid(initialUserId)) { - setResolvedUserId(initialUserId); - return; - } + const fetchUserProfile = async () => { const query = params.username || params.userId || params.name; if (!query) return; + try { + // First, try to find the user by ID if we have a valid UUID + if (isValidUuid(query)) { + const response = await getUserProfileById(query); + if (response?.userProfile) { + setResolvedUserId(response.userProfile.userId); + setProfileData(response.userProfile); + return; + } + } + + // If no user found by ID or not a valid UUID, try searching by username const results = await searchUsers(String(query), 5); const normalized = String(query).toLowerCase(); - const match = - results.find((u) => (u.username || '').toLowerCase() === normalized) || - results[0]; - if (match?.userId && isValidUuid(match.userId)) { - setResolvedUserId(match.userId); - } + const match = results.find((u) => + (u.username || '').toLowerCase() === normalized || + u.userId === query + ) || results[0]; + + if (match?.userId) { + // Now fetch the full profile using the user ID + const response = await getUserProfileById(match.userId); + if (response?.userProfile) { + setResolvedUserId(response.userProfile.userId); + setProfileData(response.userProfile); + } else { + // Fallback to basic info if full profile fetch fails + setResolvedUserId(match.userId); + setProfileData({ + userId: match.userId, + username: match.username || '', + onboardingCompleted: false, + primaryLanguage: 'English', + secondaryLanguage: [], + profilePicture: match.profilePicture || null, + country: null, + city: null, + displayName: match.displayName || match.username || null, + favoriteGenres: [], + favoriteMovies: [], + bio: match.bio || null, + eventsSaved: [], + eventsAttended: [], + privateAccount: false, + spoiler: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + bookmarkedToWatch: [], + bookmarkedWatched: [] + }); + } + } } catch (err) { console.error('Failed to resolve userId from username search:', err); } }; - maybeResolve(); + fetchUserProfile(); }, [initialUserId, params.name, params.userId, params.username]); + // Once we know the userId, fetch the full profile (including events) directly + useEffect(() => { + const fetchProfile = async () => { + if (!isValidUuid(resolvedUserId)) return; + try { + const res = await getUserProfileById(resolvedUserId); + if (res?.userProfile) { + setProfileData(res.userProfile as components['schemas']['UserProfile']); + } + } catch (err) { + console.error('Failed to fetch profile by id:', err); + } + }; + fetchProfile(); + }, [resolvedUserId]); + const loadCounts = useCallback(async () => { if (!isValidUuid(resolvedUserId)) { setFollowersCount(0); @@ -153,6 +211,7 @@ export default function OtherUserProfile() { onFollow={isValidUuid(resolvedUserId) ? handleFollow : undefined} onUnfollow={isValidUuid(resolvedUserId) ? handleUnfollow : undefined} profileUserId={resolvedUserId} + profileData={profileData} /> ); } diff --git a/frontend/app/profilePage/user/[userId]/followers.tsx b/frontend/app/profilePage/user/[userId]/followers.tsx index d0d29bd9..9679d799 100644 --- a/frontend/app/profilePage/user/[userId]/followers.tsx +++ b/frontend/app/profilePage/user/[userId]/followers.tsx @@ -10,7 +10,7 @@ import { import { useLocalSearchParams, router } from 'expo-router'; import tw from 'twrnc'; import { Ionicons } from '@expo/vector-icons'; -import { getFollowers } from '../followServiceProxy'; +import { getFollowers } from '../../../../lib/profilePage/followServiceProxy'; import type { components } from '../../../../types/api-generated'; type FollowEdge = components['schemas']['FollowEdge']; diff --git a/frontend/app/profilePage/user/[userId]/following.tsx b/frontend/app/profilePage/user/[userId]/following.tsx index 11a3e068..1aeaabdc 100644 --- a/frontend/app/profilePage/user/[userId]/following.tsx +++ b/frontend/app/profilePage/user/[userId]/following.tsx @@ -10,7 +10,7 @@ import { import { useLocalSearchParams, router } from 'expo-router'; import tw from 'twrnc'; import { Ionicons } from '@expo/vector-icons'; -import { getFollowing } from '../followServiceProxy'; +import { getFollowing } from '../../../../lib/profilePage/followServiceProxy'; import type { components } from '../../../../types/api-generated'; type FollowEdge = components['schemas']['FollowEdge']; diff --git a/frontend/app/profilePage/user/followServiceProxy.ts b/frontend/app/profilePage/user/followServiceProxy.ts deleted file mode 100644 index e443ec60..00000000 --- a/frontend/app/profilePage/user/followServiceProxy.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Local proxy so deeply nested routes import follow services without brittle ../../../ chains. -export { getFollowers, getFollowing, followUser, unfollowUser } from '../../../services/followService'; diff --git a/frontend/assets/Logo.png b/frontend/assets/Logo.png new file mode 100644 index 00000000..ecb4d73e Binary files /dev/null and b/frontend/assets/Logo.png differ diff --git a/frontend/assets/Logo.svg b/frontend/assets/Logo.svg new file mode 100644 index 00000000..73475723 --- /dev/null +++ b/frontend/assets/Logo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/assets/Logo2x.png b/frontend/assets/Logo2x.png new file mode 100644 index 00000000..a76e2c93 Binary files /dev/null and b/frontend/assets/Logo2x.png differ diff --git a/frontend/assets/SplashScreen01.png b/frontend/assets/SplashScreen01.png new file mode 100644 index 00000000..d1b2a546 Binary files /dev/null and b/frontend/assets/SplashScreen01.png differ diff --git a/frontend/assets/SplashScreen02.png b/frontend/assets/SplashScreen02.png new file mode 100644 index 00000000..5aa3a1b8 Binary files /dev/null and b/frontend/assets/SplashScreen02.png differ diff --git a/frontend/assets/SplashScreen03.png b/frontend/assets/SplashScreen03.png new file mode 100644 index 00000000..133ad0db Binary files /dev/null and b/frontend/assets/SplashScreen03.png differ diff --git a/frontend/assets/SplashScreen04.png b/frontend/assets/SplashScreen04.png new file mode 100644 index 00000000..c47fa49f Binary files /dev/null and b/frontend/assets/SplashScreen04.png differ diff --git a/frontend/assets/SplashScreen05.png b/frontend/assets/SplashScreen05.png new file mode 100644 index 00000000..e986bfa7 Binary files /dev/null and b/frontend/assets/SplashScreen05.png differ diff --git a/frontend/assets/SplashScreen06.png b/frontend/assets/SplashScreen06.png new file mode 100644 index 00000000..8235f2f1 Binary files /dev/null and b/frontend/assets/SplashScreen06.png differ diff --git a/frontend/assets/SplashScreen07.png b/frontend/assets/SplashScreen07.png new file mode 100644 index 00000000..6bd2568e Binary files /dev/null and b/frontend/assets/SplashScreen07.png differ diff --git a/frontend/assets/SplashScreen08.png b/frontend/assets/SplashScreen08.png new file mode 100644 index 00000000..c47fa49f Binary files /dev/null and b/frontend/assets/SplashScreen08.png differ diff --git a/frontend/assets/SplashScreen09.png b/frontend/assets/SplashScreen09.png new file mode 100644 index 00000000..853cb1e7 Binary files /dev/null and b/frontend/assets/SplashScreen09.png differ diff --git a/frontend/assets/gif.png b/frontend/assets/gif.png new file mode 100644 index 00000000..365ca386 Binary files /dev/null and b/frontend/assets/gif.png differ diff --git a/frontend/assets/image.png b/frontend/assets/image.png new file mode 100644 index 00000000..cf9ba6dc Binary files /dev/null and b/frontend/assets/image.png differ diff --git a/frontend/assets/keyboard-down.png b/frontend/assets/keyboard-down.png new file mode 100644 index 00000000..db2991a9 Binary files /dev/null and b/frontend/assets/keyboard-down.png differ diff --git a/frontend/assets/review.png b/frontend/assets/review.png new file mode 100644 index 00000000..52ed8597 Binary files /dev/null and b/frontend/assets/review.png differ diff --git a/frontend/assets/short-take.png b/frontend/assets/short-take.png new file mode 100644 index 00000000..76c2b86e Binary files /dev/null and b/frontend/assets/short-take.png differ diff --git a/frontend/assets/tickets/wanttowatch.png b/frontend/assets/tickets/wanttowatch.png new file mode 100644 index 00000000..c9fdc75b Binary files /dev/null and b/frontend/assets/tickets/wanttowatch.png differ diff --git a/frontend/assets/tickets/wanttowatchselected.png b/frontend/assets/tickets/wanttowatchselected.png new file mode 100644 index 00000000..20ab1ef3 Binary files /dev/null and b/frontend/assets/tickets/wanttowatchselected.png differ diff --git a/frontend/assets/tickets/wanttowatchsmall.png b/frontend/assets/tickets/wanttowatchsmall.png new file mode 100644 index 00000000..892afaff Binary files /dev/null and b/frontend/assets/tickets/wanttowatchsmall.png differ diff --git a/frontend/assets/tickets/watched.png b/frontend/assets/tickets/watched.png new file mode 100644 index 00000000..db77e9ce Binary files /dev/null and b/frontend/assets/tickets/watched.png differ diff --git a/frontend/assets/tickets/watchedselected.png b/frontend/assets/tickets/watchedselected.png new file mode 100644 index 00000000..84309b71 Binary files /dev/null and b/frontend/assets/tickets/watchedselected.png differ diff --git a/frontend/assets/tickets/watchedsmall.png b/frontend/assets/tickets/watchedsmall.png new file mode 100644 index 00000000..98a01289 Binary files /dev/null and b/frontend/assets/tickets/watchedsmall.png differ diff --git a/frontend/assets/wanttowatch.png b/frontend/assets/wanttowatch.png new file mode 100644 index 00000000..e898af14 Binary files /dev/null and b/frontend/assets/wanttowatch.png differ diff --git a/frontend/assets/watched.png b/frontend/assets/watched.png new file mode 100644 index 00000000..f242a4e7 Binary files /dev/null and b/frontend/assets/watched.png differ diff --git a/frontend/assets/zondicons_add-solid.svg b/frontend/assets/zondicons_add-solid.svg new file mode 100644 index 00000000..8850b028 --- /dev/null +++ b/frontend/assets/zondicons_add-solid.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/components/AiConsensus.tsx b/frontend/components/AiConsensus.tsx new file mode 100644 index 00000000..f423c2bb --- /dev/null +++ b/frontend/components/AiConsensus.tsx @@ -0,0 +1,227 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ActivityIndicator, + TouchableOpacity, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { Svg, Path } from 'react-native-svg'; +import { t } from '../il8n/_il8n'; +import { UiTextKey } from '../il8n/_keys'; +import type { components } from '../types/api-generated'; + +type Summary = components['schemas']['MovieSummary']; + +const AiIcon = () => ( + + + +); + +type AiConsensusProps = { + summary: Summary | null; + summaryLoading: boolean; + summaryError: string | null; +}; + +export default function AiConsensus({ + summary, + summaryLoading, + summaryError, +}: AiConsensusProps) { + const [isOpen, setIsOpen] = useState(false); + + // Get first sentence from overall for closed state + const getFirstSentence = (text: string): string => { + const match = text.match(/^[^.!?]+[.!?]/); + return match ? match[0] : text; + }; + + return ( + + {/* Error state */} + {summaryError && ( + {summaryError} + )} + + {/* Summary content */} + {summary && !summaryError && ( + <> + {/* Header - always visible */} + setIsOpen(!isOpen)} + activeOpacity={0.7} + > + Consensus + + + AI Summary + + + + + {/* First sentence - always visible */} + {summary.overall && ( + + {getFirstSentence(summary.overall)} + + )} + + {/* Expanded content */} + {isOpen && ( + + {/* Pros / Cons */} + {(summary.pros?.length || summary.cons?.length) && ( + + {summary.pros && summary.pros.length > 0 && ( + + Pros + {summary.pros + .slice(0, 2) + .map((item: string, idx: number) => ( + + "{item}" + + ))} + + )} + + {summary.cons && summary.cons.length > 0 && ( + + Cons + {summary.cons + .slice(0, 2) + .map((item: string, idx: number) => ( + + "{item}" + + ))} + + )} + + )} + + {/* Sentiment percentages */} + {summary.stats && ( + + Crowd sentiment: {summary.stats.positivePercent}% positive •{' '} + {summary.stats.neutralPercent}% neutral •{' '} + {summary.stats.negativePercent}% negative + + )} + + )} + + )} + + {/* Loading state */} + {summaryLoading && !summary && ( + + + + Analyzing community posts... + + + )} + + {/* "Empty" state text when no summary yet and no error */} + {!summary && !summaryError && !summaryLoading && ( + No consensus available yet. + )} + + ); +} + +const styles = StyleSheet.create({ + summaryContainer: { + backgroundColor: '#FFF', + padding: 16, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.25, + shadowRadius: 6, + elevation: 4, + zIndex: 5, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + consensusLabel: { + fontSize: 15, + fontWeight: '700', + color: '#000', + }, + headerRight: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + aiSummaryLabel: { + fontSize: 12, + color: '#10565D', + fontWeight: '400', + }, + summaryPreview: { + fontSize: 12, + color: 'black', + }, + expandedContent: { + marginTop: 16, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 16, + marginBottom: 16, + }, + summaryColumn: { + flex: 1, + }, + summarySubheader: { + fontSize: 12, + fontWeight: '700', + marginBottom: 8, + color: '#000', + }, + summaryBullet: { + fontSize: 12, + fontWeight: '400', + color: 'black', + lineHeight: 20, + marginBottom: 4, + }, + crowdSentiment: { + fontSize: 12, + color: '#801C03', + fontWeight: '400', + }, + loadingContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginTop: 8, + }, + summaryErrorText: { + fontSize: 14, + color: '#FF3B30', + marginTop: 8, + }, + summaryHintText: { + fontSize: 13, + color: '#777', + marginTop: 6, + }, +}); diff --git a/frontend/components/BackButton.tsx b/frontend/components/BackButton.tsx new file mode 100644 index 00000000..bf5ce053 --- /dev/null +++ b/frontend/components/BackButton.tsx @@ -0,0 +1,19 @@ +import { TouchableOpacity } from "react-native"; +import MaterialIcons from '@expo/vector-icons/MaterialIcons'; +import { Dimensions } from "react-native"; + +const { width } = Dimensions.get('window') + +type BackButtonProps = { + onPress: () => void; +}; + +const BackButton = ({ onPress }: BackButtonProps) => { + return ( + + + + ) +} + +export default BackButton \ No newline at end of file diff --git a/frontend/components/Carousel.tsx b/frontend/components/Carousel.tsx index b19e19f5..131135fa 100644 --- a/frontend/components/Carousel.tsx +++ b/frontend/components/Carousel.tsx @@ -25,6 +25,7 @@ export default function MyCarousel({ components, width, height }: CarouselProps) horizontal pagingEnabled showsHorizontalScrollIndicator={false} + nestedScrollEnabled={true} onMomentumScrollEnd={(event) => { const newIndex = Math.round( event.nativeEvent.contentOffset.x / carouselWidth diff --git a/frontend/components/CreatePostBar.tsx b/frontend/components/CreatePostBar.tsx index 0e4c15d2..43e8309e 100644 --- a/frontend/components/CreatePostBar.tsx +++ b/frontend/components/CreatePostBar.tsx @@ -1,24 +1,69 @@ -import React from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; -import { MaterialIcons } from "@expo/vector-icons"; -import { styles } from "../styles/CreatePostBar.styles"; +import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; +import { ChevronLeft } from 'lucide-react-native'; interface CreatePostBarProps { - onBack: () => void; + title?: string; + onBack?: () => void; + onSubmit?: () => void; } -export default function CreatePostBar({ onBack }: CreatePostBarProps) { +export default function CreatePostBar({ title, onBack, onSubmit }: CreatePostBarProps) { return ( - - - - - - Create + + + + + + {title} + + Post + + ); -} \ No newline at end of file +} + +const styles = StyleSheet.create({ + container: { + height: 55, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + justifyContent: 'space-between', + }, + + leftSide: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + + backButton: { + padding: 4, + }, + + title: { + fontSize: 18, + fontWeight: '600', + marginLeft: 6, + textAlign: 'left', + }, + + postButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 10, + borderWidth: 1, + borderColor: '#D8D8D8', + backgroundColor: "#D8D8D8", + }, + + postButtonText: { + color: '##979797', + fontWeight: '400', + }, +}); diff --git a/frontend/components/CreatePostToolBar.tsx b/frontend/components/CreatePostToolBar.tsx new file mode 100644 index 00000000..e8812f9f --- /dev/null +++ b/frontend/components/CreatePostToolBar.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Image +} from 'react-native'; + +import ImagePic from '../assets/image.png'; +import Gif from '../assets/gif.png'; +import Keyboard from '../assets/keyboard-down.png'; + +interface CreatePostToolBarProps { + onToolbarAction: (action: string) => void; +} + +export default function CreatePostToolBar({ onToolbarAction }: CreatePostToolBarProps) { + return ( + + onToolbarAction("video")} + style={styles.toolbarItem} + > + + + Photo and Video + + + + onToolbarAction("gif")} + style={styles.toolbarItem} + > + + + GIF + + + + onToolbarAction("photo")} + style={styles.toolbarItem} + > + + + Keyboard Down + + + + ); +} + +const styles = StyleSheet.create({ + toolbar: { + flexDirection: 'row', + justifyContent: 'space-around', + paddingTop: 12, + borderTopWidth: 1, + borderColor: '#E5E5E5', + marginTop: 20, + }, + toolbarItem: { + alignItems: 'center', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + icon: { + width: 20, + height: 20, + }, + toolbarText: { + fontSize: 14, + color: '#979797', + fontFamily: "Figtree_500Medium" + }, +}); diff --git a/frontend/components/LongPostForm.tsx b/frontend/components/LongPostForm.tsx new file mode 100644 index 00000000..1617109f --- /dev/null +++ b/frontend/components/LongPostForm.tsx @@ -0,0 +1,197 @@ +import React, { useState, forwardRef, useImperativeHandle } from 'react'; +import { + View, + Text, + TextInput, + StyleSheet, + TouchableOpacity, +} from 'react-native'; + +import MovieSelectorModal from './MovieSelectorModal'; +import StarRating from './StarRating'; +import CreatePostToolBar from './CreatePostToolBar'; +import TagModal from './TagSelectorModal'; +import Tag from './Tag'; +import SpoilerButton from './SpoilerButton'; +import type { components } from '../types/api-generated'; + +type LongPostFormData = components['schemas']['LongPostFormData']; + +interface LongPostFormProps { + onSubmit: (data: LongPostFormData) => void; + onToolbarAction: (action: string) => void; + preselectedMovie?: { id: string; title: string } | null; +} + +interface Movie { + id: string; + title?: string | null; +} + +const LongPostForm = forwardRef( + ({ onSubmit, onToolbarAction, preselectedMovie }: LongPostFormProps, ref) => { + const [movie, setMovie] = useState(preselectedMovie || null); + const [spoiler, setSpoiler] = useState(false); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [movieModalVisible, setMovieModalVisible] = useState(false); + const [tagModalVisible, setTagModalVisible] = useState(false); + const [selectedTags, setSelectedTags] = useState([]); + const [rating, setRating] = useState(0); + + const CHAR_LIMIT = 280; + + useImperativeHandle(ref, () => ({ + submit() { + if (!movie) { + alert('Please select a movie.'); + return; + } + if (content.trim().length === 0) { + alert('Please enter content.'); + return; + } + + onSubmit({ + movieId: movie.id, + spoiler, + title, + content, + rating, + tags: selectedTags, + }); + }, + })); + + return ( + + { + setMovieModalVisible(true); + }} + > + + {movie ? movie.title : 'Select Movie'} + + + + + + + + + + + + + + { + if (text.length <= CHAR_LIMIT) setContent(text); + }} + /> + + { + setTagModalVisible(true); + }} + style={styles.dropdown} + > + Add Tags + + + + {selectedTags.map(t => ( + { + const updated = selectedTags.filter(tag => tag !== t); + setSelectedTags(updated); + }} + /> + ))} + + + + + setMovieModalVisible(false)} + onSelect={selectedMovie => { + setMovie(selectedMovie); + }} + /> + + setTagModalVisible(false)} + /> + + ); + } +); + +export default LongPostForm; + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 20, + marginTop: 20, + }, + + dropdown: { + borderWidth: 1.4, + borderColor: '#e66a4e', + paddingVertical: 10, + paddingHorizontal: 14, + borderRadius: 10, + alignSelf: 'flex-start', + }, + dropdownText: { + fontFamily: 'Figtree_500Medium', + fontSize: 15, + color: '#e66a4e', + }, + + titleInput: { + fontSize: 22, + fontFamily: 'Figtree_600SemiBold', + marginBottom: 12, + color: '#000', + }, + + input: { + marginTop: 18, + minHeight: 160, + fontFamily: 'Figtree_400Regular', + fontSize: 15, + color: '#333', + }, + + starContainer: { + marginBottom: 16, + alignItems: 'flex-start', + }, + + tagRow: { + flexDirection: 'row', + flexWrap: 'nowrap', + gap: 8, + marginTop: 8, + }, +}); diff --git a/frontend/components/MovieSelectorModal.tsx b/frontend/components/MovieSelectorModal.tsx new file mode 100644 index 00000000..2224a043 --- /dev/null +++ b/frontend/components/MovieSelectorModal.tsx @@ -0,0 +1,215 @@ +import React, { useEffect, useState, useRef } from "react"; +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + FlatList, + KeyboardAvoidingView, + Platform, + Animated, +} from "react-native"; + +import { getAllMovies } from "../services/moviesService"; + +interface Movie { + id: string; + title?: string | null; +} + +interface Props { + visible: boolean; + onClose: () => void; + onSelect: (movie: Movie) => void; +} + +export default function MovieSelectorModal({ + visible, + onClose, + onSelect, +}: Props) { + const [allMovies, setAllMovies] = useState([]); + const [query, setQuery] = useState(""); + const backdropOpacity = useRef(new Animated.Value(0)).current; + const sheetTranslateY = useRef(new Animated.Value(400)).current; + + useEffect(() => { + if (visible) { + (async () => { + try { + const movies = await getAllMovies(); + console.log("Movie API returned:", movies); + // Transform API response to use consistent 'id' property + const transformedMovies = movies.map((movie: any) => ({ + id: movie.movieId, + title: movie.title + })); + setAllMovies(transformedMovies); + } catch (err) { + console.log("Error fetching movies:", err); + } + })(); + + // Fade in backdrop first + Animated.timing(backdropOpacity, { + toValue: 1, + duration: 150, + useNativeDriver: true, + }).start(); + + // Then slide up sheet without bounce using easeOut + Animated.timing(sheetTranslateY, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(); + } else { + // Reset animations + backdropOpacity.setValue(0); + sheetTranslateY.setValue(400); + } + }, [visible]); + + const filtered = query.length + ? allMovies.filter((m) => + m.title?.toLowerCase().includes(query.toLowerCase()) + ) + : []; + + return ( + + + + + + + + + + + Select Movie + + + + item.id} + contentContainerStyle={styles.pillContainer} + renderItem={({ item }) => ( + { + onSelect(item); + onClose(); + }} + > + {item.title} + + )} + /> + + + + + ); +} + +const styles = StyleSheet.create({ + modalContainer: { + flex: 1, + }, + + backdrop: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: "rgba(0,0,0,0.4)", + }, + + backdropTouchable: { + flex: 1, + }, + + keyboardAvoidingView: { + flex: 1, + justifyContent: "flex-end", + }, + + sheet: { + backgroundColor: "white", + borderTopLeftRadius: 25, + borderTopRightRadius: 25, + paddingBottom: 40, + paddingHorizontal: 20, + paddingTop: 18, + minHeight: "45%", + }, + + dragHandle: { + width: 45, + height: 4, + backgroundColor: "#ccc", + alignSelf: "center", + borderRadius: 3, + marginBottom: 14, + }, + + label: { + fontFamily: "Figtree_600SemiBold", + fontSize: 16, + marginBottom: 8, + }, + + input: { + backgroundColor: "#f2f2f2", + borderRadius: 10, + padding: 12, + fontFamily: "Figtree_400Regular", + marginBottom: 20, + fontSize: 15, + }, + + pillContainer: { + flexDirection: "row", + flexWrap: "wrap", + gap: 10, + }, + + pill: { + backgroundColor: "#e66a4e", + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 18, + }, + + pillText: { + color: "white", + fontFamily: "Figtree_500Medium", + }, +}); diff --git a/frontend/components/PicturePost.tsx b/frontend/components/PicturePost.tsx index 02470ac2..60198e82 100644 --- a/frontend/components/PicturePost.tsx +++ b/frontend/components/PicturePost.tsx @@ -6,91 +6,116 @@ import MyCarousel from './Carousel'; const { width } = Dimensions.get('window'); type PicturePostProps = { - userName: string; - username: string; - date: string; - avatarUri?: string; - content: string; - imageUrls?: string[]; - userId?: string; + userName: string; + username: string; + date: string; + avatarUri?: string; + content: string; + imageUrls?: string[]; + userId?: string; + /** If true, show a 'Spoiler' badge on the card */ + spoiler?: boolean; }; export default function PicturePost({ - userName, - username, - date, - avatarUri, - content, - imageUrls = [], - userId, + userName, + username, + date, + avatarUri, + content, + imageUrls = [], + userId, + spoiler = false, }: PicturePostProps) { - const imageComponents = imageUrls.map((url, index) => ( - - )); + const imageComponents = imageUrls.map((url, index) => ( + + )); - return ( - - - {content} + return ( + + {/* Top-right spoiler pill */} + {spoiler && ( + + Spoiler + + )} + + + {content} - {imageUrls.length > 0 ? ( - - ) : ( - - No Image - - )} + {imageUrls.length > 0 ? ( + + ) : ( + + No Image - ); + )} + + ); } const styles = StyleSheet.create({ - container: { - backgroundColor: '#FFF', - borderRadius: width * 0.03, - padding: width * 0.04, - marginBottom: width * 0.04, - width: '100%', - }, - content: { - fontSize: width * 0.0375, - color: '#000', - lineHeight: width * 0.05, - marginTop: width * 0.03, - marginBottom: width * 0.03, - flexShrink: 1, - }, - image: { - width: '100%', - height: '100%', - borderRadius: width * 0.02, - }, - imagePlaceholder: { - width: '100%', - aspectRatio: 16 / 9, - backgroundColor: '#E8E8E8', - borderRadius: width * 0.02, - justifyContent: 'center', - alignItems: 'center', - marginBottom: width * 0.03, - }, - placeholderText: { - color: '#999', - fontSize: width * 0.035, - }, -}); \ No newline at end of file + container: { + backgroundColor: '#FFF', + borderRadius: width * 0.03, + padding: width * 0.04, + marginBottom: width * 0.04, + width: '100%', + position: 'relative', // 👈 needed for pill positioning + }, + content: { + fontSize: width * 0.0375, + color: '#000', + lineHeight: width * 0.05, + marginTop: width * 0.03, + marginBottom: width * 0.03, + flexShrink: 1, + }, + image: { + width: '100%', + height: '100%', + borderRadius: width * 0.02, + }, + imagePlaceholder: { + width: '100%', + aspectRatio: 16 / 9, + backgroundColor: '#E8E8E8', + borderRadius: width * 0.02, + justifyContent: 'center', + alignItems: 'center', + marginBottom: width * 0.03, + }, + placeholderText: { + color: '#999', + fontSize: width * 0.035, + }, + spoilerPill: { + position: 'absolute', + top: width * 0.025, + right: width * 0.035, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 999, + backgroundColor: 'rgba(0,0,0,0.75)', + borderWidth: 1, + borderColor: '#F5C518', + zIndex: 2, + }, + spoilerText: { + color: '#F5C518', + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + }, +}); diff --git a/frontend/components/PostForm.tsx b/frontend/components/PostForm.tsx deleted file mode 100644 index ef11962a..00000000 --- a/frontend/components/PostForm.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React, { useState } from 'react'; -import { View, TextInput, Button } from 'react-native'; - -interface PostFormProps { - showTitle?: boolean; - showStars?: boolean; - showTextBox?: boolean; - onSubmit: (data: { title: string; content: string; rating: number }) => void; -} - -export default function PostForm({ - showTitle = false, - showStars = false, - showTextBox = true, - onSubmit -}: PostFormProps) { - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [rating, setRating] = useState(0); - - const handleSubmit = () => { - onSubmit({ title, content, rating }); - }; - - return ( - - {showTitle && ( - - )} - - {showStars && ( - - setRating(Number(text))} - keyboardType="numeric" - style={{ - borderWidth: 1, - borderColor: '#ddd', - padding: 10, - borderRadius: 8 - }} - /> - - )} - - {showTextBox && ( - - )} - -