Files
fltr-app/app/(tabs)/explore.tsx
DevOFVictory 9516642beb fixed search
2025-11-12 19:25:21 +01:00

469 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 &amp; 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>
);
}