diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cb974a06..b9a6ca5a 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") @@ -285,22 +286,24 @@ model schema_migrations { /// 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? + 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,24 +400,24 @@ 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 + ratingId String? + 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]) + Rating Rating? @relation(fields: [ratingId], references: [id]) + UserProfile UserProfile @relation(fields: [userId], references: [userId]) @@schema("public") } model Post { - id String @id @default(uuid()) + id String @id userId String @db.Uuid content String type PostType @@ -427,7 +430,7 @@ model Post { } model Rating { - id String @id @default(uuid()) + id String @id userId String @db.Uuid movieId String stars Int @@ -455,16 +458,16 @@ model UserFollow { model UserProfile { userId String @id @db.Uuid username String? + favoriteMovies String[] @default([]) + createdAt DateTime @default(now()) + updatedAt DateTime + city String? + country String? + favoriteGenres String[] @default([]) onboardingCompleted Boolean @default(false) primaryLanguage String @default("English") - secondaryLanguage String[] @default([]) profilePicture String? - country String? - city String? - favoriteGenres String[] @default([]) - favoriteMovies String[] @default([]) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + secondaryLanguage String[] @default([]) Comment Comment[] Post Post[] Rating Rating[] @@ -484,7 +487,7 @@ model bootcamp { } 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 diff --git a/backend/src/controllers/translate.ts b/backend/src/controllers/translate.ts new file mode 100644 index 00000000..4715c6b7 --- /dev/null +++ b/backend/src/controllers/translate.ts @@ -0,0 +1,99 @@ +import { Request, Response } from 'express'; + +const BASE_URL = 'https://ftapi.pythonanywhere.com'; + +interface TranslationResponse { + 'source-language': string; + 'source-text': string; + 'destination-language': string; + 'destination-text': string; + pronunciation: { + 'source-text-phonetic': string | null; + 'source-text-audio': string; + 'destination-text-audio': string; + }; + translations: { + 'all-translations': Array<[string, string[]]> | null; + 'possible-translations': string[]; + 'possible-mistakes': string[] | null; + }; + definitions: any[] | null; + 'see-also': string[] | null; +} + +/** + * translate text with optional source language (auto detect if not provided) + * @query text - Text to translate (required) + * @query dl - Destination language code (required) + * @query sl - Source language code (optional, auto detects if not provided) + */ +export const translateText = async (req: Request, res: Response) => { + try { + const { text, dl, sl } = req.query; + + if (!text || !dl) { + return res.status(400).json({ + error: 'Missing parameters', + message: 'Both text and destination language are required', + }); + } + + const params = new URLSearchParams({ + dl: dl as string, + text: text as string, + }); + + if (sl) { + params.append('sl', sl as string); + } + + const response = await fetch(`${BASE_URL}/translate?${params.toString()}`); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + const data: TranslationResponse = await response.json(); + + return res.status(200).json({ + success: true, + sourceLanguage: data['source-language'], + sourceText: data['source-text'], + destinationLanguage: data['destination-language'], + destinationText: data['destination-text'], + pronunciation: data.pronunciation, + translations: data.translations, + definitions: data.definitions, + }); + } catch (error) { + console.error('Translation error:', error); + return res.status(500).json({ + error: 'Translation failed', + message: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } +}; + +// get all supported languages +export const getSupportedLanguages = async (req: Request, res: Response) => { + try { + const response = await fetch(`${BASE_URL}/languages`); + + if (!response.ok) { + throw new Error(`API error: ${response.statusText}`); + } + + const languages = await response.json(); + + return res.status(200).json({ + success: true, + languages, + }); + } catch (error) { + console.error('Error fetching languages:', error); + return res.status(500).json({ + error: 'Failed to fetch supported languages', + message: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } +}; \ No newline at end of file diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index cee57850..9558dddb 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -6,6 +6,7 @@ import { getMovieById, updateMovie, } from "../controllers/tmdb"; +import { translateText, getSupportedLanguages } from "../controllers/translate"; import { deleteUserProfile, ensureUserProfile, getUserComments, getUserProfile, getUserRatings, updateUserProfile } from '../controllers/user'; import { authenticateUser } from '../middleware/auth'; import { protect } from "../controllers/protected"; @@ -28,6 +29,9 @@ router.get("/swagger-output.json", serveSwagger); //OpenAPI 3.0 spec router.get("/openapi.json", serveSwagger); +router.get("/api/translate", translateText); +router.get("/api/languages", getSupportedLanguages); + // everything under here is a private endpoint router.use('/api', authenticateUser, ensureUserProfile); diff --git a/frontend/screen/MovieChosenScreen.tsx b/frontend/screen/MovieChosenScreen.tsx index b7cfa01d..652589e6 100644 --- a/frontend/screen/MovieChosenScreen.tsx +++ b/frontend/screen/MovieChosenScreen.tsx @@ -1,4 +1,10 @@ -import { ScrollView, View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { + ScrollView, + View, + Text, + StyleSheet, + TouchableOpacity, +} from 'react-native'; import { useState } from 'react'; import SearchBar from '../components/SearchBar'; import RatingRow from '../components/RatingRow'; @@ -6,147 +12,237 @@ import TagList from '../components/TagList'; import ActionButtons from '../components/ActionButtons'; import ReviewCard from '../components/ReviewCard'; import FilterBar from '../components/FilterBar'; +import { translateTextApi } from '../services/translationService'; + +// keep the original English description as a constant +const ORIGINAL_DESCRIPTION = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,'; export default function MovieChosenScreen() { - const [activeTab, setActiveTab] = useState<'reviews' | 'comments'>('reviews'); + const [activeTab, setActiveTab] = useState<'reviews' | 'comments'>('reviews'); + + const [translatedDescription, setTranslatedDescription] = useState< + string | null + >(null); + const [isTranslatingDescription, setIsTranslatingDescription] = + useState(false); + const [translateError, setTranslateError] = useState(null); + + const handleTranslateDescription = async () => { + const description = ORIGINAL_DESCRIPTION; + if (!description) return; + + try { + setIsTranslatingDescription(true); + setTranslateError(null); + + // Hard-code: translate FROM English TO Hindi + const resp = await translateTextApi(description, 'hi', 'en'); + console.log('[translate] response:', resp); + + setTranslatedDescription(resp.destinationText); + } catch (err: any) { + console.error('[translate] error translating description:', err?.message); + setTranslateError('Failed to translate description'); + } finally { + setIsTranslatingDescription(false); + } + }; + + // pick which description to show + const descriptionToShow = translatedDescription ?? ORIGINAL_DESCRIPTION; - return ( - - {/* Search Bar */} - + return ( + + {/* Search Bar */} + - {/* Movie Title */} - Movie Title + {/* Movie Title */} + Movie Title - {/* Metadata */} - - 2025 • Directed by: Emily Chooi - Genre • Genre • Genre • Genre • Genre - + {/* Metadata */} + + 2025 • Directed by: Emily Chooi + + Genre • Genre • Genre • Genre • Genre + + - {/* Description */} - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, + {/* Description + Translate button */} + + + {descriptionToShow} + + + + + + {isTranslatingDescription + ? 'Translating...' + : 'Translate to Hindi'} + + + + {translateError && ( + {translateError} + )} + + + {/* Tags */} + + + {/* Ratings */} + + + + + + + {/* Action Buttons */} + + + {/* Tabs */} + + setActiveTab('reviews')} + > + + Reviews + + + setActiveTab('comments')} + > + + Comments + + + + + {/* Filter Bar*/} + - {/* Tags */} - - - {/* Ratings */} - - - - - - - {/* Action Buttons */} - - - {/* Tabs */} - - setActiveTab('reviews')} - > - - Reviews - - - setActiveTab('comments')} - > - - Comments - - - - - {/* Filter Bar*/} - - - {/* Content */} - - {activeTab === 'reviews' ? ( - <> - - - - ) : ( - Comments coming soon... - )} - - - ); + {/* Content */} + + {activeTab === 'reviews' ? ( + <> + + + + ) : ( + Comments coming soon... + )} + + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#F5F5F5', - }, - title: { - fontSize: 34, - fontWeight: 'bold', - paddingHorizontal: 16, - paddingTop: 24, - paddingBottom: 8, - }, - metaContainer: { - paddingHorizontal: 16, - paddingBottom: 12, - }, - metaText: { - fontSize: 14, - color: '#666', - }, - genreText: { - fontSize: 14, - color: '#666', - marginTop: 4, - }, - description: { - fontSize: 15, - lineHeight: 22, - paddingHorizontal: 16, - paddingVertical: 12, - color: '#333', - }, - ratingsContainer: { - backgroundColor: '#FFF', - padding: 16, - marginTop: 12, - }, - tabContainer: { - flexDirection: 'row', - backgroundColor: '#FFF', - borderBottomWidth: 1, - borderBottomColor: '#E0E0E0', - }, - tab: { - flex: 1, - paddingVertical: 16, - alignItems: 'center', - }, - activeTab: { - borderBottomWidth: 3, - borderBottomColor: '#000', - }, - tabText: { - fontSize: 16, - color: '#999', - }, - activeTabText: { - color: '#000', - fontWeight: '600', - }, - contentContainer: { - padding: 16, - }, - placeholderText: { - fontSize: 16, - color: '#999', - textAlign: 'center', - paddingVertical: 32, - }, -}); \ No newline at end of file + container: { + flex: 1, + backgroundColor: '#F5F5F5', + }, + title: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 16, + paddingTop: 24, + paddingBottom: 8, + }, + metaContainer: { + paddingHorizontal: 16, + paddingBottom: 12, + }, + metaText: { + fontSize: 14, + color: '#666', + }, + genreText: { + fontSize: 14, + color: '#666', + marginTop: 4, + }, + description: { + fontSize: 15, + lineHeight: 22, + paddingHorizontal: 16, + paddingVertical: 12, + color: '#333', + }, + translateRow: { + paddingHorizontal: 16, + marginBottom: 4, + }, + translateButton: { + alignSelf: 'flex-start', + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + backgroundColor: '#000', + }, + translateButtonText: { + color: '#fff', + fontSize: 13, + fontWeight: '500', + }, + translateErrorText: { + paddingHorizontal: 16, + marginTop: 4, + fontSize: 12, + color: '#FF3B30', + }, + ratingsContainer: { + backgroundColor: '#FFF', + padding: 16, + marginTop: 12, + }, + tabContainer: { + flexDirection: 'row', + backgroundColor: '#FFF', + borderBottomWidth: 1, + borderBottomColor: '#E0E0E0', + }, + tab: { + flex: 1, + paddingVertical: 16, + alignItems: 'center', + }, + activeTab: { + borderBottomWidth: 3, + borderBottomColor: '#000', + }, + tabText: { + fontSize: 16, + color: '#999', + }, + activeTabText: { + color: '#000', + fontWeight: '600', + }, + contentContainer: { + padding: 16, + }, + placeholderText: { + fontSize: 16, + color: '#999', + textAlign: 'center', + paddingVertical: 32, + }, +}); diff --git a/frontend/services/translationService.ts b/frontend/services/translationService.ts new file mode 100644 index 00000000..9ab58e27 --- /dev/null +++ b/frontend/services/translationService.ts @@ -0,0 +1,39 @@ +// frontend/app/services/translationService.ts +import { api } from "./apiClient"; + +export type TranslateApiResponse = { + success: boolean; + sourceLanguage: string; + sourceText: string; + destinationLanguage: string; + destinationText: string; + pronunciation: { + "source-text-phonetic": string | null; + "source-text-audio": string; + "destination-text-audio": string; + }; + translations: { + "all-translations": Array<[string, string[]]> | null; + "possible-translations": string[]; + "possible-mistakes": string[] | null; + }; + definitions: any[] | null; +}; + +export async function translateTextApi( + text: string, + destLang: string, + sourceLang?: string +): Promise { + const params: Record = { + text, + dl: destLang, + }; + + if (sourceLang) { + params.sl = sourceLang; + } + + // Your backend route is /api/translate + return api.get("/api/translate", params); +} diff --git a/frontend/types/api-generated.ts b/frontend/types/api-generated.ts index 3d78a299..deafc9cb 100644 --- a/frontend/types/api-generated.ts +++ b/frontend/types/api-generated.ts @@ -2437,6 +2437,97 @@ export interface paths { patch?: never; trace?: never; }; + "/translate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: { + text?: string; + dl?: string; + sl?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/translate/languages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/{api{where{userId:user.id}}": { parameters: { query?: never;