diff --git a/backend/src/controllers/translate.ts b/backend/src/controllers/translate.ts new file mode 100644 index 0000000..4715c6b --- /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 4b39c1c..0100f78 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -19,6 +19,7 @@ import { createPost, getPostById, getPosts, updatePost, deletePost, getPostRepos import { searchMovies, searchUsers, searchReviews, searchPosts } from "../controllers/search.js"; import { getHomeFeed } from "../controllers/feed"; import { getMovieSummaryHandler } from "../controllers/movies.js"; +import { translateText, getSupportedLanguages } from "../controllers/translate"; // backend/src/routes/index.ts const router = Router(); @@ -26,8 +27,8 @@ const router = Router(); router.get("/api/ping", ping); router.get("/api/db-test", dbTest); router.get('/movies/:movieId/summary', getMovieSummaryHandler); - - +router.get("/api/translate", translateText); +router.get("/api/languages", getSupportedLanguages); // Legacy endpoint router.get("/swagger-output.json", serveSwagger); diff --git a/backend/src/services/translationService.ts b/backend/src/services/translationService.ts new file mode 100644 index 0000000..4715c6b --- /dev/null +++ b/backend/src/services/translationService.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/frontend/assets/translate.png b/frontend/assets/translate.png new file mode 100644 index 0000000..60125e7 Binary files /dev/null and b/frontend/assets/translate.png differ diff --git a/frontend/components/PicturePost.tsx b/frontend/components/PicturePost.tsx index 60198e8..44f5261 100644 --- a/frontend/components/PicturePost.tsx +++ b/frontend/components/PicturePost.tsx @@ -1,10 +1,38 @@ -import React from 'react'; +import React, { useState } from 'react'; import { View, Text, StyleSheet, Dimensions, Image } from 'react-native'; import UserBar from './UserBar'; import MyCarousel from './Carousel'; +import { useAuth } from '../context/AuthContext'; +import { translateTextApi } from '../services/translationService'; const { width } = Dimensions.get('window'); +function mapPrimaryLanguage(primaryLanguage?: string | null): { + code: string; + label: string; +} { + if (!primaryLanguage) return { code: 'en', label: 'English' }; + + const lower = primaryLanguage.toLowerCase().trim(); + + switch (lower) { + case 'hindi': + return { code: 'hi', label: 'Hindi' }; + case 'tamil': + return { code: 'ta', label: 'Tamil' }; + case 'telugu': + return { code: 'te', label: 'Telugu' }; + case 'bengali': + case 'bangla': + return { code: 'bn', label: 'Bengali' }; + case 'english': + case 'en': + return { code: 'en', label: 'English' }; + default: + return { code: 'en', label: primaryLanguage }; + } +} + type PicturePostProps = { userName: string; username: string; @@ -27,6 +55,60 @@ export default function PicturePost({ userId, spoiler = false, }: PicturePostProps) { + const { profile } = useAuth(); + + const mapped = mapPrimaryLanguage((profile as any)?.primaryLanguage); + const userLangCode = mapped.code; + + const [isTranslated, setIsTranslated] = useState(false); + const [translated, setTranslated] = useState(null); + const [loadingTranslation, setLoadingTranslation] = useState(false); + + const shouldShowTranslate = userLangCode !== 'en'; + + console.log( + '[PicturePost] RENDER: langRaw =', + (profile as any)?.primaryLanguage + ); + console.log('[PicturePost] mapped lang =', mapped); + console.log('[PicturePost] shouldShowTranslate =', shouldShowTranslate); + + const captionToShow = isTranslated && translated ? translated : content; + + const toggleTranslation = async () => { + console.log('=== [PicturePost] toggleTranslation pressed ===', { + isTranslated, + hasTranslated: !!translated, + }); + + const turningOn = !isTranslated; + + if (turningOn && shouldShowTranslate && !translated) { + try { + setLoadingTranslation(true); + + console.log('[PicturePost] Calling translateTextApi with:', { + textSnippet: content.slice(0, 80), + dest: userLangCode, + }); + + // assume original caption is English; force 'en' as source + const response = await translateTextApi(content, userLangCode, 'en'); + console.log('[PicturePost] translateTextApi response =', response); + + const translatedText = response.destinationText || content; + setTranslated(translatedText); + } catch (err) { + console.error('[PicturePost] Translation ERROR:', err); + setTranslated(null); + } finally { + setLoadingTranslation(false); + } + } + + setIsTranslated(prev => !prev); + }; + const imageComponents = imageUrls.map((url, index) => ( - {content} + + {captionToShow} {imageUrls.length > 0 ? ( @@ -72,7 +158,7 @@ const styles = StyleSheet.create({ padding: width * 0.04, marginBottom: width * 0.04, width: '100%', - position: 'relative', // 👈 needed for pill positioning + position: 'relative', // for spoiler pill }, content: { fontSize: width * 0.0375, diff --git a/frontend/components/TextPost.tsx b/frontend/components/TextPost.tsx index 035eaf6..ef670d0 100644 --- a/frontend/components/TextPost.tsx +++ b/frontend/components/TextPost.tsx @@ -1,9 +1,38 @@ -import React from 'react'; +// frontend/app/components/TextPost.tsx +import React, { useState } from 'react'; import { View, Text, StyleSheet, Dimensions } from 'react-native'; import UserBar from './UserBar'; +import { translateTextApi } from '../services/translationService'; +import { useAuth } from '../context/AuthContext'; const { width } = Dimensions.get('window'); +function mapPrimaryLanguage(primaryLanguage?: string | null): { + code: string; + label: string; +} { + if (!primaryLanguage) return { code: 'en', label: 'English' }; + + const lower = primaryLanguage.toLowerCase().trim(); + + switch (lower) { + case 'hindi': + return { code: 'hi', label: 'Hindi' }; + case 'tamil': + return { code: 'ta', label: 'Tamil' }; + case 'telugu': + return { code: 'te', label: 'Telugu' }; + case 'bengali': + case 'bangla': + return { code: 'bn', label: 'Bengali' }; + case 'english': + case 'en': + return { code: 'en', label: 'English' }; + default: + return { code: 'en', label: primaryLanguage }; + } +} + type TextPostProps = { userName: string; username: string; @@ -11,7 +40,6 @@ type TextPostProps = { avatarUri?: string; content: string; userId?: string; - /** If true, show a 'Spoiler' badge on the card */ spoiler?: boolean; }; @@ -24,9 +52,61 @@ export default function TextPost({ userId, spoiler = false, }: TextPostProps) { + const { profile } = useAuth(); + + const mapped = mapPrimaryLanguage((profile as any)?.primaryLanguage); + const userLangCode = mapped.code; + + const [isTranslated, setIsTranslated] = useState(false); + const [translated, setTranslated] = useState(null); + const [loadingTranslation, setLoadingTranslation] = useState(false); + + const shouldShowTranslate = userLangCode !== 'en'; + + console.log( + '[TextPost] RENDER: langRaw =', + (profile as any)?.primaryLanguage + ); + console.log('[TextPost] mapped lang =', mapped); + console.log('[TextPost] shouldShowTranslate =', shouldShowTranslate); + + const textToShow = isTranslated && translated ? translated : content; + + const toggleTranslation = async () => { + console.log('=== [TextPost] toggleTranslation pressed ===', { + isTranslated, + hasTranslated: !!translated, + }); + + const turningOn = !isTranslated; + + if (turningOn && shouldShowTranslate && !translated) { + try { + setLoadingTranslation(true); + + console.log('[TextPost] Calling translateTextApi with:', { + textSnippet: content.slice(0, 80), + dest: userLangCode, + }); + + const response = await translateTextApi(content, userLangCode, 'en'); + console.log('[TextPost] translateTextApi response =', response); + + const translatedText = response.destinationText || content; + setTranslated(translatedText); + } catch (err) { + console.error('[TextPost] Translation ERROR:', err); + setTranslated(null); + } finally { + setLoadingTranslation(false); + } + } + + setIsTranslated(prev => !prev); + }; + return ( - {/* Top-right spoiler pill */} {spoiler && ( Spoiler @@ -39,8 +119,12 @@ export default function TextPost({ date={date} avatarUri={avatarUri} userId={userId} + showTranslate={shouldShowTranslate} + onPressTranslate={toggleTranslation} + loadingTranslate={loadingTranslation} /> - {content} + + {textToShow} ); } diff --git a/frontend/components/UserBar.tsx b/frontend/components/UserBar.tsx index 9425cdc..2f1abfe 100644 --- a/frontend/components/UserBar.tsx +++ b/frontend/components/UserBar.tsx @@ -1,27 +1,35 @@ +// components/UserBar.tsx import React from 'react'; import { View, Text, - Image, StyleSheet, + Image, TouchableOpacity, + ActivityIndicator, Dimensions, } from 'react-native'; -import { useRouter } from 'expo-router'; -import Avatar from './Avatar'; const { width } = Dimensions.get('window'); -type UserBarProps = { +// ⬇️ adjust if your icon lives somewhere else +const translateIcon = require('../assets/translate.png'); + +export type UserBarProps = { name: string; username?: string; date?: string; avatarUri?: string; - avatarSize?: number; userId?: string; + + // old props used by ReviewPost + avatarSize?: number; // in px nameColor?: string; - usernameColor?: string; - dateColor?: string; + + // new translation props + showTranslate?: boolean; + onPressTranslate?: () => void; + loadingTranslate?: boolean; }; export default function UserBar({ @@ -29,46 +37,64 @@ export default function UserBar({ username, date, avatarUri, - avatarSize = width * 0.1, - userId, - nameColor = '#000', - usernameColor = '#666', - dateColor = '#999', + userId, // not used visually yet, but kept for navigation if you want + avatarSize, + nameColor, + showTranslate = false, + onPressTranslate, + loadingTranslate = false, }: UserBarProps) { - const router = useRouter(); - - const handlePress = () => { - if (userId) { - router.push(`/profilePage?userId=${userId}`); - } - }; + // default avatar size if not provided + const size = avatarSize ?? width * 0.1; return ( - - - - - - {name} - - {username && ( - - @{username} + {/* Left: avatar + name/username */} + + {avatarUri ? ( + + ) : ( + + )} + + + + {name} + {username ? @{username} : null} + + + + {/* Right: translate icon (optional) + date (optional) */} + + {showTranslate && ( + + {loadingTranslate ? ( + + ) : ( + + )} + )} + + {date ? {date} : null} - {date && {date}} ); } @@ -76,27 +102,43 @@ export default function UserBar({ const styles = StyleSheet.create({ container: { flexDirection: 'row', + justifyContent: 'space-between', alignItems: 'center', - width: '100%', }, - avatar: { - marginRight: width * 0.03, + leftRow: { + flexDirection: 'row', + alignItems: 'center', }, - textContainer: { - flex: 1, + rightRow: { flexDirection: 'row', alignItems: 'center', - gap: width * 0.02, + gap: 6, + }, + avatar: { + marginRight: width * 0.03, + }, + avatarPlaceholder: { + backgroundColor: '#DDD', }, name: { - fontSize: 12, + fontSize: width * 0.04, fontWeight: '600', + color: '#000', }, username: { - fontSize: 12, - marginTop: 2, + fontSize: width * 0.032, + color: '#666', }, date: { - fontSize: width * 0.035, + fontSize: width * 0.032, + color: '#888', + }, + translateButton: { + marginRight: 4, + }, + translateIcon: { + width: 16, + height: 16, + resizeMode: 'contain', }, }); diff --git a/frontend/services/translationService.ts b/frontend/services/translationService.ts new file mode 100644 index 0000000..59dba44 --- /dev/null +++ b/frontend/services/translationService.ts @@ -0,0 +1,42 @@ +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; + } + + // IMPORTANT: pass params directly (this matches your old working version) + const response = await api.get("/api/translate", params as any); + + // Handle both: api.get returns either raw data OR AxiosResponse + const data = (response as any).data ?? response; + return data as TranslateApiResponse; +}