:api added explore auto-complete feature

This commit is contained in:
Cron1cle
2025-10-08 15:02:31 +02:00
parent 5c49acb2f4
commit a01ffcd2bb
6 changed files with 350 additions and 21 deletions

24
apis/autoCompleteApi.ts Normal file
View File

@@ -0,0 +1,24 @@
export type AutoCompleteItemType = "SHOW" | "PERSON" | "SEASON" | string;
export type AutoCompleteItem = {
type: AutoCompleteItemType;
text: string;
};
const DISCOVER_BASE = "http://45.157.177.99:8080/discover";
export async function getAutoComplete(
query: string,
limit = 10,
signal?: AbortSignal
): Promise<AutoCompleteItem[]> {
if (!query.trim()) return [];
const url = `${DISCOVER_BASE}/autoComplete?q=${encodeURIComponent(
query
)}&limit=${limit}`;
const res = await fetch(url, { signal });
if (!res.ok) throw new Error("AutoComplete failed " + res.status);
const data: unknown = await res.json();
if (!Array.isArray(data)) return [];
return data as AutoCompleteItem[];
}

View File

@@ -1,11 +1,118 @@
import styles from "@/app/tabStyles/indexStyles";
import React from "react";
import { Text, View } from "react-native";
import {
Text,
View,
TextInput,
FlatList,
TouchableOpacity,
Keyboard,
TouchableWithoutFeedback,
} from "react-native";
import Feather from "@expo/vector-icons/Feather";
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
import { useShowContext } from "@/contexts/ShowContext";
export default function TabTwoScreen() {
const { query, setQuery, suggestions, loading, error, clear } =
useDiscoveryContext();
const { shows } = useShowContext();
const personSuggestions = React.useMemo(
() => suggestions.filter((s) => s.type === "PERSON"),
[suggestions]
);
const showSuggestions = React.useMemo(
() => suggestions.filter((s) => s.type === "SHOW"),
[suggestions]
);
const [tag, setTag] = React.useState<string | null>(null);
return (
<View style={styles.mainContainer}>
<Text>Explore Screen</Text>
</View>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={styles.mainContainer}>
<View style={styles.header}>
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
</View>
<View style={styles.searchContainer}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Wonach suchst du?"
placeholderTextColor=""
style={{
fontSize: 18,
fontWeight: "500",
color: "hsl(221, 39%, 80%)",
}}
returnKeyType="search"
onSubmitEditing={() => {
console.log("Search:", query);
}}
autoCapitalize="none"
/>
{query.length === 0 ? (
<Feather name="search" size={24} color="hsl(221, 39%, 80%)" />
) : (
<Feather
name="x"
size={24}
color="hsl(221, 39%, 80%)"
onPress={() => setQuery("")}
/>
)}
</View>
{tag && (
<View style={styles.tagContainer}>
<Text style={styles.tagLabel}>{tag}</Text>
<Feather
name="x"
size={18}
color="hsl(221, 39%, 80%)"
onPress={() => setTag(null)}
style={{ marginLeft: "auto", marginRight: 10 }}
/>
</View>
)}
{query.length > 0 && (
<>
<View style={styles.suggestionsSection}>
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
{showSuggestions.map((suggestion, idx) => (
<TouchableOpacity
key={suggestion.text + "_" + idx}
style={styles.suggestionContainer}
onPress={() => {
setTag(suggestion.text);
}}
>
<View style={styles.imageContainer}></View>
<Text style={styles.suggestionLabel}>{suggestion.text}</Text>
</TouchableOpacity>
))}
{personSuggestions.map((suggestion, idx) => (
<TouchableOpacity
key={suggestion.text + "_" + idx}
style={styles.suggestionContainer}
onPress={() => {
setTag(suggestion.text);
}}
>
<View style={styles.imageContainer}></View>
<Text style={styles.suggestionLabel}>{suggestion.text}</Text>
</TouchableOpacity>
))}
</View>
</>
)}
</View>
</TouchableWithoutFeedback>
);
}

View File

@@ -87,6 +87,7 @@ export default function HomeScreen() {
alignItems: "center",
paddingHorizontal: 10,
gap: 10,
marginLeft: 10,
}}
>
{uniqueStreamingServices.map((serviceName) => {

View File

@@ -2,6 +2,7 @@ import { ShowProvider } from "@/contexts/ShowContext";
import { SeasonProvider } from "@/contexts/SeasonContext";
import { StreamingServiceProvider } from "@/contexts/StreamingServiceContext";
import { PersonProvider } from "@/contexts/PersonContext";
import { DiscoveryProvider } from "@/contexts/DiscoveryContext";
import { Stack } from "expo-router";
import "react-native-reanimated";
@@ -11,22 +12,24 @@ export default function RootLayout() {
<SeasonProvider>
<StreamingServiceProvider>
<PersonProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="showDetails"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="participant"
options={{
presentation: "fullScreenModal",
headerShown: false,
}}
/>
</Stack>
<DiscoveryProvider>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="showDetails"
options={{
headerShown: false,
}}
/>
<Stack.Screen
name="participant"
options={{
presentation: "fullScreenModal",
headerShown: false,
}}
/>
</Stack>
</DiscoveryProvider>
</PersonProvider>
</StreamingServiceProvider>
</SeasonProvider>

View File

@@ -40,7 +40,105 @@ export default StyleSheet.create({
filterSection: {
width: "100%",
height: 70,
backgroundColor: "hsl(221, 39%, 5%)",
marginTop: 20,
},
searchContainer: {
width: "90%",
height: 60,
marginHorizontal: "auto",
backgroundColor: "hsl(221, 39%, 8%)",
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,
},
searchLabel: {
color: "hsl(221, 39%, 80%)",
fontSize: 18,
fontWeight: "500",
},
suggestionsSection: {
width: "90%",
height: "auto",
paddingBottom: 15,
borderRadius: 20,
backgroundColor: "hsl(221, 39%, 8%)",
borderWidth: 1.5,
borderColor: "hsl(221, 39%, 15%)",
marginHorizontal: "auto",
alignSelf: "center",
marginTop: 15,
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
suggestionTitle: {
color: "hsl(0, 0%, 60%)",
fontSize: 14,
marginLeft: 15,
marginTop: 15,
fontWeight: "500",
},
suggestionContainer: {
marginTop: 15,
width: "100%",
height: 30,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingHorizontal: 10,
},
imageContainer: {
width: 20,
height: 20,
borderRadius: 10,
borderWidth: 1.5,
borderColor: "hsl(0, 0%, 90%)",
},
suggestionLabel: {
color: "white",
fontSize: 12,
fontWeight: "500",
marginLeft: 10,
},
tagContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "hsl(221, 39%, 8%)",
borderWidth: 1.5,
borderColor: "hsl(221, 39%, 15%)",
height: "auto",
width: "auto",
paddingVertical: 10,
borderRadius: 50,
marginTop: 15,
marginLeft: 20,
justifyContent: "space-between",
},
tagLabel: {
color: "hsl(0, 0%, 90%)",
fontSize: 14,
fontWeight: "500",
marginLeft: 15,
textAlign: "center",
},
});

View File

@@ -0,0 +1,96 @@
import React, {
createContext,
useContext,
useEffect,
useRef,
useState,
useCallback,
} from "react";
import { getAutoComplete, AutoCompleteItem } from "@/apis/autoCompleteApi";
type DiscoveryContextType = {
query: string;
setQuery: (q: string) => void;
suggestions: AutoCompleteItem[];
loading: boolean;
error: string | null;
clear: () => void;
};
const DiscoveryContext = createContext<DiscoveryContextType | null>(null);
export const DiscoveryProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const [query, setQuery] = useState("");
const [suggestions, setSuggestions] = useState<AutoCompleteItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const cacheRef = useRef<Record<string, AutoCompleteItem[]>>({});
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 clear = () => {
setQuery("");
setSuggestions([]);
setError(null);
};
return (
<DiscoveryContext.Provider
value={{ query, setQuery, suggestions, loading, error, clear }}
>
{children}
</DiscoveryContext.Provider>
);
};
export const useDiscoveryContext = () => {
const ctx = useContext(DiscoveryContext);
if (!ctx)
throw new Error(
"useDiscoveryContext must be used within DiscoveryProvider"
);
return ctx;
};