469 lines
16 KiB
TypeScript
469 lines
16 KiB
TypeScript
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
||
import { Season } from "@/apis/seasonApi";
|
||
import { Show } from "@/apis/showApi";
|
||
import styles from "@/app/tabStyles/indexStyles";
|
||
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,
|
||
TouchableWithoutFeedback,
|
||
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 { useSearch } from "@/hooks/useSearch";
|
||
import {
|
||
getIconName,
|
||
mapApiPersonToUI,
|
||
mapApiSeasonToUI,
|
||
mapApiShowToUI,
|
||
} from "@/utils/searchMapping";
|
||
|
||
export default function ExploreScreen() {
|
||
const { query, setQuery, suggestions } = useDiscoveryContext();
|
||
|
||
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
|
||
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
|
||
|
||
const {
|
||
data: results = [],
|
||
error,
|
||
refetch,
|
||
} = useSearch(tagStrings as string[]);
|
||
|
||
// Lokaler Show-Cache
|
||
const [showsById, setShowsById] = React.useState<Record<number, Show>>({});
|
||
|
||
// Steuerung für Vorschlagsliste
|
||
const [showSuggestions, setShowSuggestions] = React.useState(false);
|
||
|
||
function tagAdded(tag: AutoCompleteItem) {
|
||
const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag];
|
||
setTags(nextTags);
|
||
setQuery("");
|
||
setShowSuggestions(false);
|
||
Keyboard.dismiss();
|
||
}
|
||
|
||
function tagRemoved(tag: AutoCompleteItem) {
|
||
const nextTags = tags.filter((t) => t.text !== tag.text);
|
||
setTags(nextTags);
|
||
}
|
||
|
||
// Cache mit SHOW-Resultaten füllen
|
||
React.useEffect(() => {
|
||
const fromResults: Record<number, Show> = {};
|
||
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]);
|
||
|
||
// SEASON-Ergebnisse nach showId gruppieren
|
||
const seasonsByShowId = React.useMemo(() => {
|
||
const map = new Map<number, Season[]>();
|
||
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);
|
||
}
|
||
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]);
|
||
|
||
// Fehlende Shows für Carousels nachladen
|
||
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<number, Show> = {};
|
||
for (const s of fetched) {
|
||
if (!s) continue;
|
||
next[s.id] = s;
|
||
}
|
||
if (Object.keys(next).length) {
|
||
setShowsById((prev) => ({ ...prev, ...next }));
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [seasonsByShowId, showsById]);
|
||
|
||
// PERSON-Resultate
|
||
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]);
|
||
|
||
// SHOW-Resultate, die KEINE Staffeln haben -> als einzelne ShowBox anzeigen
|
||
const standaloneShows: Show[] = React.useMemo(() => {
|
||
const seen = new Set<number>();
|
||
const list: Show[] = [];
|
||
for (const r of results) {
|
||
if (r.type !== "SHOW") continue;
|
||
const ui = mapApiShowToUI(r.data) as Show | undefined;
|
||
if (!ui?.id) continue;
|
||
const id = Number(ui.id);
|
||
if (seasonsByShowId.has(id)) continue; // bereits als Carousel vorhanden -> nicht doppelt
|
||
if (seen.has(id)) continue;
|
||
seen.add(id);
|
||
list.push(ui);
|
||
}
|
||
return list;
|
||
}, [results, seasonsByShowId]);
|
||
|
||
// Moderner Fehlerblock
|
||
if (error) {
|
||
return (
|
||
<View
|
||
style={[
|
||
styles.mainContainer,
|
||
{ justifyContent: "center", alignItems: "center", padding: 20 },
|
||
]}
|
||
>
|
||
<View
|
||
style={{
|
||
alignItems: "center",
|
||
gap: 12,
|
||
backgroundColor: "rgba(255,255,255,0.05)",
|
||
paddingVertical: 24,
|
||
paddingHorizontal: 20,
|
||
borderRadius: 12,
|
||
width: "85%",
|
||
}}
|
||
>
|
||
<Text style={{ fontSize: 36 }}>⚠️</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 18,
|
||
fontWeight: "600",
|
||
color: "white",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Fehler beim Laden
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 14,
|
||
color: "rgba(255,255,255,0.6)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
|
||
</Text>
|
||
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
if (typeof refetch === "function") refetch();
|
||
}}
|
||
style={{
|
||
marginTop: 6,
|
||
backgroundColor: "rgba(255,255,255,0.15)",
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 18,
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<Text style={{ color: "white", fontWeight: "600" }}>Erneut versuchen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const noResults =
|
||
persons.length === 0 && seasonsByShowId.size === 0 && standaloneShows.length === 0;
|
||
|
||
return (
|
||
<View style={[styles.mainContainer]}>
|
||
<View style={styles.header}>
|
||
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
|
||
</View>
|
||
|
||
<TouchableWithoutFeedback
|
||
onPress={() => {
|
||
Keyboard.dismiss();
|
||
setShowSuggestions(false);
|
||
}}
|
||
>
|
||
<View style={{ flex: 1 }}>
|
||
<View style={styles.sectionContainer}>
|
||
<View style={styles.searchContainer}>
|
||
<TextInput
|
||
value={query}
|
||
autoComplete="off"
|
||
autoCorrect={false}
|
||
onChangeText={(t) => {
|
||
setQuery(t);
|
||
if (t.length > 0 && !showSuggestions) setShowSuggestions(true);
|
||
if (t.length === 0) setShowSuggestions(false);
|
||
}}
|
||
placeholder="Wonach suchst du?"
|
||
placeholderTextColor=""
|
||
style={styles.searchInput}
|
||
returnKeyType="search"
|
||
onFocus={() => setShowSuggestions(true)}
|
||
onSubmitEditing={() => {
|
||
if (!query.trim()) return;
|
||
tagAdded({ type: "CUSTOM", text: query.trim() });
|
||
}}
|
||
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("");
|
||
setShowSuggestions(false);
|
||
}}
|
||
/>
|
||
)}
|
||
</View>
|
||
|
||
<View style={styles.tagContainer}>
|
||
{tags.map((tag) => (
|
||
<TagChip
|
||
key={tag.text}
|
||
icon={getIconName(tag.type)}
|
||
label={tag.text}
|
||
onPress={() => {
|
||
tagRemoved(tag);
|
||
}}
|
||
/>
|
||
))}
|
||
</View>
|
||
|
||
{/* Suggestions dropdown */}
|
||
{query.length > 0 && showSuggestions && (
|
||
<View style={styles.suggestionsSection}>
|
||
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
|
||
<ScrollView keyboardShouldPersistTaps="handled">
|
||
{suggestions.map((suggestion, idx) => (
|
||
<TouchableOpacity
|
||
key={suggestion.text + "_" + idx}
|
||
style={styles.suggestionContainer}
|
||
onPress={() => tagAdded(suggestion)}
|
||
>
|
||
<FontAwesome
|
||
name={getIconName(suggestion.type)}
|
||
size={16}
|
||
color="hsl(0, 0%, 90%)"
|
||
/>
|
||
<Text style={styles.suggestionLabel}>
|
||
{suggestion.text}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
</ScrollView>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* Results */}
|
||
<View style={{ flex: 1 }}>
|
||
<ScrollView keyboardShouldPersistTaps="handled">
|
||
{/* Personen Section (top) */}
|
||
{persons.length > 0 && (
|
||
<View style={styles.sectionContainer}>
|
||
<Text style={styles.sectionTitle}>Personen</Text>
|
||
{persons.slice(0, 5).map((p) => (
|
||
<PersonRow key={`p-${p.personId ?? p.id}`} person={p} />
|
||
))}
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.sectionContainer}>
|
||
{(seasonsByShowId.size > 0 || standaloneShows.length > 0) && (
|
||
<Text style={styles.sectionTitle}>Staffeln & Shows</Text>
|
||
)}
|
||
|
||
{/* Carousels für Shows mit Staffeln */}
|
||
{Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => {
|
||
const show = showsById[Number(showId)];
|
||
if (!seasons || seasons.length === 0) return null;
|
||
|
||
if (!show) {
|
||
return (
|
||
<SeasonCarousel
|
||
key={`sc-${showId}`}
|
||
show={
|
||
{
|
||
showId: showId as any,
|
||
title: "blaaa",
|
||
description: "",
|
||
genre: "",
|
||
thumbnailUrl: "",
|
||
running: false,
|
||
} as any
|
||
}
|
||
seasons={seasons}
|
||
renderItem={(s) => (
|
||
<ShowBox
|
||
show={
|
||
{
|
||
showId: showId as any,
|
||
title: `Show #${showId}`,
|
||
description: "",
|
||
genre: "",
|
||
thumbnailUrl: "",
|
||
running: false,
|
||
} as any
|
||
}
|
||
displayedSeason={s}
|
||
shadow={false}
|
||
/>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
return (
|
||
<SeasonCarousel
|
||
key={`sc-${showId}`}
|
||
show={show}
|
||
seasons={seasons}
|
||
renderItem={(s) => (
|
||
<ShowBox show={show} displayedSeason={s} shadow={false} />
|
||
)}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{/* Einzelne Shows (ohne Staffeln) */}
|
||
{standaloneShows.map((show) => (
|
||
<View key={`show-${show.id}`} style={{ marginTop: 12 }}>
|
||
<ShowBox show={show} shadow={false} />
|
||
</View>
|
||
))}
|
||
|
||
{/* Schöner Empty-State */}
|
||
{noResults && (
|
||
<View
|
||
style={{
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
gap: 12,
|
||
backgroundColor: "rgba(255,255,255,0.04)",
|
||
paddingVertical: 28,
|
||
paddingHorizontal: 20,
|
||
borderRadius: 12,
|
||
marginTop: 8,
|
||
}}
|
||
>
|
||
<Feather
|
||
name="search"
|
||
size={36}
|
||
color="rgba(255,255,255,0.9)"
|
||
/>
|
||
<Text
|
||
style={{
|
||
fontSize: 18,
|
||
fontWeight: "600",
|
||
color: "white",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Keine Ergebnisse gefunden
|
||
</Text>
|
||
<Text
|
||
style={{
|
||
fontSize: 14,
|
||
color: "rgba(255,255,255,0.7)",
|
||
textAlign: "center",
|
||
}}
|
||
>
|
||
Passen Sie Ihre Tags an oder setzen Sie die Filter zurück.
|
||
</Text>
|
||
|
||
<View style={{ flexDirection: "row", gap: 10, marginTop: 4 }}>
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
setTags([]);
|
||
setQuery("");
|
||
setShowSuggestions(false);
|
||
Keyboard.dismiss();
|
||
if (typeof refetch === "function") refetch();
|
||
}}
|
||
style={{
|
||
backgroundColor: "rgba(255,255,255,0.15)",
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 14,
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<Text style={{ color: "white", fontWeight: "600" }}>
|
||
Filter zurücksetzen
|
||
</Text>
|
||
</TouchableOpacity>
|
||
|
||
<TouchableOpacity
|
||
onPress={() => {
|
||
setQuery("");
|
||
setShowSuggestions(false);
|
||
Keyboard.dismiss();
|
||
}}
|
||
style={{
|
||
backgroundColor: "rgba(255,255,255,0.08)",
|
||
paddingVertical: 10,
|
||
paddingHorizontal: 14,
|
||
borderRadius: 8,
|
||
}}
|
||
>
|
||
<Text style={{ color: "white" }}>Eingabe löschen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</ScrollView>
|
||
</View>
|
||
</View>
|
||
</TouchableWithoutFeedback>
|
||
</View>
|
||
);
|
||
}
|