diff --git a/apis/showApi.ts b/apis/showApi.ts index 6e802e6..c8c94bc 100644 --- a/apis/showApi.ts +++ b/apis/showApi.ts @@ -65,3 +65,36 @@ export async function getShows(): Promise { throw error; } } + +export async function getShowById(showId: number): Promise { + try { + const apiKey = process.env.EXPO_PUBLIC_API_KEY; + const response = await fetch(`${SHOW_API_URL}/${showId}`, { + headers: { + "Content-Type": "application/json", + "X-API-Key": apiKey ?? "", + }, + }); + if (!response.ok) { + console.error("Fetch error:", response); + return null; + } + const s: RawShow = await response.json(); + return { + id: s.showId, + title: s.title, + description: s.description, + genres: s.genre ? s.genre.split(",").map((g) => g.trim()) : [], + thumbnailUri: s.thumbnailUrl, + bannerUri: s.bannerUrl ?? "", + logoUri: s.logoUrl ?? "", + streamingService: s.streamingServices, + concept: s.concept, + running: s.running, + + }; + } catch (error) { + console.error("Fetch error:", error); + return null; + } +} diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 6989eac..d4647c4 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,71 +1,125 @@ import { AutoCompleteItem } from "@/apis/autoCompleteApi"; import { getSearchResults, SearchResultItem } from "@/apis/searchApi"; import styles from "@/app/tabStyles/indexStyles"; +import { Season, Show } from "@/app/types"; +import ShowBox from "@/components/discovery/ShowBox"; import { useDiscoveryContext } from "@/contexts/DiscoveryContext"; import { FontAwesome } from "@expo/vector-icons"; import Feather from "@expo/vector-icons/Feather"; import React from "react"; -import { - Keyboard, - ScrollView, - Text, - TextInput, - TouchableOpacity, - View -} from "react-native"; +import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { getShowById } from "@/apis/showApi"; +import PersonRow from "@/components/discovery/PersonRow"; +import SeasonCarousel from "@/components/discovery/SeasonCarousel"; +import TagChip from "@/components/discovery/TagChip"; +import { getIconName, mapApiPersonToUI, mapApiSeasonToUI, mapApiShowToUI } from "@/utils/searchMapping"; -export default function TabTwoScreen() { +export default function ExploreScreen() { const { query, setQuery, suggestions } = useDiscoveryContext(); const [tags, setTags] = React.useState([]); + const [results, setResults] = React.useState([]); - const [searchResults, setSearchResults] = React.useState([]); + // Show metadata cache by id (filled from SHOW results and lazy-loaded by id) + const [showsById, setShowsById] = React.useState>({}); - const getIconName = (type: AutoCompleteItem["type"]) => { - switch (type) { - case "PERSON": - return "user"; - case "SHOW": - return "television"; - case "YEAR": - return "calendar"; - default: - return "tag"; - } - }; + // --- helpers --- + const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]); function tagAdded(tag: AutoCompleteItem) { - console.log("Tag added:", tag); - const nextTags = tags.some((t) => t.text === tag.text) - ? tags - : [...tags, tag]; - + const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag]; setTags(nextTags); - const tagStrings = nextTags.map((t) => t.text); - - getSearchResults(tagStrings, 20) - .then(setSearchResults) + const inputs = nextTags.map((t) => t.text); + getSearchResults(inputs, 50) + .then((items) => { + setResults(items || []); + }) .catch(console.error); setQuery(""); Keyboard.dismiss(); - - console.log("Searching with tags:", tagStrings); } + // Keep our local show cache in sync with SHOW items returned by search + React.useEffect(() => { + const fromResults: Record = {}; + for (const r of results) { + if (r.type === "SHOW") { + const uiShow = mapApiShowToUI(r.data); + if (uiShow.showId != null) fromResults[Number(uiShow.showId)] = uiShow as Show; + } + } + if (Object.keys(fromResults).length) { + setShowsById((prev) => ({ ...prev, ...fromResults })); + } + }, [results]); + // Group SEASON results by showId + const seasonsByShowId = React.useMemo(() => { + const map = new Map(); + for (const r of results) { + if (r.type !== "SEASON") continue; + const s = mapApiSeasonToUI(r.data); + if (!s || s.showId == null) continue; + const key = Number(s.showId); + const list = map.get(key) ?? []; + list.push(s as Season); + map.set(key, list); + } + // sort seasons per show by startDate asc + for (const [k, list] of map) { + list.sort((a, b) => { + const da = a?.startDate ? new Date(a.startDate).getTime() : Number.POSITIVE_INFINITY; + const db = b?.startDate ? new Date(b.startDate).getTime() : Number.POSITIVE_INFINITY; + return da - db; + }); + map.set(k, list); + } + return map; + }, [results]); + + // Lazy fetch missing shows needed for Season carousels + React.useEffect(() => { + const needed = Array.from(seasonsByShowId.keys()).filter((id) => !showsById[id]); + if (needed.length === 0) return; + let cancelled = false; + (async () => { + try { + const fetched = await Promise.all(needed.map((id) => getShowById(id))); + if (cancelled) return; + const next: Record = {}; + for (const s of fetched) { + if (!s) continue; + next[Number((s as any).showId)] = s as Show; + } + if (Object.keys(next).length) setShowsById((prev) => ({ ...prev, ...next })); + } catch (e) { + console.error(e); + } + })(); + return () => { + cancelled = true; + }; + }, [seasonsByShowId, showsById]); + + // PERSON hits shown at the top (like old screen) + const persons = React.useMemo(() => { + return results + .filter((r) => r.type === "PERSON") + .map((r) => mapApiPersonToUI(r.data)) + .sort((a, b) => (a.name || "").localeCompare(b.name || "")); + }, [results]); return ( - + Durchsuchen - - + {/* Search bar */} console.log("Search:", query)} + onSubmitEditing={() => { + if (!query.trim()) return; + tagAdded({ type: "CUSTOM", text: query.trim() }); + }} autoCapitalize="none" /> {query.length === 0 ? ( ) : ( - setQuery("")} - /> + setQuery("")} /> )} + {/* Tag chips */} {tags.map((tag) => ( - - setTags((prev) => prev.filter((t) => t.text !== tag.text)) - } - > - - - {tag.text} - - - + icon={getIconName(tag.type)} + label={tag.text} + onPress={() => setTags((prev) => prev.filter((t) => t.text !== tag.text))} + /> ))} + {/* Suggestions dropdown */} {query.length > 0 && ( Suchvorschläge @@ -131,65 +169,65 @@ export default function TabTwoScreen() { { - tagAdded(suggestion); - }} + onPress={() => tagAdded(suggestion)} > - + {suggestion.text} ))} - - - )} - - + {/* Results */} + - {searchResults.map((result: SearchResultItem, idx) => { - switch (result.type) { - case "PERSON": - return ( - - - - + {/* Personen Section (top) */} + {persons.length > 0 && ( + + Personen + {persons.slice(0, 5).map((p) => ( + + ))} + + )} - {/* Text */} - - {result.data.name || "Unbekannt"} ({"25"}) - - aus: {"unterscheidlichen Shows"} - - + {/* Staffeln grouped by Show with page view */} + + Staffeln - {/* Chevron */} - - - ); - case "SHOW": + {Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => { + const show = showsById[Number(showId)]; + if (!seasons || seasons.length === 0) return null; + // If show metadata is not yet loaded, render a minimal ShowBox fallback once per page item + if (!show) { return ( - - - {result.data.title} (Show) - - + } + /> ); - default: - return null; - } - })} + } + return ( + } + /> + ); + })} + + {seasonsByShowId.size === 0 && ( + + Keine Staffeln gefunden. Passe deine Tags an. + + )} + - - ); diff --git a/app/types.ts b/app/types.ts new file mode 100644 index 0000000..f52b232 --- /dev/null +++ b/app/types.ts @@ -0,0 +1,32 @@ +export type Person = { +personId: number; +name: string; +birthDate?: string | null; +imageUrl?: string | null; +}; + + + +export type Season = { +seasonId: number; +showId: number; +startDate?: string | null; +endDate?: string | null; +seasonNumber?: number | null; +participants?: Person[]; +moderators?: Person[]; +}; + + +export type Show = { +showId: number; +title: string; +description?: string; +genre?: string; +thumbnailUrl: string; +logoUrl?: string; +bannerUrl?: string; +running?: boolean; +streamingServices?: string; +concept?: string +}; \ No newline at end of file diff --git a/components/discovery/GenreTag.tsx b/components/discovery/GenreTag.tsx new file mode 100644 index 0000000..6915f75 --- /dev/null +++ b/components/discovery/GenreTag.tsx @@ -0,0 +1,19 @@ +import { StyleSheet, Text, TextProps } from 'react-native'; + +export default function GenreTag(props: TextProps) { + return ; +} + +const styles = StyleSheet.create({ + genreTag: { + fontFamily: 'SpaceMono', + fontSize: 12, + paddingVertical: 4, + paddingHorizontal: 8, + borderRadius: 12, + backgroundColor: '#333747', + color: '#fff', + textAlign: 'center', + overflow: 'hidden', + }, +}); \ No newline at end of file diff --git a/components/discovery/PersonRow.tsx b/components/discovery/PersonRow.tsx new file mode 100644 index 0000000..c3919da --- /dev/null +++ b/components/discovery/PersonRow.tsx @@ -0,0 +1,49 @@ +import { FontAwesome } from "@expo/vector-icons"; +import { useNavigation } from "@react-navigation/native"; +import React from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +export type PersonLite = { id?: number; personId?: number; name?: string; birthDate?: string | null; imageUrl?: string | null }; + +const calcAge = (birthDate?: string | null): number | null => { + if (!birthDate) return null; + const d = new Date(birthDate); + if (isNaN(d.getTime())) return null; + const today = new Date(); + let age = today.getFullYear() - d.getFullYear(); + const m = today.getMonth() - d.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--; + return age < 0 || age > 130 ? null : age; +}; + +export default function PersonRow({ person }: { person: PersonLite }) { + const navigation = useNavigation(); + const age = calcAge(person.birthDate); + const id = person.personId ?? person.id; + + return ( + { + // If your PersonDetail expects a Person object instead of an id, adapt this accordingly + // navigation.navigate("PersonDetail" as never, { personId: id } as never); + }} + style={styles.personRow} + > + + + + + {person.name || "Unbekannt"}{age != null ? ` (${age})` : ""} + aus: unterschiedlichen Shows + + + + ); +} + +const styles = StyleSheet.create({ + personRow: { width: "100%", flexDirection: "row", alignItems: "center", backgroundColor: "#1b1e2b", borderRadius: 10, paddingHorizontal: 10, paddingVertical: 10, marginBottom: 8 }, + avatarCircle: { width: 40, height: 40, borderRadius: 999, backgroundColor: "#2a2f45", alignItems: "center", justifyContent: "center", marginRight: 10 }, + personName: { color: "white", fontSize: 16, fontWeight: "600" }, + personMeta: { color: "#bbb", fontSize: 12, marginTop: 2 }, +}); diff --git a/components/discovery/SeasonCarousel.tsx b/components/discovery/SeasonCarousel.tsx new file mode 100644 index 0000000..fe48530 --- /dev/null +++ b/components/discovery/SeasonCarousel.tsx @@ -0,0 +1,94 @@ +import { Season, Show } from "@/app/types"; +import { FontAwesome } from "@expo/vector-icons"; +import React from "react"; +import { Dimensions, FlatList, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, Pressable, StyleSheet, View } from "react-native"; + +const WINDOW_WIDTH = Dimensions.get("window").width; + +function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); } + +export default function SeasonCarousel({ + show, + seasons, + renderItem, +}: { + show: Show; + seasons: Season[]; + renderItem: (season: Season) => React.ReactNode; +}) { + const [currentIndex, setCurrentIndex] = React.useState(0); + const [sliderWidth, setSliderWidth] = React.useState(Math.floor(WINDOW_WIDTH - 20)); + const listRef = React.useRef | null>(null); + + const onLayout = (e: LayoutChangeEvent) => { + const w = Math.max(0, Math.floor(e.nativeEvent.layout.width)); + if (w) setSliderWidth(w); + }; + + const onMomentumEnd = (e: NativeSyntheticEvent) => { + const x = e.nativeEvent.contentOffset.x; + const index = clamp(Math.round(x / sliderWidth), 0, Math.max(0, seasons.length - 1)); + setCurrentIndex(index); + }; + + const scrollTo = (target: number) => { + const ref = listRef.current; + if (!ref) return; + try { ref.scrollToIndex({ index: target, animated: true }); } catch {} + }; + + const goPrev = () => { + setCurrentIndex((curr) => { const next = clamp(curr - 1, 0, Math.max(0, seasons.length - 1)); if (next !== curr) setTimeout(() => scrollTo(next), 0); return next; }); + }; + const goNext = () => { + setCurrentIndex((curr) => { const next = clamp(curr + 1, 0, Math.max(0, seasons.length - 1)); if (next !== curr) setTimeout(() => scrollTo(next), 0); return next; }); + }; + + return ( + + (listRef.current = r)} + data={seasons} + keyExtractor={(season, idx) => `${show.showId}-${(season as any)?.seasonId ?? `season-${idx}`}`} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + snapToAlignment="start" + decelerationRate="fast" + onMomentumScrollEnd={onMomentumEnd} + renderItem={({ item }) => ( + + {renderItem(item)} + + )} + /> + + {seasons.length > 1 && ( + + + + + + + {seasons.map((_, i) => ( + + ))} + + + = seasons.length - 1 && carouselStyles.arrowDisabled]} disabled={currentIndex >= seasons.length - 1} hitSlop={8}> + + + + )} + + ); +} + +const carouselStyles = StyleSheet.create({ + controls: { paddingHorizontal: 8, width: "100%", flexDirection: "row", alignItems: "center", justifyContent: "space-between" }, + dotsRow: { flexDirection: "row", alignItems: "center" }, + dot: { width: 6, height: 6, borderRadius: 999, backgroundColor: "#888", opacity: 0.4, marginHorizontal: 3 }, + dotActive: { opacity: 1 }, + arrowButton: { padding: 6, opacity: 0.9 }, + arrowDisabled: { opacity: 0.3 }, +}); diff --git a/components/discovery/ShowBox.tsx b/components/discovery/ShowBox.tsx new file mode 100644 index 0000000..31cd1a3 --- /dev/null +++ b/components/discovery/ShowBox.tsx @@ -0,0 +1,123 @@ +import { Season, Show } from "@/app/types"; +import GenreTag from "@/components/discovery/GenreTag"; +import { useNavigation } from "@react-navigation/native"; +import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +export default function ShowBox({ + show, + displayedSeason, + shadow = true, +}: { + show: Show; + displayedSeason?: Season; + shadow?: boolean; +}) { + const navigation = useNavigation(); + + return ( + navigation.navigate("ShowDetail", { show })} + style={ + !shadow + ? [styles.showContainer, { backgroundColor: "#1b1e2b", paddingBottom: 0 }] + : [styles.showContainer, styles.shadow, { backgroundColor: "#1b1e2b" }] + } + > + + + {show.running && LIVE} + + + + {show.title} + + {displayedSeason ? ( + + Staffel {displayedSeason.seasonNumber} ( + {new Date(displayedSeason.startDate).getFullYear()}) + + ) : null} + + + {show.description} + + + + {show.genre.split(", ").map((genre: any) => ( + {genre} + ))} + + + + ); +} + +const styles = StyleSheet.create({ + showTitle: { + fontSize: 18, + fontWeight: "bold", + textAlign: "left", + color: "#ffffff", + }, + showDescription: { + marginTop: 5, + fontSize: 12, + textAlign: "left", + flex: 1, + color: "#cccccc", + }, + showContainer: { + width: "100%", + height: 220, + alignItems: "center", + padding: 10, + borderRadius: 10, + flexDirection: "row", + justifyContent: "flex-start", + backgroundColor: "#1b1e2b", + }, + shadow: { + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, + }, + showImageContainer: { + width: 140, + height: "100%", + backgroundColor: "#2b2e3b", + borderRadius: 10, + }, + showImage: { + width: "100%", + height: "100%", + borderRadius: 10, + }, + showRight: { + flex: 1, + height: "100%", + flexDirection: "column", + backgroundColor: "#1b1e2b", + paddingLeft: 10, + paddingVertical: 2, + }, + showGenreTagContainer: { + flexDirection: "row", + justifyContent: "flex-start", + flexWrap: "wrap", + gap: 5, + marginTop: 2, + backgroundColor: "#1b1e2b", + }, + runningTag: { + position: "absolute", + top: 3, + left: 3, + backgroundColor: "red", + opacity: 0.65, + color: "white", + padding: 5, + borderRadius: 90, + }, +}); diff --git a/components/discovery/TagChip.tsx b/components/discovery/TagChip.tsx new file mode 100644 index 0000000..a851358 --- /dev/null +++ b/components/discovery/TagChip.tsx @@ -0,0 +1,20 @@ +import { FontAwesome } from "@expo/vector-icons"; +import React from "react"; +import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; + +export default function TagChip({ icon, label, onPress }: { icon: any; label: string; onPress: () => void }) { + return ( + + + + {label} + + + + ); +} + +const styles = StyleSheet.create({ + tag: { flexDirection: "row", alignItems: "center", backgroundColor: "#333", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, marginRight: 8, marginBottom: 8 }, + tagLabel: { color: "white" }, +}); diff --git a/utils/searchMapping.ts b/utils/searchMapping.ts new file mode 100644 index 0000000..f2f7ee3 --- /dev/null +++ b/utils/searchMapping.ts @@ -0,0 +1,50 @@ +import { AutoCompleteItem } from "@/apis/autoCompleteApi"; + +export const getIconName = (type: AutoCompleteItem["type"]) => { + switch (type) { + case "PERSON": + return "user"; + case "SHOW": + return "television"; + case "YEAR": + case "CUSTOM": + return "calendar"; + default: + return "tag"; + } +}; + +// Helpers that adapt backend searchApi payloads (as in the prompt) to UI types used in our components +export function mapApiPersonToUI(data: any) { + return { + id: data?.id ?? data?.personId, + personId: data?.personId ?? data?.id, + name: data?.name ?? "", + birthDate: data?.birthDate ?? null, + imageUrl: data?.imageUrl ?? null, + }; +} + +export function mapApiSeasonToUI(data: any) { + return { + seasonId: data?.seasonId ?? data?.id, + showId: data?.showId, + startDate: data?.startDate ?? null, + endDate: data?.endDate ?? null, + seasonNumber: data?.seasonNumber ?? null, + participants: data?.seasonParticipants ?? data?.participants ?? [], + moderators: data?.moderators ?? [], + teams: data?.teams ?? [], + } as any; +} + +export function mapApiShowToUI(data: any) { + return { + showId: data?.showId ?? data?.id, + title: data?.title ?? data?.name ?? `Show #${data?.showId ?? data?.id ?? "?"}`, + description: data?.description ?? "", + genre: data?.genre ?? "", + thumbnailUrl: data?.thumbnailUrl ?? data?.imageUrl ?? "", + running: data?.running ?? false, + } as any; +}