From 9725d8bff1b1c23eceb5f4b3347ea57a9de8bd1e Mon Sep 17 00:00:00 2001 From: Yordan Simeonov <118773725+Cron1cle@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:50:21 +1100 Subject: [PATCH] update: gemini fixes --- app/(tabs)/explore.tsx | 41 +++------ app/(tabs)/index.tsx | 121 ++++++++++++++----------- app/_layout.tsx | 48 +++++----- app/participant.tsx | 126 +------------------------- app/showDetails.tsx | 105 +++++---------------- app/stackStyles/participantStyles.tsx | 37 ++++---- app/stackStyles/showDetailStyles.tsx | 33 +++---- app/tabStyles/indexStyles.tsx | 101 ++++++++++++--------- constants/colors.ts | 11 +++ contexts/DiscoveryContext.tsx | 56 +----------- contexts/SeasonContext.tsx | 103 --------------------- contexts/ShowContext.tsx | 42 --------- contexts/StreamingServiceContext.tsx | 59 ------------ hooks/useAutoComplete.ts | 13 +++ hooks/useDebounce.ts | 17 ++++ hooks/usePersonHistory.ts | 87 ++++++++++++++++++ hooks/useSearch.ts | 10 ++ hooks/useSeason.ts | 50 ++++++++++ hooks/useShow.ts | 10 ++ hooks/useShows.ts | 9 ++ hooks/useStreamingServices.ts | 13 +++ package-lock.json | 27 ++++++ package.json | 1 + 23 files changed, 473 insertions(+), 647 deletions(-) delete mode 100644 contexts/SeasonContext.tsx delete mode 100644 contexts/ShowContext.tsx delete mode 100644 contexts/StreamingServiceContext.tsx create mode 100644 hooks/useAutoComplete.ts create mode 100644 hooks/useDebounce.ts create mode 100644 hooks/usePersonHistory.ts create mode 100644 hooks/useSearch.ts create mode 100644 hooks/useSeason.ts create mode 100644 hooks/useShow.ts create mode 100644 hooks/useShows.ts create mode 100644 hooks/useStreamingServices.ts diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index e4c0abd..5b60f5a 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -1,5 +1,5 @@ import { AutoCompleteItem } from "@/apis/autoCompleteApi"; -import { getSearchResults, SearchResultItem } from "@/apis/searchApi"; +import { SearchResultItem } from "@/apis/searchApi"; import { Season } from "@/apis/seasonApi"; import { Show } from "@/apis/showApi"; import styles from "@/app/tabStyles/indexStyles"; @@ -10,6 +10,7 @@ import Feather from "@expo/vector-icons/Feather"; import React from "react"; import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native"; +import { useSearch } from "@/hooks/useSearch"; import { getShowById } from "@/apis/showApi"; import PersonRow from "@/components/discovery/PersonRow"; import SeasonCarousel from "@/components/discovery/SeasonCarousel"; @@ -20,25 +21,19 @@ export default function ExploreScreen() { const { query, setQuery, suggestions } = useDiscoveryContext(); const [tags, setTags] = React.useState([]); - const [results, setResults] = React.useState([]); + const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]); + const { data: results = [] } = useSearch(tagStrings); // Show metadata cache by id (filled from SHOW results and lazy-loaded by id) const [showsById, setShowsById] = React.useState>({}); // --- helpers --- - const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]); + function tagAdded(tag: AutoCompleteItem) { const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag]; setTags(nextTags); - const inputs = nextTags.map((t) => t.text); - getSearchResults(inputs, 50) - .then((items) => { - setResults(items || []); - }) - .catch(console.error); - setQuery(""); Keyboard.dismiss(); } @@ -46,12 +41,6 @@ export default function ExploreScreen() { function tagRemoved(tag: AutoCompleteItem) { const nextTags = tags.filter((t) => t.text !== tag.text); setTags(nextTags); - const inputs = nextTags.map((t) => t.text); - getSearchResults(inputs, 50) - .then((items) => { - setResults(items || []); - }) - .catch(console.error); } // Keep our local show cache in sync with SHOW items returned by search @@ -135,7 +124,7 @@ export default function ExploreScreen() { Durchsuchen - + {/* Search bar */} { if (!query.trim()) return; @@ -205,8 +188,8 @@ export default function ExploreScreen() { {/* Personen Section (top) */} {persons.length > 0 && ( - - Personen + + Personen {persons.slice(0, 5).map((p) => ( ))} @@ -214,8 +197,8 @@ export default function ExploreScreen() { )} {/* Staffeln grouped by Show with page view */} - - Staffeln + + Staffeln {Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => { const show = showsById[Number(showId)]; @@ -242,7 +225,7 @@ export default function ExploreScreen() { })} {seasonsByShowId.size === 0 && ( - + Keine Staffeln gefunden. Passe deine Tags an. )} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 615fe23..452ba3d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,8 +1,8 @@ import styles from "@/app/tabStyles/indexStyles"; import ShowCard from "@/components/ui/ShowCard"; -import { useShowContext } from "@/contexts/ShowContext"; -import { useStreamingServiceContext } from "@/contexts/StreamingServiceContext"; -import * as Haptics from 'expo-haptics'; +import { useShows } from "@/hooks/useShows"; +import { useStreamingServices } from "@/hooks/useStreamingServices"; +import * as Haptics from "expo-haptics"; import { router } from "expo-router"; import React from "react"; import { @@ -18,54 +18,43 @@ import { } from "react-native-gesture-handler"; export default function HomeScreen() { - const { shows, error, loading } = useShowContext(); - const { streamingServices } = useStreamingServiceContext(); - const [filteredShows, setFilteredShows] = React.useState(shows); + const { data: shows = [], error, isLoading: loading } = useShows(); + const { data: streamingServices = {} } = useStreamingServices(); const [activeFilter, setActiveFilter] = React.useState("all"); const haptikFeedback = () => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } - - React.useEffect(() => { - setFilteredShows(shows); - }, [shows]); + }; const handleFilter = (type: string) => { haptikFeedback(); - setActiveFilter(type); - - - if (type === "all") { - setFilteredShows(shows); - return; - } - - if (type === "live") { - const filtered = shows.filter((show) => show.running); - setFilteredShows(filtered); - return; - } - if (type === activeFilter) { - setFilteredShows(shows); - setActiveFilter('all'); - return; + setActiveFilter("all"); + } else { + setActiveFilter(type); } - - - - const filtered = shows.filter((show) => - show.streamingService.split(',').map(s => s.trim()).includes(type) - ); - setFilteredShows(filtered); }; + const filteredShows = React.useMemo(() => { + if (activeFilter === "all") { + return shows; + } + if (activeFilter === "live") { + return shows.filter((show) => show.running); + } + return shows.filter((show) => + show.streamingService + .split(",") + .map((s) => s.trim()) + .includes(activeFilter) + ); + }, [shows, activeFilter]); + const uniqueStreamingServices = React.useMemo(() => { const uniqueServices = new Set(); shows.forEach((show) => { - const services = show.streamingService.split(', ').map(s => s.trim()); - services.forEach(service => uniqueServices.add(service)); + const services = show.streamingService.split(", ").map((s) => s.trim()); + services.forEach((service) => uniqueServices.add(service)); }); return Array.from(uniqueServices); }, [shows]); @@ -91,7 +80,7 @@ export default function HomeScreen() { { justifyContent: "center", alignItems: "center" }, ]} > - Error: {error} + Error: {error?.message || String(error)} ); } @@ -102,7 +91,10 @@ export default function HomeScreen() { FLTR - + handleFilter("all")} > - ALLE + + ALLE + )} {activeFilter !== "live" && ( @@ -143,25 +137,35 @@ export default function HomeScreen() { }} onPress={() => handleFilter("live")} > - - LIVE + + + LIVE + )} - + {uniqueStreamingServices.map((serviceName) => { const streamingService = streamingServices[ - `assets.images.streamingServices.${serviceName.toLowerCase()}` + `assets.images.streamingServices.${serviceName.toLowerCase()}` ]; return ( streamingServices[`assets.images.streamingServices.${s.toLowerCase()}`])} + streamingServicesUris={show.streamingService + .split(", ") + .map( + (s) => + streamingServices[ + `assets.images.streamingServices.${s.toLowerCase()}` + ] + )} genres={show.genres} {...(showLiveBadge ? { - liveBadgeText: "LIVE", - liveBadgeContainerStyle: styles.liveBadgeContainer, - } + liveBadgeText: "LIVE", + liveBadgeContainerStyle: styles.liveBadgeContainer, + } : {})} /> ); diff --git a/app/_layout.tsx b/app/_layout.tsx index da8b329..3a33173 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,34 +1,30 @@ import { DiscoveryProvider } from "@/contexts/DiscoveryContext"; -import { SeasonProvider } from "@/contexts/SeasonContext"; -import { ShowProvider } from "@/contexts/ShowContext"; -import { StreamingServiceProvider } from "@/contexts/StreamingServiceContext"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { Stack } from "expo-router"; import "react-native-reanimated"; +const queryClient = new QueryClient(); + export default function RootLayout() { return ( - - - - - - - - - - - - - + + + + + + + + + ); } diff --git a/app/participant.tsx b/app/participant.tsx index 004bad2..e4f1dd2 100644 --- a/app/participant.tsx +++ b/app/participant.tsx @@ -1,7 +1,6 @@ -import { getPersonHistory, type PersonMini } from "@/apis/personHistoryApi"; -import { getShowById } from "@/apis/showApi"; +import { PersonMini } from "@/apis/personHistoryApi"; import styles from "@/app/stackStyles/participantStyles"; -import { useShowContext } from "@/contexts/ShowContext"; +import { usePersonHistory, AppearanceGroup } from "@/hooks/usePersonHistory"; import Ionicons from "@expo/vector-icons/Ionicons"; import { router, useLocalSearchParams } from "expo-router"; import * as WebBrowser from "expo-web-browser"; @@ -12,35 +11,14 @@ import { ScrollView, } from "react-native-gesture-handler"; -type SeasonEntry = { - seasonNumber: number; - partner: PersonMini | null; - participants: PersonMini[]; - startDate: string | null; -}; - -type AppearanceGroup = { - show: { - id: number; - title: string; - bannerUri: string; - thumbnailUri: string; - }; - seasons: SeasonEntry[]; -}; - export default function ParticipantScreen() { - const { shows } = useShowContext(); const { name, participantId } = useLocalSearchParams(); const pid = Array.isArray(participantId) ? Number(participantId[0]) : Number(participantId); - const [, setLoading] = React.useState(false); - const [, setError] = React.useState(null); - - const [appearances, setAppearances] = React.useState([]); + const { data: appearances = [], isLoading, isError } = usePersonHistory(pid); const formatYear = (iso?: string | null) => { if (!iso) return null; @@ -48,104 +26,6 @@ export default function ParticipantScreen() { return y || null; }; - React.useEffect(() => { - if (!pid || Number.isNaN(pid)) return; - const controller = new AbortController(); - setLoading(true); - setError(null); - - (async () => { - try { - const hist = await getPersonHistory(pid, controller.signal); - - const grouped = new Map>(); - for (const h of hist) { - if (!Number.isFinite(h.showId) || h.showId <= 0) continue; - const seasonsForShow = - grouped.get(h.showId) ?? new Map(); - - const existing = seasonsForShow.get(h.seasonNumber); - if (existing) { - seasonsForShow.set(h.seasonNumber, { - seasonNumber: h.seasonNumber, - partner: existing.partner ?? h.partner ?? null, - participants: existing.participants.length - ? existing.participants - : (h.seasonParticipants ?? []), - startDate: existing.startDate ?? h.startDate ?? null, - }); - } else { - seasonsForShow.set(h.seasonNumber, { - seasonNumber: h.seasonNumber, - partner: h.partner ?? null, - participants: h.seasonParticipants ?? [], - startDate: h.startDate ?? null, - }); - } - - grouped.set(h.showId, seasonsForShow); - } - - const showIds = Array.from(grouped.keys()); - - const fromContext = showIds - .map((id) => shows.find((s) => s.id === id)) - .filter((s): s is (typeof shows)[number] => !!s); - - const missingIds = showIds.filter( - (id) => !fromContext.some((s) => s.id === id) - ); - - const fetched = await Promise.all( - missingIds.map(async (id) => { - try { - const s = await getShowById(id); - return s; - } catch { - return null; - } - }) - ); - - const allShows = [ - ...fromContext, - ...fetched.filter(Boolean), - ] as typeof shows; - - const result: AppearanceGroup[] = allShows.map((s) => { - const seasonsMap = grouped.get(s.id)!; - const seasonsSorted = Array.from(seasonsMap.values()).sort( - (a, b) => a.seasonNumber - b.seasonNumber - ); - return { - show: { - id: s.id, - title: s.title, - bannerUri: s.bannerUri, - thumbnailUri: s.thumbnailUri, - }, - seasons: seasonsSorted, - }; - }); - - result.sort((a, b) => - a.show.title.localeCompare(b.show.title, "de", { - sensitivity: "base", - }) - ); - - setAppearances(result); - } catch (e: any) { - if (!controller.signal.aborted) - setError(e?.message || "Fehler beim Laden"); - } finally { - if (!controller.signal.aborted) setLoading(false); - } - })(); - - return () => controller.abort(); - }, [pid, shows]); - const [expandedShows, setExpandedShows] = React.useState>( new Set() ); diff --git a/app/showDetails.tsx b/app/showDetails.tsx index 825a256..ce82174 100644 --- a/app/showDetails.tsx +++ b/app/showDetails.tsx @@ -1,13 +1,15 @@ -import { getShowById, Show } from "@/apis/showApi"; - import ParticipantDetails from "@/components/ui/ParticipantDeatails"; - import ShowInfo from "@/components/ui/ShowInfo"; import StackHeader from "@/components/ui/StackHeader"; -import { useSeasonContext } from "@/contexts/SeasonContext"; +import { + useSeasonCount, + useSeasonDates, + useSeasonParticipants, +} from "@/hooks/useSeason"; +import { useShow } from "@/hooks/useShow"; import * as Haptics from "expo-haptics"; import { router, useLocalSearchParams } from "expo-router"; -import React, { useState } from "react"; +import React from "react"; import { Dimensions, Image, @@ -19,93 +21,34 @@ import { import styles from "./stackStyles/showDetailStyles"; export default function ShowDetails() { - const { - // bannerUri, - // description, - // concept, - // genres, - // streamingService, - id, - // endDate, - } = useLocalSearchParams(); - - const [show, setShow] = useState(null); - const [, setLoading] = useState(true); + const { id } = useLocalSearchParams(); + const showId = Number(id); const [selectedParticipants, setSelectedParticipants] = React.useState(true); const [selectedSeason, setSelectedSeason] = React.useState(1); - const showId = Number(id); - const { fetchSeasonParticipants, fetchSeasonCount, fetchSeasonDates } = - useSeasonContext(); - const [seasonCount, setSeasonCount] = React.useState(0); - const [participants, setParticipants] = React.useState< - { id: number; name: string; imageUri: string }[] - >([]); - const [startDate, setStartDate] = React.useState( - undefined - ); - const [pLoading, setPLoading] = React.useState(false); - const [pError, setPError] = React.useState(null); + + const { data: show } = useShow(showId); + const { data: seasonCount = 0 } = useSeasonCount(showId); + const { + data: participants, + isLoading: pLoading, + isError: pError, + } = useSeasonParticipants(showId, selectedSeason); + const { data: dates } = useSeasonDates(showId, selectedSeason); + const startDate = dates?.startDate; const sortedParticipants = React.useMemo(() => { - return participants.sort((a, b) => + return [...participants].sort((a, b) => a.name.localeCompare(b.name, "de", { sensitivity: "base" }) ); }, [participants]); React.useEffect(() => { - if (!showId) return; - - const fetchShow = async () => { - try { - const data = await getShowById(showId); - setShow(data); - } finally { - setLoading(false); - } - }; - - fetchShow(); - - let active = true; - (async () => { - const count = await fetchSeasonCount(showId); - if (active) { - setSeasonCount(count); - if (count > 0 && selectedSeason > count) setSelectedSeason(1); - } - })(); - return () => { - active = false; - }; - }, [showId, fetchSeasonCount, selectedSeason]); - - React.useEffect(() => { - if (!showId || !selectedSeason) return; - let active = true; - (async () => { - setPError(null); - setPLoading(true); - try { - const [data, dates] = await Promise.all([ - fetchSeasonParticipants(showId, selectedSeason), - fetchSeasonDates(showId, selectedSeason), - ]); - if (active) { - setParticipants(data); - setStartDate(dates?.startDate); - } - } catch { - if (active) setPError("Fehler beim Laden"); - } finally { - if (active) setPLoading(false); - } - })(); - return () => { - active = false; - }; - }, [showId, selectedSeason, fetchSeasonParticipants, fetchSeasonDates]); + if (seasonCount > 0 && selectedSeason > seasonCount) { + setSelectedSeason(1); + } + }, [seasonCount, selectedSeason]); const formattedStartDate = React.useMemo(() => { if (!startDate) return ""; diff --git a/app/stackStyles/participantStyles.tsx b/app/stackStyles/participantStyles.tsx index a4ade8d..4ffb0c0 100644 --- a/app/stackStyles/participantStyles.tsx +++ b/app/stackStyles/participantStyles.tsx @@ -1,9 +1,10 @@ import { Dimensions, StyleSheet } from "react-native"; +import { Colors } from "@/constants/colors"; const styles = StyleSheet.create({ mainContainer: { flex: 1, - backgroundColor: "hsl(221, 39%, 12%)", + backgroundColor: Colors.header, }, closeIcon: { position: "absolute", @@ -11,7 +12,7 @@ const styles = StyleSheet.create({ right: 15, }, participantName: { - color: "white", + color: Colors.text, fontSize: 20, fontWeight: "600", textAlign: "center", @@ -27,7 +28,7 @@ const styles = StyleSheet.create({ }, participantInfoSection: { width: "100%", - height: "auto", + minHeight: "auto", flexDirection: "row", justifyContent: "center", alignItems: "center", @@ -35,7 +36,7 @@ const styles = StyleSheet.create({ marginTop: 5, }, participantInfo: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 16, textAlign: "center", }, @@ -43,20 +44,20 @@ const styles = StyleSheet.create({ width: 4, height: 4, borderRadius: 3, - backgroundColor: "hsl(0, 0%, 80%)", + backgroundColor: Colors.textSecondary, marginHorizontal: 7, marginTop: 2, }, performedShowsSection: { width: "100%", height: "100%", - backgroundColor: 'hsl(221, 39%, 16%)', + backgroundColor: Colors.background, marginTop: 20, }, performedShowsTitle: { fontSize: 16, fontWeight: "600", - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, marginTop: 15, marginLeft: 15, }, @@ -67,7 +68,7 @@ const styles = StyleSheet.create({ borderRadius: 10, }, showLabel: { - color: "white", + color: Colors.text, fontSize: 14, fontWeight: "600", textAlign: "center", @@ -83,14 +84,14 @@ const styles = StyleSheet.create({ backgroundColor: "#eee", }, showTitle: { - color: "white", + color: Colors.text, fontSize: 12, fontWeight: "600", textAlign: "center", marginTop: 15, }, showSeason: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 12, fontWeight: "400", textAlign: "center", @@ -102,7 +103,7 @@ const styles = StyleSheet.create({ borderRadius: 15, marginTop: 20, alignItems: "center", - backgroundColor: "hsl(336, 79%, 63%)", + backgroundColor: Colors.primary, }, card: { @@ -113,12 +114,12 @@ const styles = StyleSheet.create({ horizontalLine: { height: 50, width: 2, - backgroundColor: "hsl(0, 0%, 70%)", + backgroundColor: Colors.textSecondary, marginTop: 10, alignSelf: "center", }, partnerLabel: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 12, fontWeight: "400", textAlign: "center", @@ -126,17 +127,17 @@ const styles = StyleSheet.create({ }, participantContainer: { width: "auto", - height: "auto", + minHeight: "auto", borderRadius: 15, marginTop: 15, alignItems: "center", justifyContent: "center", - backgroundColor: "hsl(221, 39%, 12%)", + backgroundColor: Colors.header, padding: 10, }, participantLabel: { - color: "white", + color: Colors.text, fontSize: 12, }, participantRow: { @@ -167,7 +168,7 @@ const styles = StyleSheet.create({ backgroundColor: "hsl(221, 39%, 28%)", }, moreChipText: { - color: "white", + color: Colors.text, fontSize: 11, fontWeight: "600", }, @@ -175,7 +176,7 @@ const styles = StyleSheet.create({ width: 50, height: 50, borderRadius: 20, - backgroundColor: "hsl(221, 39%, 12%)", + backgroundColor: Colors.header, marginLeft: 15, marginTop: 15, marginBottom: 5, diff --git a/app/stackStyles/showDetailStyles.tsx b/app/stackStyles/showDetailStyles.tsx index 804f553..d1345f8 100644 --- a/app/stackStyles/showDetailStyles.tsx +++ b/app/stackStyles/showDetailStyles.tsx @@ -1,9 +1,10 @@ import { StyleSheet } from "react-native"; +import { Colors } from "@/constants/colors"; const styles = StyleSheet.create({ mainContainer: { flex: 1, - backgroundColor: "hsl(221, 39%, 12%)", + backgroundColor: Colors.header, }, showImage: { width: 200, @@ -22,14 +23,14 @@ const styles = StyleSheet.create({ bottom: 25, }, showInfoText: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 14, }, dot: { width: 4, height: 4, borderRadius: 3, - backgroundColor: "hsl(0, 0%, 80%)", + backgroundColor: Colors.textSecondary, marginHorizontal: 7, marginTop: 2, }, @@ -49,27 +50,27 @@ const styles = StyleSheet.create({ }, infoContainner: { width: "100%", - height: "auto", + minHeight: "auto", paddingHorizontal: 20, paddingVertical: 15, - backgroundColor: "hsl(221, 39%, 0%)", + backgroundColor: Colors.background, flexDirection: "row", gap: 20, }, infoLabel: { fontWeight: "300", - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 16, }, participantsDetailsContainer: { width: "100%", height: "100%", - backgroundColor: "hsl(221, 39%, 2%)", + backgroundColor: Colors.card, }, participantContainer: { height: 160, width: 110, - backgroundColor: "hsl(336, 79%, 63%)", + backgroundColor: Colors.primary, borderRadius: 10, marginBottom: 30, }, @@ -82,8 +83,8 @@ const styles = StyleSheet.create({ }, seasonsSection: { width: "100%", - height: 40, - backgroundColor: "hsl(221, 39%, 2%)", + minHeight: 40, + backgroundColor: Colors.card, flexDirection: "row", alignItems: "center", gap: 10, @@ -105,23 +106,23 @@ const styles = StyleSheet.create({ alignItems: "center", }, seasonLabel: { - color: "white", + color: Colors.text, fontWeight: "bold", }, participantLabel: { - color: "white", + color: Colors.text, fontWeight: "500", textAlign: "center", fontSize: 11, marginTop: 10, }, seasonsLabel: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontWeight: "500", fontSize: 16, }, detailTitle: { - color: "hsl(0, 0%, 100%)", + color: Colors.text, fontSize: 14, fontWeight: "bold", marginTop: 10, @@ -129,7 +130,7 @@ const styles = StyleSheet.create({ marginBottom: 5, }, detailLabel: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 14, lineHeight: 20, width: "90%", @@ -138,7 +139,7 @@ const styles = StyleSheet.create({ marginTop: 5, }, startDate: { - color: "hsl(0, 0%, 80%)", + color: Colors.textSecondary, fontSize: 16, textAlign: "center", marginTop: 15, diff --git a/app/tabStyles/indexStyles.tsx b/app/tabStyles/indexStyles.tsx index 06a61bc..264d7d4 100644 --- a/app/tabStyles/indexStyles.tsx +++ b/app/tabStyles/indexStyles.tsx @@ -1,29 +1,36 @@ +import { Colors } from "@/constants/colors"; import { StyleSheet } from "react-native"; +const shadow = { + shadowColor: "#000", + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.25, + shadowRadius: 3.84, + elevation: 5, +}; + export default StyleSheet.create({ mainContainer: { flex: 1, - backgroundColor: "hsl(221, 39%, 11%)", - // paddingHorizontal: 10, + backgroundColor: Colors.background, + paddingHorizontal: 15, }, header: { - height: 125, - backgroundColor: "hsl(221, 39%, 12%)", + minHeight: 125, + backgroundColor: Colors.header, alignItems: "center", justifyContent: "center", borderBottomWidth: 1, - borderBottomColor: "hsl(221, 39%, 15%)", - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 3, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, + borderRadius: 25, + marginBottom: 15, + borderBottomColor: Colors.border, + ...shadow, }, title: { - color: "white", + color: Colors.text, fontSize: 38, fontWeight: "bold", marginTop: "auto", @@ -33,38 +40,37 @@ export default StyleSheet.create({ position: "absolute", top: 15, left: 20, - backgroundColor: "red", + backgroundColor: Colors.red, borderRadius: 10, paddingVertical: 5, paddingHorizontal: 10, }, filterSection: { width: "100%", - height: 70, + minHeight: 70, marginTop: 20, }, searchContainer: { width: "100%", height: 60, marginHorizontal: "auto", - backgroundColor: "hsl(221, 39%, 8%)", + backgroundColor: Colors.card, flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderRadius: 20, paddingHorizontal: 20, - marginTop: 15, borderWidth: 1.5, - borderColor: "hsl(221, 39%, 15%)", - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, + borderColor: Colors.border, + ...shadow, + }, + searchInput: { + fontSize: 18, + fontWeight: "500", + color: "hsl(221, 39%, 80%)", + width: "90%", + height: "100%", }, searchLabel: { color: "hsl(221, 39%, 80%)", @@ -79,24 +85,16 @@ export default StyleSheet.create({ height: "auto", paddingBottom: 15, borderRadius: 20, - backgroundColor: "hsl(221, 39%, 8%)", + backgroundColor: Colors.card, borderWidth: 1.5, - borderColor: "hsl(221, 39%, 15%)", + borderColor: Colors.border, marginHorizontal: "auto", alignSelf: "center", marginTop: 15, - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.25, - shadowRadius: 3.84, - elevation: 5, - // opacity: 0.9, + ...shadow, }, suggestionTitle: { - color: "hsl(0, 0%, 60%)", + color: Colors.textSecondary, fontSize: 14, marginLeft: 15, marginTop: 15, @@ -116,10 +114,10 @@ export default StyleSheet.create({ height: 20, borderRadius: 10, borderWidth: 1.5, - borderColor: "hsl(0, 0%, 90%)", + borderColor: Colors.text, }, suggestionLabel: { - color: "white", + color: Colors.text, fontSize: 12, fontWeight: "500", marginLeft: 10, @@ -136,7 +134,7 @@ export default StyleSheet.create({ marginTop: 5, }, tagLabel: { - color: "white", + color: Colors.text, marginRight: 5, }, tagContainer: { @@ -164,6 +162,23 @@ export default StyleSheet.create({ justifyContent: "center", marginRight: 10, }, - personName: { color: "white", fontSize: 16, fontWeight: "600" }, + personName: { color: Colors.text, fontSize: 16, fontWeight: "600" }, personMeta: { color: "#bbb", fontSize: 12, marginTop: 2 }, + sectionContainer: { + width: "100%", + paddingHorizontal: 10, + marginBottom: 12, + }, + sectionTitle: { + color: Colors.text, + fontSize: 18, + fontWeight: "600", + marginBottom: 6, + }, + centerText: { + color: Colors.text, + fontSize: 16, + textAlign: "center", + marginTop: 14, + }, }); diff --git a/constants/colors.ts b/constants/colors.ts index add52a2..536ef70 100644 --- a/constants/colors.ts +++ b/constants/colors.ts @@ -1,3 +1,14 @@ +export const Colors = { + background: 'hsl(221, 39%, 11%)', + header: 'hsl(221, 39%, 12%)', + card: 'hsl(221, 39%, 8%)', + border: 'hsl(221, 39%, 15%)', + text: 'white', + textSecondary: 'hsl(0, 0%, 60%)', + primary: '#199edb', + red: 'red', +}; + export type ShowDetailColors = { tabColor: string; seasonColor: string; diff --git a/contexts/DiscoveryContext.tsx b/contexts/DiscoveryContext.tsx index ff94906..a9b5133 100644 --- a/contexts/DiscoveryContext.tsx +++ b/contexts/DiscoveryContext.tsx @@ -1,12 +1,10 @@ import React, { createContext, useContext, - useEffect, - useRef, useState, - useCallback, } from "react"; -import { getAutoComplete, AutoCompleteItem } from "@/apis/autoCompleteApi"; +import { useAutoComplete } from "@/hooks/useAutoComplete"; +import { AutoCompleteItem } from "@/apis/autoCompleteApi"; type DiscoveryContextType = { query: string; @@ -25,61 +23,15 @@ export const DiscoveryProvider = ({ children: React.ReactNode; }) => { const [query, setQuery] = useState(""); - const [suggestions, setSuggestions] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const abortRef = useRef(null); - const debounceRef = useRef | null>(null); - const cacheRef = useRef>({}); - - const fetchSuggestions = useCallback((q: string) => { - if (abortRef.current) abortRef.current.abort(); - if (!q.trim()) { - setSuggestions([]); - setLoading(false); - return; - } - const cached = cacheRef.current[q]; - if (cached) { - setSuggestions(cached); - setLoading(false); - return; - } - const controller = new AbortController(); - abortRef.current = controller; - setLoading(true); - setError(null); - getAutoComplete(q, 10, controller.signal) - .then((items) => { - cacheRef.current[q] = items; - setSuggestions(items); - }) - .catch((e) => { - if (controller.signal.aborted) return; - setError(e.message || "Fehler"); - }) - .finally(() => { - if (!controller.signal.aborted) setLoading(false); - }); - }, []); - - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => fetchSuggestions(query), 300); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [query, fetchSuggestions]); + const { data: suggestions = [], isLoading: loading, error } = useAutoComplete(query); const clear = () => { setQuery(""); - setSuggestions([]); - setError(null); }; return ( {children} diff --git a/contexts/SeasonContext.tsx b/contexts/SeasonContext.tsx deleted file mode 100644 index e5988ec..0000000 --- a/contexts/SeasonContext.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { getSeason, SeasonParticipant } from "@/apis/seasonApi"; -import React, { - createContext, - useCallback, - useContext, - useState, -} from "react"; - -type SeasonContextType = { - fetchSeasonParticipants: ( - showId: number, - seasonNumber: number - ) => Promise; - fetchSeasonCount: (showId: number) => Promise; - fetchSeasonDates: ( - showId: number, - seasonNumber: number - ) => Promise<{ startDate?: string; endDate?: string | null } | null>; -}; - -const SeasonContext = createContext(null); - -export const SeasonProvider = ({ children }: { children: React.ReactNode }) => { - const [seasonCache, setSeasonCache] = useState< - Record - >({}); - const [seasonCountCache, setSeasonCountCache] = useState< - Record - >({}); - const [datesCache, setDatesCache] = useState< - Record - >({}); - - const fetchSeasonParticipants = useCallback( - async (showId: number, seasonNumber: number) => { - const key = `${showId}-${seasonNumber}`; - if (seasonCache[key]) return seasonCache[key]; - try { - const season = await getSeason(showId, seasonNumber); - const participants = season?.participants ?? []; - setSeasonCache((c) => ({ ...c, [key]: participants })); - return participants; - } catch { - return []; - } - }, - [seasonCache] - ); - - const fetchSeasonCount = useCallback( - async (showId: number) => { - if (seasonCountCache[showId] !== undefined) - return seasonCountCache[showId]; - let n = 0; - for (let s = 1; s <= 50; s++) { - try { - const season = await getSeason(showId, s); - if (!season) break; - n = s; - } catch { - break; - } - } - setSeasonCountCache((c) => ({ ...c, [showId]: n })); - return n; - }, - [seasonCountCache] - ); - - const fetchSeasonDates = useCallback( - async (showId: number, seasonNumber: number) => { - const key = `${showId}-${seasonNumber}`; - if (datesCache[key]) return datesCache[key]; - try { - const season = await getSeason(showId, seasonNumber); - const dates = season - ? { startDate: season.startDate, endDate: season.endDate } - : null; - if (dates) setDatesCache((c) => ({ ...c, [key]: dates })); - return dates; - } catch { - return null; - } - }, - [datesCache] - ); - - return ( - - {children} - - ); -}; - - -export const useSeasonContext = () => { - const ctx = useContext(SeasonContext); - if (!ctx) - throw new Error("useSeasonContext must be used within a SeasonProvider"); - return ctx; -}; diff --git a/contexts/ShowContext.tsx b/contexts/ShowContext.tsx deleted file mode 100644 index d7ec9cc..0000000 --- a/contexts/ShowContext.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { getShows, Show } from "@/apis/showApi"; -import { createContext, useContext, useEffect, useState } from "react"; - -type ShowContextType = { - shows: Show[]; - loading: boolean; - error: string | null; -}; - -const ShowContext = createContext(null); - -export const ShowProvider = ({ children }: { children: React.ReactNode }) => { - const [shows, setShows] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - (async () => { - try { - const data = await getShows(); - setShows(data); - } catch { - setError("Failed to fetch shows"); - } finally { - setLoading(false); - } - })(); - }, []); - - return ( - - {children} - - ); -}; - -export const useShowContext = () => { - const ctx = useContext(ShowContext); - if (!ctx) - throw new Error("useShowContext must be used within a ShowProvider"); - return ctx; -}; diff --git a/contexts/StreamingServiceContext.tsx b/contexts/StreamingServiceContext.tsx deleted file mode 100644 index 23297ff..0000000 --- a/contexts/StreamingServiceContext.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { - getStreamingImages, - StreamingServiceRaw, -} from "@/apis/streamingServiceApi"; -import { createContext, useContext } from "react"; -import React from "react"; - -type StreamingServiceContextType = { - streamingServices: Record; - loading: boolean; - error: string | null; -}; - -const StreamingServiceContext = - createContext(null); - -export const StreamingServiceProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const [streamingServices, setStreamingServices] = React.useState< - Record - >({}); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(null); - - React.useEffect(() => { - (async () => { - try { - const data: StreamingServiceRaw[] = await getStreamingImages(); - - const mapped = Object.fromEntries(data.map((s) => [s.key, s.value])); - setStreamingServices(mapped); - } catch { - setError("Failed to fetch streaming services"); - } finally { - setLoading(false); - } - })(); - }, []); - - return ( - - {children} - - ); -}; - -export const useStreamingServiceContext = () => { - const ctx = useContext(StreamingServiceContext); - if (!ctx) - throw new Error( - "useStreamingServiceContext must be used within a StreamingServiceProvider" - ); - return ctx; -}; diff --git a/hooks/useAutoComplete.ts b/hooks/useAutoComplete.ts new file mode 100644 index 0000000..bea1dd5 --- /dev/null +++ b/hooks/useAutoComplete.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getAutoComplete } from "@/apis/autoCompleteApi"; +import { useDebounce } from "./useDebounce"; + +export const useAutoComplete = (query: string) => { + const debouncedQuery = useDebounce(query, 300); + + return useQuery({ + queryKey: ["autoComplete", debouncedQuery], + queryFn: () => getAutoComplete(debouncedQuery), + enabled: !!debouncedQuery, + }); +}; \ No newline at end of file diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts new file mode 100644 index 0000000..a8f4b00 --- /dev/null +++ b/hooks/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +}; \ No newline at end of file diff --git a/hooks/usePersonHistory.ts b/hooks/usePersonHistory.ts new file mode 100644 index 0000000..3338cf4 --- /dev/null +++ b/hooks/usePersonHistory.ts @@ -0,0 +1,87 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPersonHistory, PersonHistoryRecord, PersonMini } from "@/apis/personHistoryApi"; +import { getShowById, Show } from "@/apis/showApi"; + +type SeasonEntry = { + seasonNumber: number; + partner: PersonMini | null; + participants: PersonMini[]; + startDate: string | null; +}; + +export type AppearanceGroup = { + show: Show; + seasons: SeasonEntry[]; +}; + +export const usePersonHistory = (personId: number) => { + return useQuery({ + queryKey: ["personHistory", personId], + queryFn: async () => { + const history = await getPersonHistory(personId); + + const grouped = new Map>(); + for (const h of history) { + if (!Number.isFinite(h.showId) || h.showId <= 0) continue; + const seasonsForShow = + grouped.get(h.showId) ?? new Map(); + + const existing = seasonsForShow.get(h.seasonNumber); + if (existing) { + seasonsForShow.set(h.seasonNumber, { + seasonNumber: h.seasonNumber, + partner: existing.partner ?? h.partner ?? null, + participants: existing.participants.length + ? existing.participants + : (h.seasonParticipants ?? []), + startDate: existing.startDate ?? h.startDate ?? null, + }); + } else { + seasonsForShow.set(h.seasonNumber, { + seasonNumber: h.seasonNumber, + partner: h.partner ?? null, + participants: h.seasonParticipants ?? [], + startDate: h.startDate ?? null, + }); + } + + grouped.set(h.showId, seasonsForShow); + } + + const showIds = Array.from(grouped.keys()); + + const shows = await Promise.all( + showIds.map(async (id) => { + try { + const s = await getShowById(id); + return s; + } catch { + return null; + } + }) + ); + + const validShows = shows.filter((s): s is Show => !!s); + + const result: AppearanceGroup[] = validShows.map((s) => { + const seasonsMap = grouped.get(s.id)!; + const seasonsSorted = Array.from(seasonsMap.values()).sort( + (a, b) => a.seasonNumber - b.seasonNumber + ); + return { + show: s, + seasons: seasonsSorted, + }; + }); + + result.sort((a, b) => + a.show.title.localeCompare(b.show.title, "de", { + sensitivity: "base", + }) + ); + + return result; + }, + enabled: !!personId, + }); +}; \ No newline at end of file diff --git a/hooks/useSearch.ts b/hooks/useSearch.ts new file mode 100644 index 0000000..82b0c03 --- /dev/null +++ b/hooks/useSearch.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSearchResults } from "@/apis/searchApi"; + +export const useSearch = (tags: string[]) => { + return useQuery({ + queryKey: ["search", tags], + queryFn: () => getSearchResults(tags), + enabled: tags.length > 0, + }); +}; \ No newline at end of file diff --git a/hooks/useSeason.ts b/hooks/useSeason.ts new file mode 100644 index 0000000..1882040 --- /dev/null +++ b/hooks/useSeason.ts @@ -0,0 +1,50 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSeason } from "@/apis/seasonApi"; + +export const useSeason = (showId: number, seasonNumber: number) => { + return useQuery({ + queryKey: ["season", showId, seasonNumber], + queryFn: () => getSeason(showId, seasonNumber), + enabled: !!showId && !!seasonNumber, + }); +}; + +export const useSeasonParticipants = (showId: number, seasonNumber: number) => { + const { data: season, ...rest } = useSeason(showId, seasonNumber); + return { + data: season?.participants ?? [], + ...rest, + }; +}; + +export const useSeasonDates = (showId: number, seasonNumber: number) => { + const { data: season, ...rest } = useSeason(showId, seasonNumber); + return { + data: season ? { startDate: season.startDate, endDate: season.endDate } : null, + ...rest, + }; +}; + +// This is a bit tricky, as we need to fetch seasons sequentially to know the count. +// React Query is not ideal for this kind of sequential fetching. +// However, we can still wrap the existing logic in a useQuery hook. +// This is not the most efficient way, but it's better than nothing. +export const useSeasonCount = (showId: number) => { + return useQuery({ + queryKey: ["seasonCount", showId], + queryFn: async () => { + let n = 0; + for (let s = 1; s <= 50; s++) { + try { + const season = await getSeason(showId, s); + if (!season) break; + n = s; + } catch { + break; + } + } + return n; + }, + enabled: !!showId, + }); +}; \ No newline at end of file diff --git a/hooks/useShow.ts b/hooks/useShow.ts new file mode 100644 index 0000000..3057c58 --- /dev/null +++ b/hooks/useShow.ts @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { getShowById } from "@/apis/showApi"; + +export const useShow = (showId: number) => { + return useQuery({ + queryKey: ["show", showId], + queryFn: () => getShowById(showId), + enabled: !!showId, + }); +}; \ No newline at end of file diff --git a/hooks/useShows.ts b/hooks/useShows.ts new file mode 100644 index 0000000..8420550 --- /dev/null +++ b/hooks/useShows.ts @@ -0,0 +1,9 @@ +import { useQuery } from "@tanstack/react-query"; +import { getShows } from "@/apis/showApi"; + +export const useShows = () => { + return useQuery({ + queryKey: ["shows"], + queryFn: getShows, + }); +}; \ No newline at end of file diff --git a/hooks/useStreamingServices.ts b/hooks/useStreamingServices.ts new file mode 100644 index 0000000..1bb08bd --- /dev/null +++ b/hooks/useStreamingServices.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { getStreamingImages, StreamingServiceRaw } from "@/apis/streamingServiceApi"; + +export const useStreamingServices = () => { + return useQuery({ + queryKey: ["streamingServices"], + queryFn: async () => { + const data: StreamingServiceRaw[] = await getStreamingImages(); + const mapped = Object.fromEntries(data.map((s) => [s.key, s.value])); + return mapped; + }, + }); +}; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index be32fdb..10ffe12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "@tanstack/react-query": "^5.90.5", "expo": "54.0.21", "expo-blur": "~15.0.7", "expo-constants": "~18.0.10", @@ -3873,6 +3874,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@tanstack/query-core": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.5.tgz", + "integrity": "sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.5.tgz", + "integrity": "sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", diff --git a/package.json b/package.json index 08fead8..02bf644 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@react-navigation/bottom-tabs": "^7.3.10", "@react-navigation/elements": "^2.3.8", "@react-navigation/native": "^7.1.6", + "@tanstack/react-query": "^5.90.5", "expo": "54.0.21", "expo-blur": "~15.0.7", "expo-constants": "~18.0.10",