diff --git a/backend/src/controllers/search.ts b/backend/src/controllers/search.ts index a15c12a..f7f695f 100644 --- a/backend/src/controllers/search.ts +++ b/backend/src/controllers/search.ts @@ -9,6 +9,7 @@ type MovieResult = { localRating: string | null; languages: any; numRatings: string | null; + imageUrl: string | null; source: "local" | "tmdb"; }; @@ -38,6 +39,7 @@ async function searchTMDB(query: string): Promise { title: movie.title, overview: movie.overview, vote_average: movie.vote_average, + poster_path: movie.poster_path, spoken_languages: [], })); } @@ -104,6 +106,7 @@ export const searchMovies = async (req: Request, res: Response) => { localRating: true, languages: true, numRatings: true, + imageUrl: true, }, }); @@ -161,6 +164,7 @@ export const searchMovies = async (req: Request, res: Response) => { localRating: saved.localRating, languages: saved.languages, numRatings: saved.numRatings, + imageUrl: saved.imageUrl, source: "tmdb" as const, }); } catch (saveErr) { @@ -240,6 +244,16 @@ export const searchUsers = async (req: Request, res: Response) => { OR: orClauses, }, take: limitNum, + + select: { + userId: true, + username: true, + favoriteGenres: true, + secondaryLanguage: true, + favoriteMovies: true, + createdAt: true, + profilePicture: true, + }, }); const toStrings = (val?: string[] | null) => @@ -326,7 +340,7 @@ export const searchReviews = async (req: Request, res: Response) => { where: whereClause, take: limitNum, orderBy: { - votes: "desc" // sorting by most votes to least, essentially most relevant + date: "desc" // sorting by most votes to least, essentially most relevant }, include: { UserProfile: { @@ -364,7 +378,6 @@ export const searchReviews = async (req: Request, res: Response) => { export const searchPosts = async (req: Request, res: Response) => { const { q, type, limit = "10" } = req.query; - // Validate query parameter if (!q || typeof q !== "string") { return res.status(400).json({ message: "Query parameter 'q' is required" }); } @@ -378,7 +391,6 @@ export const searchPosts = async (req: Request, res: Response) => { } try { - // Build where clause dynamically const whereClause: any = { content: { contains: q, @@ -386,7 +398,6 @@ export const searchPosts = async (req: Request, res: Response) => { } }; - // Add optional type filter if (type && (type === "SHORT" || type === "LONG")) { whereClause.type = type; } @@ -395,31 +406,39 @@ export const searchPosts = async (req: Request, res: Response) => { where: whereClause, take: limitNum, orderBy: { - votes: "desc" // sorting by most votes to least, essentially most relevant + createdAt: "desc" }, include: { - UserProfile: { - select: { - userId: true, - username: true, - }, - }, - _count: { - select: { - Comment: true - } - } -}, + UserProfile: { + select: { + userId: true, + username: true, + }, + }, + movie: true, // Just include all movie fields + _count: { + select: { + Comment: true + } + } + }, }); + // Serialize the response - convert all BigInts and handle _count + const serializedPosts = JSON.parse( + JSON.stringify(posts, (key, value) => + typeof value === 'bigint' ? Number(value) : value + ) + ); + return res.json({ type: "posts", query: q, - count: posts.length, + count: serializedPosts.length, filters: { postType: type || "any", }, - results: posts, + results: serializedPosts, }); } catch (error) { console.error("searchPosts error:", error); @@ -429,3 +448,61 @@ export const searchPosts = async (req: Request, res: Response) => { }); } }; + +/** + * Search events by title or description + * GET /search/events?q={query}&limit=10 + */ +export const searchEvents = async (req: Request, res: Response) => { + const { q, limit = "10" } = req.query; + + if (!q || typeof q !== "string") { + return res.status(400).json({ message: "Query parameter 'q' is required" }); + } + + const limitNum = parseInt(limit as string); + + if (limitNum > 50) { + return res.status(400).json({ + message: "limit cannot exceed 50" + }); + } + + try { + const events = await prisma.local_event.findMany({ + where: { + OR: [ + { + title: { + contains: q, + mode: "insensitive" + } + }, + { + description: { + contains: q, + mode: "insensitive" + } + } + ] + }, + take: limitNum, + orderBy: { + time: "asc" // Show upcoming events first + }, + }); + + return res.json({ + type: "events", + query: q, + count: events.length, + results: events, + }); + } catch (error) { + console.error("searchEvents error:", error); + return res.status(500).json({ + message: "Failed to search events", + error: error instanceof Error ? error.message : String(error), + }); + } +}; \ No newline at end of file diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 3f09c57..cc6054e 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -16,7 +16,7 @@ import { getComment, createComment, updateComment, deleteComment, getMovieCommen import { createRating, getRatings, getRatingById, deleteRating, updateRating,getMovieRatings } from "../controllers/ratings"; 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 { searchMovies, searchUsers, searchReviews, searchPosts, searchEvents } from "../controllers/search.js"; import { getHomeFeed } from "../controllers/feed"; import { getMovieSummaryHandler } from "../controllers/movies.js"; import { translateText, getSupportedLanguages } from "../controllers/translate"; @@ -124,5 +124,6 @@ router.get("/api/search/movies", searchMovies) router.get("/api/search/users", searchUsers) router.get("/api/search/reviews", searchReviews) router.get("/api/search/posts", searchPosts) +router.get("/api/search/events", searchEvents) export default router; diff --git a/backend/src/tests/api/userFollows.api.test.ts b/backend/src/tests/api/userFollows.api.test.ts index f0398dc..9ed486b 100644 --- a/backend/src/tests/api/userFollows.api.test.ts +++ b/backend/src/tests/api/userFollows.api.test.ts @@ -177,12 +177,18 @@ describe('Follow Controller', () => { const dbError = new Error('Database error'); (prisma.userFollow.findMany as jest.Mock).mockRejectedValue(dbError); - await getFollowers(mockReq as Request, mockRes as Response); + // Suppress console.error for this test + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + await getFollowing(mockReq as Request, mockRes as Response); expect(statusMock).toHaveBeenCalledWith(500); expect(jsonMock).toHaveBeenCalledWith({ - message: 'Failed to get followers', + message: 'Failed to get following', }); + + // Restore console.error + consoleErrorSpy.mockRestore(); }); it('should return empty array when user has no followers', async () => { @@ -202,8 +208,10 @@ describe('Follow Controller', () => { }); it('should return 500 on database error', async () => { - const dbError = new Error('Database error'); - (prisma.userFollow.findMany as jest.Mock).mockRejectedValue(dbError); + const dbError = new Error('Database error'); + (prisma.userFollow.findMany as jest.Mock).mockRejectedValue(dbError); + + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); await getFollowing(mockReq as Request, mockRes as Response); @@ -211,6 +219,8 @@ describe('Follow Controller', () => { expect(jsonMock).toHaveBeenCalledWith({ message: 'Failed to get following', }); + + consoleErrorSpy.mockRestore(); }); it('should return empty array when user is not following anyone', async () => { diff --git a/backend/src/tests/unit/search.unit.test.ts b/backend/src/tests/unit/search.unit.test.ts index 7d40c95..c05c7ff 100644 --- a/backend/src/tests/unit/search.unit.test.ts +++ b/backend/src/tests/unit/search.unit.test.ts @@ -2,6 +2,9 @@ import { searchMovies, searchUsers, searchReviews, searchPosts } from "../../con import { Request, Response } from "express"; import { prisma } from "../../services/db"; +// Mock fetch globally before tests +global.fetch = jest.fn(); + describe("Search Controller Unit Tests", () => { let mockRequest: Partial; let mockResponse: Partial; @@ -19,6 +22,9 @@ describe("Search Controller Unit Tests", () => { mockResponse = responseObject; jest.clearAllMocks(); + + // Reset fetch mock before each test + (global.fetch as jest.Mock).mockReset(); }); afterEach(() => { @@ -91,10 +97,10 @@ describe("Search Controller Unit Tests", () => { expect.objectContaining({ type: "movies", query: "fight", - count: 3, // Changed to 3 + count: 3, sources: { - local: 3, // All 3 from local - tmdb: 0, // None from TMDB + local: 3, + tmdb: 0, }, }) ); @@ -113,8 +119,27 @@ describe("Search Controller Unit Tests", () => { languages: [], numRatings: "0", }, + { + movieId: "uuid-2", + title: "Test Movie 2", + description: "Test 2", + imdbRating: BigInt(80), + localRating: "0", + languages: [], + numRatings: "0", + }, + { + movieId: "uuid-3", + title: "Test Movie 3", + description: "Test 3", + imdbRating: BigInt(85), + localRating: "0", + languages: [], + numRatings: "0", + }, ]; + // Mock 3 movies to avoid TMDB fallback jest.spyOn(prisma.movie, "findMany").mockResolvedValueOnce(mockMovies as any); await searchMovies(mockRequest as Request, mockResponse as Response); @@ -286,38 +311,53 @@ describe("Search Controller Unit Tests", () => { }); it("should search posts successfully", async () => { - mockRequest.query = { q: "cinema" }; - - const mockPosts = [ - { + mockRequest.query = { q: "cinema" }; + + const mockPosts = [ + { + id: "post-uuid", + userId: "user-uuid", + movieId: "movie-uuid", + content: "I love cinema!", + type: "SHORT", + stars: null, + spoiler: false, + tags: [], + imageUrls: [], + repostedPostId: null, + createdAt: new Date(), + UserProfile: { + userId: "user-uuid", + username: "john_doe", + }, + movie: { + movieId: "movie-uuid", + title: "Test Movie", + imageUrl: "test.jpg", + }, + _count: { + Comment: 3, + }, + }, + ]; + + jest.spyOn(prisma.post, "findMany").mockResolvedValueOnce(mockPosts as any); + + await searchPosts(mockRequest as Request, mockResponse as Response); + + expect(responseObject.json).toHaveBeenCalledWith( + expect.objectContaining({ + type: "posts", + count: 1, + results: expect.arrayContaining([ + expect.objectContaining({ id: "post-uuid", - userId: "user-uuid", content: "I love cinema!", - type: "SHORT", - votes: 5, - createdAt: new Date(), - user: { - userId: "user-uuid", - username: "john_doe", - }, - _count: { - comments: 3, - }, - }, - ]; - - jest.spyOn(prisma.post, "findMany").mockResolvedValueOnce(mockPosts as any); - - await searchPosts(mockRequest as Request, mockResponse as Response); - - expect(responseObject.json).toHaveBeenCalledWith( - expect.objectContaining({ - type: "posts", - count: 1, - results: mockPosts, }) - ); - }); + ]), + }) + ); +}); it("should filter by post type", async () => { mockRequest.query = { q: "discussion", type: "LONG" }; diff --git a/frontend/app/movies/[movieId].tsx b/frontend/app/movies/[movieId].tsx index 9206c46..b15f2f1 100644 --- a/frontend/app/movies/[movieId].tsx +++ b/frontend/app/movies/[movieId].tsx @@ -1,30 +1,28 @@ -import { View, StyleSheet, TouchableOpacity, Text } from 'react-native'; -import { useLocalSearchParams, router } from 'expo-router'; +import { SafeAreaView, StyleSheet, TouchableOpacity } from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; import MovieChosenScreen from '../../screen/MovieChosenScreen'; export default function MovieDetailPage() { + const router = useRouter(); const { movieId } = useLocalSearchParams<{ movieId: string }>(); - + if (!movieId) { - return ( - - - No movie ID provided - router.back()} - style={styles.backButtonError} - > - Go Back - - - - ); + return null; } - + return ( - + + {/* Back button */} + router.back()} + style={styles.backButton} + > + + + - + ); } @@ -33,23 +31,8 @@ const styles = StyleSheet.create({ 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', + backButton: { + paddingHorizontal: 16, + paddingVertical: 12, }, -}); +}); \ No newline at end of file diff --git a/frontend/app/profilePage/user/[userId].tsx b/frontend/app/profilePage/user/[userId].tsx index 6b23e15..cf6a646 100644 --- a/frontend/app/profilePage/user/[userId].tsx +++ b/frontend/app/profilePage/user/[userId].tsx @@ -30,6 +30,7 @@ export default function OtherUserProfile() { 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( @@ -56,7 +57,8 @@ export default function OtherUserProfile() { } // If no user found by ID or not a valid UUID, try searching by username - const results = await searchUsers(String(query), 5); + const searchResponse = await searchUsers(String(query)); + const results = searchResponse.results || []; const normalized = String(query).toLowerCase(); const match = results.find((u) => (u.username || '').toLowerCase() === normalized || @@ -81,10 +83,10 @@ export default function OtherUserProfile() { profilePicture: match.profilePicture || null, country: null, city: null, - displayName: match.displayName || match.username || null, - favoriteGenres: [], + displayName: match.username || null, + favoriteGenres: match.favoriteGenres || [], favoriteMovies: [], - bio: match.bio || null, + bio: null, eventsSaved: [], eventsAttended: [], privateAccount: false, @@ -158,12 +160,11 @@ export default function OtherUserProfile() { console.error('Failed to determine current user:', err); } }; - fetchCurrentUser(); // ensures we know whether the visitor already follows this profile + fetchCurrentUser(); }, []); const displayUser: User = useMemo(() => { - const username = - usernameFromParams || 'user'; + const username = usernameFromParams || 'user'; const name = params.name || username; return { name, @@ -214,4 +215,4 @@ export default function OtherUserProfile() { profileData={profileData} /> ); -} +} \ No newline at end of file diff --git a/frontend/app/search/results.tsx b/frontend/app/search/results.tsx new file mode 100644 index 0000000..bea696b --- /dev/null +++ b/frontend/app/search/results.tsx @@ -0,0 +1,3 @@ +import SearchResultsScreen from '../../screen/SearchResultsScreen'; + +export default SearchResultsScreen; \ No newline at end of file diff --git a/frontend/components/MovieCard.tsx b/frontend/components/MovieCard.tsx new file mode 100644 index 0000000..09fce6b --- /dev/null +++ b/frontend/components/MovieCard.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Image } from 'react-native'; +import { router } from 'expo-router'; + +const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p/w500'; + +type MovieCardProps = { + movieId: string; + title: string; + imageUrl?: string | null; + badge?: 'New!' | 'Hot!'; + onPress?: () => void; +}; + +export default function MovieCard({ + movieId, + title, + imageUrl, + badge, + onPress, +}: MovieCardProps) { + const handlePress = () => { + if (onPress) { + onPress(); + } else { + router.push({ + pathname: '/movies/[movieId]', + params: { movieId }, + }); + } + }; + + const formatImageUrl = (url: string | null | undefined) => { + if (!url) { + return `https://via.placeholder.com/150x220/667eea/ffffff?text=${encodeURIComponent(title)}`; + } + + if (url.startsWith('http')) { + return url; + } + + return `${TMDB_IMAGE_BASE_URL}${url.startsWith('/') ? '' : '/'}${url}`; + }; + + return ( + + + {badge && ( + + {badge} + + )} + + ); +} + +const styles = StyleSheet.create({ + movieCard: { + width: 150, + height: 220, + borderRadius: 12, + backgroundColor: '#E0E0E0', + position: 'relative', + overflow: 'hidden', + marginRight: 12, + marginBottom: 12, + }, + movieImage: { + width: '100%', + height: '100%', + borderRadius: 12 + }, + badge: { + position: 'absolute', + top: 12, + left: 12, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + borderWidth: 2, + }, + badgeNew: { + backgroundColor: '#fff', + borderColor: '#E91E63' + }, + badgeHot: { + backgroundColor: '#fff', + borderColor: '#000' + }, + badgeText: { + fontSize: 14, + fontWeight: '600', + color: '#000' + }, +}); \ No newline at end of file diff --git a/frontend/components/ReactionButton.tsx b/frontend/components/ReactionButton.tsx index 25e4147..bce31ba 100644 --- a/frontend/components/ReactionButton.tsx +++ b/frontend/components/ReactionButton.tsx @@ -28,7 +28,7 @@ export default function ReactionButton({ - {count} + {String(count)} @@ -39,8 +39,8 @@ export default function ReactionButton({ {emoji} - {count} + {String(count)} ); -} +} \ No newline at end of file diff --git a/frontend/components/ReviewPost.tsx b/frontend/components/ReviewPost.tsx index 04435ad..0ea32cb 100644 --- a/frontend/components/ReviewPost.tsx +++ b/frontend/components/ReviewPost.tsx @@ -150,27 +150,18 @@ export default function ReviewPost({ const styles = StyleSheet.create({ container: { width: '100%', - borderRadius: width * 0.03, + borderRadius: width * 0.04, overflow: 'hidden', backgroundColor: '#000', - // Shadow for iOS - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 4, - }, - shadowOpacity: 0.3, - shadowRadius: 8, - // Elevation for Android - elevation: 8, + marginBottom: width * 0.04, // Space between posts }, imageBackground: { width: '100%', - minHeight: width * 0.8, + aspectRatio: 16/9, // ← Changed to wider aspect ratio like the image justifyContent: 'flex-end', }, image: { - borderRadius: width * 0.03, + borderRadius: width * 0.04, }, fallbackBackground: { backgroundColor: '#1a1a1a', @@ -208,19 +199,17 @@ const styles = StyleSheet.create({ bottom: 0, left: 0, right: 0, - height: width * 0.6, + height: '100%', // ← Cover entire image with gradient }, contentContainer: { padding: width * 0.04, - paddingBottom: width * 0.05, }, movieTitle: { - fontSize: width * 0.055, + fontSize: width * 0.045, // ← Slightly smaller fontWeight: '700', color: '#FFFFFF', - marginTop: width * 0.03, - marginBottom: width * 0.02, - flexShrink: 1, + marginTop: width * 0.02, + marginBottom: width * 0.015, textShadowColor: 'rgba(0, 0, 0, 0.75)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 3, @@ -229,4 +218,13 @@ const styles = StyleSheet.create({ marginTop: width * 0.01, alignItems: 'flex-start', }, -}); + reviewText: { + fontSize: width * 0.035, + color: '#FFFFFF', + marginTop: width * 0.025, + lineHeight: width * 0.05, + textShadowColor: 'rgba(0, 0, 0, 0.75)', + textShadowOffset: { width: 0, height: 1 }, + textShadowRadius: 3, + }, +}); \ No newline at end of file diff --git a/frontend/components/SearchBar.tsx b/frontend/components/SearchBar.tsx index 9de0961..99457cf 100644 --- a/frontend/components/SearchBar.tsx +++ b/frontend/components/SearchBar.tsx @@ -10,6 +10,7 @@ type SearchBarProps = { onPress?: () => void; editable?: boolean; onSubmitEditing?: () => void; + onSearchPress?: () => void; }; export default function SearchBar({ diff --git a/frontend/components/UserCard.tsx b/frontend/components/UserCard.tsx new file mode 100644 index 0000000..884813f --- /dev/null +++ b/frontend/components/UserCard.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Dimensions, Image } from 'react-native'; +import { router } from 'expo-router'; + +const { width } = Dimensions.get('window'); + +type UserCardProps = { + userId: string; + username: string; + avatarUri?: string; + favoriteGenres?: string[]; + isFollowing?: boolean; + onFollowPress?: () => void; +}; + +export default function UserCard({ + userId, + username, + avatarUri, + favoriteGenres = [], + isFollowing = false, + onFollowPress, +}: UserCardProps) { + const handleUserPress = () => { + router.push({ + pathname: '/profilePage/user/[userId]', + params: { userId }, + }); + }; + + return ( + + + + + @{username} + {favoriteGenres.length > 0 && ( + + {favoriteGenres.slice(0, 3).join(', ')} + + )} + + + { + e.stopPropagation(); + onFollowPress?.(); + }} + activeOpacity={0.7} + > + + {isFollowing ? 'Following' : 'Follow'} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFF', + padding: width * 0.04, + borderBottomWidth: 1, + borderBottomColor: '#F0F0F0', + }, + avatar: { + width: width * 0.12, + height: width * 0.12, + borderRadius: width * 0.06, + marginRight: width * 0.03, + }, + infoContainer: { + flex: 1, + }, + username: { + fontSize: width * 0.04, + fontWeight: '600', + color: '#000', + marginBottom: width * 0.01, + }, + genres: { + fontSize: width * 0.035, + color: '#666', + }, + followButton: { + backgroundColor: '#D62E05', + paddingHorizontal: width * 0.05, + paddingVertical: width * 0.02, + borderRadius: width * 0.015, + }, + followingButton: { + backgroundColor: '#FFE5E0', + }, + followButtonText: { + color: '#FFF', + fontSize: width * 0.035, + fontWeight: '600', + }, + followingButtonText: { + color: '#D62E05', + }, +}); \ No newline at end of file diff --git a/frontend/screen/MovieChosenScreen.tsx b/frontend/screen/MovieChosenScreen.tsx index 0841aa0..6e08b3c 100644 --- a/frontend/screen/MovieChosenScreen.tsx +++ b/frontend/screen/MovieChosenScreen.tsx @@ -280,9 +280,15 @@ export default function MovieChosenScreen({ movieId }: MovieChosenScreenProps) { }; const formatCount = (count: number): string => { - if (count >= 1000000) return `${(count / 1000000).toFixed(2)}M`; - if (count >= 1000) return `${(count / 1000).toFixed(2)}k`; - return count.toString(); + + if (count >= 1000000) { + return `${(count / 1000000).toFixed(2)}M`; + } + if (count >= 1000) { + return `${(count / 1000).toFixed(2)}k`; + } + return String(count); + }; const handleComment = (post: Post) => { @@ -614,7 +620,7 @@ export default function MovieChosenScreen({ movieId }: MovieChosenScreenProps) { {(releaseYear || director) && ( - {releaseYear && releaseYear} + {releaseYear ? `${releaseYear}` : ''} {releaseYear && director && ' • '} {director && `Directed by: ${director}`} diff --git a/frontend/screen/SearchResultsScreen.tsx b/frontend/screen/SearchResultsScreen.tsx new file mode 100644 index 0000000..0d44709 --- /dev/null +++ b/frontend/screen/SearchResultsScreen.tsx @@ -0,0 +1,399 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + SafeAreaView, + ScrollView, + ActivityIndicator, + Dimensions, + TouchableOpacity, + FlatList, +} from 'react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import SearchBar from '../components/SearchBar'; +import SearchToggle from '../components/SearchToggle'; +import MovieCard from '../components/MovieCard'; +import UserCard from '../components/UserCard'; +import TextPost from '../components/TextPost'; +import PicturePost from '../components/PicturePost'; +import ReviewPost from '../components/ReviewPost'; +import EventCard from '../app/events/components/EventCard'; +import { + searchMovies, + searchUsers, + searchPosts, + searchReviews, + searchEvents, + type MovieSearchResponse, + type UserSearchResponse, + type PostSearchResponse, + // type ReviewSearchResponse, +} from '../services/searchService'; +import type { components } from '../types/api-generated'; + +type Movie = components["schemas"]["Movie"]; +type Post = components["schemas"]["Post"]; +type Rating = components["schemas"]["Rating"]; + +const { width, height } = Dimensions.get('window'); + +type SearchCategory = 'movies' | 'posts' | 'events' | 'users'; + +export default function SearchResultsScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ + query: string; + category: SearchCategory; + origin?: string; + }>(); + + const [searchQuery, setSearchQuery] = useState(params.query || ''); + const [selectedCategory, setSelectedCategory] = useState( + (params.category as SearchCategory) || 'movies' + ); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + + const searchCategories = [ + { value: 'movies' as SearchCategory, label: 'Movies' }, + { value: 'posts' as SearchCategory, label: 'Posts' }, + { value: 'events' as SearchCategory, label: 'Events' }, + { value: 'users' as SearchCategory, label: 'Users' }, + ]; + + useEffect(() => { + if (searchQuery) { + performSearch(searchQuery, selectedCategory); + } + }, [selectedCategory]); + + const performSearch = async (query: string, category: SearchCategory) => { + if (!query.trim()) return; + + try { + setLoading(true); + setError(null); + + let response; + switch (category) { + case 'movies': + console.log('Calling movies API...'); + response = await searchMovies(query) as MovieSearchResponse; + console.log('Movies response:', JSON.stringify(response, null, 2)); + console.log('First movie imageUrl:', response.results?.[0]?.imageUrl); + setResults(response.results || []); + break; + case 'users': + response = await searchUsers(query) as UserSearchResponse; + setResults(response.results || []); + break; + case 'posts': + response = await searchPosts(query) as PostSearchResponse; + setResults(response.results || []); + break; + case 'events': + response = await searchEvents(query); + setResults(response.results || []); + break; + default: + setResults([]); + } + } catch (err) { + console.error('Search error:', err); + setError('Failed to search. Please try again.'); + setResults([]); + } finally { + setLoading(false); + } + }; + + const handleBack = () => { + router.back(); + }; + + const handleNewSearch = (newQuery: string) => { + setSearchQuery(newQuery); + performSearch(newQuery, selectedCategory); + }; + + const renderMovieResults = () => ( + + {results.map((movie: Movie, index) => ( + router.push({ + pathname: '/movies/[movieId]', + params: { movieId: movie.movieId }, + })} + /> + ))} + +); + + const renderUserResults = () => ( + + {results.map((user: any, index: number) => ( + console.log('Follow user:', user.userId)} + /> + ))} + +); + + const renderPostResults = () => ( + + {results.map((post: Post) => { + const username = post.UserProfile?.username || 'Unknown'; + const userId = post.userId; + const hasImages = post.imageUrls && post.imageUrls.length > 0; + const isLongPost = post.type === 'LONG'; + const isShortPost = post.type === 'SHORT'; + const hasStars = post.stars !== null && post.stars !== undefined; + + // DEBUG: Log post details + console.log('Post debug:', { + id: post.id, + type: post.type, + isLongPost, + isShortPost, + hasStars, + stars: post.stars, + hasImages, + imageUrls: post.imageUrls, + }); + + // LONG post with stars = ReviewPost + if (isLongPost && hasStars) { + console.log('✅ Rendering ReviewPost for:', post.id); + return ( + + ); + } + + // SHORT post with images = PicturePost + if (isShortPost && hasImages) { + console.log('✅ Rendering PicturePost for:', post.id); + return ( + + ); + } + + // SHORT post without images = TextPost + if (isShortPost && !hasImages) { + console.log('✅ Rendering TextPost for:', post.id); + return ( + + ); + } + + // Fallback + console.warn('❌ Post did not match any type:', post.type, post); + return null; + })} + +); + + const renderEventResults = () => ( + + {results.map((event: any, index: number) => ( + router.push(`/events/eventDetail?eventId=${event.id}`)} + /> + ))} + + ); + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 1) return 'Today'; + if (diffDays < 2) return '1d'; + if (diffDays < 7) return `${diffDays}d`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const renderResults = () => { + switch (selectedCategory) { + case 'movies': + return renderMovieResults(); + case 'users': + return renderUserResults(); + case 'posts': + return renderPostResults(); + case 'events': + return renderEventResults(); + default: + return null; + } + }; + + return ( + + {/* SearchBar */} + { + console.log('🔍 Search icon clicked!'); + performSearch(searchQuery, selectedCategory); + }} +/> + + {/* Category Toggle */} + + + + + {/* Results */} + {loading ? ( + + + Searching... + + ) : error ? ( + + {error} + + ) : results.length === 0 ? ( + + + + No results found for "{searchQuery}" + + + ) : ( + + + {results.length} result{results.length !== 1 ? 's' : ''} + + {renderResults()} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#FFF', + }, + backButtonContainer: { + paddingHorizontal: width * 0.04, + }, + backButton: { + padding: width * 0.01, + alignSelf: 'flex-start', + }, + toggleContainer: { + paddingHorizontal: width * 0.04, + marginBottom: height * 0.02, + }, + resultsContainer: { + flex: 1, + }, + centerContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + loadingText: { + marginTop: height * 0.02, + fontSize: width * 0.04, + color: '#999', + }, + errorText: { + fontSize: width * 0.04, + color: '#FF0000', + textAlign: 'center', + paddingHorizontal: width * 0.08, + }, + emptyText: { + marginTop: height * 0.02, + fontSize: width * 0.04, + color: '#999', + textAlign: 'center', + paddingHorizontal: width * 0.08, + }, + resultCount: { + fontSize: width * 0.04, + fontWeight: '600', + color: '#666', + paddingHorizontal: width * 0.04, + paddingVertical: height * 0.015, + borderBottomWidth: 1, + borderBottomColor: '#EEEEEE', + marginBottom: height * 0.01, + }, + moviesScroll: { + paddingHorizontal: 16, + paddingTop: 16, + }, + postsList: { + paddingHorizontal: width * 0.04, + }, + reviewsList: { + paddingHorizontal: width * 0.04, + }, + eventsScroll: { + paddingHorizontal: width * 0.04, + }, +}); \ No newline at end of file diff --git a/frontend/screen/SearchScreen.tsx b/frontend/screen/SearchScreen.tsx index 8e35939..c4e08d8 100644 --- a/frontend/screen/SearchScreen.tsx +++ b/frontend/screen/SearchScreen.tsx @@ -53,49 +53,19 @@ export default function SearchScreen() { }, [selectedCategory, searchQuery]); const handleSearch = async () => { - const query = searchQuery.trim(); - if (!query) return; - - if (selectedCategory === 'users') { - try { - setStatusMessage(''); - const results = await searchUsers(query, 5); - const normalized = query.toLowerCase(); - const match = - results.find((u) => (u.username || '').toLowerCase() === normalized) || - results[0]; - - if (!match?.userId) { - setStatusMessage('No user found for that username.'); - return; - } - - router.push({ - pathname: '/profilePage/user/[userId]', - params: { - userId: match.userId, - username: match.username || query, - name: match.name || match.username || query, - profilePic: match.profilePicture || '', - origin: params.origin, - }, - }); - } catch (err: any) { - console.error('Failed to search users', err); - setStatusMessage(err?.message || 'Failed to search users.'); - } - return; - } - - router.push({ - pathname: '/search/results', - params: { - query, - category: selectedCategory, - origin: params.origin, - }, - }); - }; + const query = searchQuery.trim(); + if (!query) return; + + // Navigate to results page for ALL categories + router.push({ + pathname: '/search/results', + params: { + query, + category: selectedCategory, + origin: params.origin, + }, + }); +}; return ( diff --git a/frontend/services/searchService.ts b/frontend/services/searchService.ts index eaa7948..98eef89 100644 --- a/frontend/services/searchService.ts +++ b/frontend/services/searchService.ts @@ -1,5 +1,10 @@ import { api } from './apiClient'; +import type { components } from '../types/api-generated'; +type Movie = components["schemas"]["Movie"]; +type Post = components["schemas"]["Post"]; + +// Use the detailed SearchUser type from main type SearchUser = { userId?: string; username?: string; @@ -24,17 +29,43 @@ type SearchUser = { eventsAttended?: string[]; }; -type SearchUsersResponse = { - data?: SearchUser[]; - results?: SearchUser[]; - message?: string; +export type MovieSearchResponse = { + type: 'movies'; + query: string; + count: number; + results: Movie[]; +}; + +export type UserSearchResponse = { + type: 'users'; + query: string; + count: number; + results: SearchUser[]; }; -export async function searchUsers(query: string, limit: number = 10): Promise { - const res = await api.get('/api/search/users', { - q: query, - limit: String(limit), - }); - // Backend returns `{ results: [...] }` (and sometimes `{ data: [...] }`); normalize here. - return res.data ?? res.results ?? []; +export type PostSearchResponse = { + type: 'posts'; + query: string; + count: number; + results: Post[]; +}; + +export async function searchMovies(query: string): Promise { + return api.get('/api/search/movies', { q: query }); +} + +export async function searchUsers(query: string): Promise { + return api.get('/api/search/users', { q: query, limit: '10' }); +} + +export async function searchPosts(query: string): Promise { + return api.get('/api/search/posts', { q: query }); } + +export async function searchReviews(query: string): Promise { + return api.get('/api/search/reviews', { q: query }); +} + +export async function searchEvents(query: string): Promise { + return api.get('/api/search/events', { q: query }); +} \ No newline at end of file