:api added explore auto-complete feature
This commit is contained in:
24
apis/autoCompleteApi.ts
Normal file
24
apis/autoCompleteApi.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -1,11 +1,118 @@
|
|||||||
import styles from "@/app/tabStyles/indexStyles";
|
import styles from "@/app/tabStyles/indexStyles";
|
||||||
import React from "react";
|
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() {
|
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 (
|
return (
|
||||||
|
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
|
||||||
<View style={styles.mainContainer}>
|
<View style={styles.mainContainer}>
|
||||||
<Text>Explore Screen</Text>
|
<View style={styles.header}>
|
||||||
|
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
|
||||||
</View>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function HomeScreen() {
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: 10,
|
||||||
gap: 10,
|
gap: 10,
|
||||||
|
marginLeft: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{uniqueStreamingServices.map((serviceName) => {
|
{uniqueStreamingServices.map((serviceName) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ShowProvider } from "@/contexts/ShowContext";
|
|||||||
import { SeasonProvider } from "@/contexts/SeasonContext";
|
import { SeasonProvider } from "@/contexts/SeasonContext";
|
||||||
import { StreamingServiceProvider } from "@/contexts/StreamingServiceContext";
|
import { StreamingServiceProvider } from "@/contexts/StreamingServiceContext";
|
||||||
import { PersonProvider } from "@/contexts/PersonContext";
|
import { PersonProvider } from "@/contexts/PersonContext";
|
||||||
|
import { DiscoveryProvider } from "@/contexts/DiscoveryContext";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ export default function RootLayout() {
|
|||||||
<SeasonProvider>
|
<SeasonProvider>
|
||||||
<StreamingServiceProvider>
|
<StreamingServiceProvider>
|
||||||
<PersonProvider>
|
<PersonProvider>
|
||||||
|
<DiscoveryProvider>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@@ -27,6 +29,7 @@ export default function RootLayout() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</DiscoveryProvider>
|
||||||
</PersonProvider>
|
</PersonProvider>
|
||||||
</StreamingServiceProvider>
|
</StreamingServiceProvider>
|
||||||
</SeasonProvider>
|
</SeasonProvider>
|
||||||
|
|||||||
@@ -40,7 +40,105 @@ export default StyleSheet.create({
|
|||||||
filterSection: {
|
filterSection: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: 70,
|
height: 70,
|
||||||
backgroundColor: "hsl(221, 39%, 5%)",
|
|
||||||
marginTop: 20,
|
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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
96
contexts/DiscoveryContext.tsx
Normal file
96
contexts/DiscoveryContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user