580 lines
16 KiB
TypeScript
580 lines
16 KiB
TypeScript
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
|
import { Season } from "@/apis/seasonApi";
|
|
import { Show } from "@/apis/showApi";
|
|
import { Colors } from "@/constants/colors";
|
|
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
|
|
import Feather from "@expo/vector-icons/Feather";
|
|
import { Stack } from "expo-router";
|
|
import React from "react";
|
|
import {
|
|
Dimensions,
|
|
Image,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
} from "react-native";
|
|
|
|
import { getShowById } from "@/apis/showApi";
|
|
import PersonRow from "@/components/discovery/PersonRow";
|
|
import TagChip from "@/components/discovery/TagChip";
|
|
import { useSearch } from "@/hooks/useSearch";
|
|
import {
|
|
getIconName,
|
|
mapApiPersonToUI,
|
|
mapApiSeasonToUI,
|
|
mapApiShowToUI,
|
|
} from "@/utils/searchMapping";
|
|
import { router } from "expo-router";
|
|
|
|
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);
|
|
}
|
|
|
|
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: alle Shows (direkte Treffer + aus Seasons) vereint
|
|
const unifiedShows = React.useMemo(() => {
|
|
const map = new Map<number, { show: Show; seasons: Season[] }>();
|
|
|
|
// Shows aus Season-Ergebnissen
|
|
for (const [showId, seasons] of seasonsByShowId) {
|
|
const show = showsById[Number(showId)];
|
|
if (show) {
|
|
map.set(Number(showId), { show, seasons });
|
|
}
|
|
}
|
|
|
|
// Direkte Show-Treffer (ohne Seasons)
|
|
for (const r of results) {
|
|
if (r.type !== "SHOW") continue;
|
|
const uiShow = mapApiShowToUI(r.data);
|
|
const id = Number(uiShow.id);
|
|
if (!map.has(id)) {
|
|
const cachedShow = showsById[id];
|
|
map.set(id, { show: cachedShow ?? uiShow, seasons: [] });
|
|
}
|
|
}
|
|
|
|
return Array.from(map.values());
|
|
}, [results, seasonsByShowId, showsById]);
|
|
|
|
// Fehlerblock
|
|
if (error) {
|
|
return (
|
|
<View
|
|
style={[
|
|
s.container,
|
|
{ justifyContent: "center", alignItems: "center", padding: 20 },
|
|
]}
|
|
>
|
|
<View style={s.emptyState}>
|
|
<Feather
|
|
name="alert-triangle"
|
|
size={44}
|
|
color="rgba(255,255,255,0.2)"
|
|
/>
|
|
<Text style={s.emptyTitle}>Fehler beim Laden</Text>
|
|
<Text style={s.emptySubtitle}>
|
|
{error?.message || "Ein unerwarteter Fehler ist aufgetreten."}
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
if (typeof refetch === "function") refetch();
|
|
}}
|
|
style={s.emptyButton}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={s.emptyButtonText}>Erneut versuchen</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const noResults = persons.length === 0 && unifiedShows.length === 0;
|
|
|
|
return (
|
|
<View style={s.container}>
|
|
<Stack.Screen
|
|
options={{
|
|
headerSearchBarOptions: {
|
|
placeholder: "Wonach suchst du?",
|
|
hideWhenScrolling: false,
|
|
autoCapitalize: "none",
|
|
onChangeText: (e) => {
|
|
const text = e.nativeEvent.text;
|
|
setQuery(text);
|
|
setShowSuggestions(text.length > 0);
|
|
},
|
|
onSearchButtonPress: (e) => {
|
|
const text = e.nativeEvent.text;
|
|
if (!text?.trim()) return;
|
|
tagAdded({ type: "CUSTOM", text: text.trim() });
|
|
},
|
|
onCancelButtonPress: () => {
|
|
setQuery("");
|
|
setShowSuggestions(false);
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
|
|
{/* Suggestions overlay */}
|
|
{query.length > 0 && showSuggestions && suggestions.length > 0 && (
|
|
<View style={s.suggestionsOverlay}>
|
|
<ScrollView
|
|
keyboardShouldPersistTaps="handled"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<Text style={s.suggestionsHeader}>VORSCHLÄGE</Text>
|
|
<View style={s.suggestionsGroup}>
|
|
{suggestions.slice(0, 10).map((suggestion, idx, arr) => (
|
|
<TouchableOpacity
|
|
key={suggestion.text + "_" + idx}
|
|
style={[
|
|
s.suggestionRow,
|
|
idx === 0 && {
|
|
borderTopLeftRadius: 10,
|
|
borderTopRightRadius: 10,
|
|
},
|
|
idx === arr.length - 1 && {
|
|
borderBottomLeftRadius: 10,
|
|
borderBottomRightRadius: 10,
|
|
borderBottomWidth: 0,
|
|
},
|
|
]}
|
|
onPress={() => tagAdded(suggestion)}
|
|
activeOpacity={0.6}
|
|
>
|
|
<Feather
|
|
name={
|
|
suggestion.type === "PERSON"
|
|
? "user"
|
|
: suggestion.type === "SHOW"
|
|
? "tv"
|
|
: "tag"
|
|
}
|
|
size={14}
|
|
color="rgba(255,255,255,0.5)"
|
|
/>
|
|
<Text style={s.suggestionText}>{suggestion.text}</Text>
|
|
<Feather
|
|
name="arrow-up-left"
|
|
size={12}
|
|
color="rgba(255,255,255,0.3)"
|
|
/>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
<ScrollView
|
|
contentInsetAdjustmentBehavior="automatic"
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
{/* Tag chips */}
|
|
{tags.length > 0 && (
|
|
<View style={s.section}>
|
|
<View style={s.tagRow}>
|
|
{tags.map((tag) => (
|
|
<TagChip
|
|
key={tag.text}
|
|
icon={getIconName(tag.type)}
|
|
label={tag.text}
|
|
onPress={() => tagRemoved(tag)}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Personen Section */}
|
|
{persons.length > 0 && (
|
|
<View style={s.section}>
|
|
<Text style={s.sectionHeader}>PERSONEN</Text>
|
|
<View style={s.groupedCard}>
|
|
{persons.slice(0, 5).map((p, i, arr) => (
|
|
<PersonRow
|
|
key={`p-${p.personId ?? p.id}`}
|
|
person={p}
|
|
isFirst={i === 0}
|
|
isLast={i === arr.length - 1}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Shows + Staffeln unified */}
|
|
{unifiedShows.length > 0 && (
|
|
<View style={s.section}>
|
|
<Text style={s.sectionHeader}>SHOWS</Text>
|
|
{unifiedShows.map(({ show, seasons }) => (
|
|
<TouchableOpacity
|
|
key={`show-${show.id}`}
|
|
style={s.showCard}
|
|
activeOpacity={0.7}
|
|
onPress={() =>
|
|
router.push({
|
|
pathname: "/showDetails",
|
|
params: { id: String(show.id) },
|
|
})
|
|
}
|
|
>
|
|
<Image
|
|
source={{
|
|
uri: show.bannerUri || show.thumbnailUri,
|
|
}}
|
|
style={s.showCardImage}
|
|
resizeMode="cover"
|
|
/>
|
|
<View style={s.showCardBody}>
|
|
<View style={s.showCardHeader}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={s.showCardTitle} numberOfLines={1}>
|
|
{show.title}
|
|
</Text>
|
|
{show.description ? (
|
|
<Text style={s.showCardDescription} numberOfLines={2}>
|
|
{show.description}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
{show.running && (
|
|
<View style={s.liveBadge}>
|
|
<Text style={s.liveBadgeText}>LIVE</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
{seasons.length > 0 && (
|
|
<View style={s.seasonRow}>
|
|
{seasons.map((season) => (
|
|
<View
|
|
key={(season as any).seasonId}
|
|
style={s.seasonPill}
|
|
>
|
|
<Text style={s.seasonPillText}>
|
|
S{(season as any).seasonNumber}
|
|
{(season as any).startDate
|
|
? ` · ${new Date((season as any).startDate).getFullYear()}`
|
|
: ""}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
|
|
{/* Empty State */}
|
|
{noResults && (
|
|
<View style={s.section}>
|
|
<View style={s.emptyState}>
|
|
<Feather name="search" size={44} color="rgba(255,255,255,0.2)" />
|
|
<Text style={s.emptyTitle}>Keine Ergebnisse</Text>
|
|
<Text style={s.emptySubtitle}>
|
|
Passe deine Tags an oder setze die Filter zurück.
|
|
</Text>
|
|
|
|
{tags.length > 0 && (
|
|
<TouchableOpacity
|
|
onPress={() => {
|
|
setTags([]);
|
|
setQuery("");
|
|
setShowSuggestions(false);
|
|
if (typeof refetch === "function") refetch();
|
|
}}
|
|
style={s.emptyButton}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Text style={s.emptyButtonText}>Filter zurücksetzen</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const s = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: Colors.background,
|
|
},
|
|
section: {
|
|
paddingHorizontal: 16,
|
|
marginBottom: 20,
|
|
paddingTop: 20,
|
|
},
|
|
sectionHeader: {
|
|
color: "rgba(255,255,255,0.45)",
|
|
fontSize: 13,
|
|
fontWeight: "600",
|
|
letterSpacing: 0.5,
|
|
marginBottom: 8,
|
|
paddingLeft: 4,
|
|
},
|
|
groupedCard: {
|
|
borderRadius: 10,
|
|
overflow: "hidden",
|
|
},
|
|
tagRow: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
},
|
|
// Suggestions overlay
|
|
suggestionsOverlay: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
zIndex: 10,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 6,
|
|
backgroundColor: Colors.background,
|
|
},
|
|
suggestionsHeader: {
|
|
color: "rgba(255,255,255,0.45)",
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
letterSpacing: 0.5,
|
|
marginBottom: 6,
|
|
paddingLeft: 4,
|
|
marginTop: Dimensions.get("screen").height / 7,
|
|
},
|
|
suggestionsGroup: {
|
|
borderRadius: 10,
|
|
overflow: "hidden",
|
|
},
|
|
suggestionRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
backgroundColor: "rgba(255,255,255,0.06)",
|
|
paddingHorizontal: 14,
|
|
paddingVertical: 10,
|
|
gap: 10,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: "rgba(255,255,255,0.08)",
|
|
},
|
|
suggestionText: {
|
|
color: "white",
|
|
fontSize: 15,
|
|
flex: 1,
|
|
},
|
|
// Empty state
|
|
emptyState: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
paddingVertical: 48,
|
|
paddingHorizontal: 32,
|
|
gap: 8,
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 20,
|
|
fontWeight: "600",
|
|
color: "rgba(255,255,255,0.8)",
|
|
marginTop: 8,
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: 15,
|
|
color: "rgba(255,255,255,0.45)",
|
|
textAlign: "center",
|
|
lineHeight: 20,
|
|
},
|
|
emptyButton: {
|
|
marginTop: 16,
|
|
backgroundColor: "rgba(255,255,255,0.1)",
|
|
paddingVertical: 10,
|
|
paddingHorizontal: 20,
|
|
borderRadius: 10,
|
|
},
|
|
emptyButtonText: {
|
|
color: Colors.primary,
|
|
fontSize: 15,
|
|
fontWeight: "600",
|
|
},
|
|
// Unified show cards
|
|
showCard: {
|
|
borderRadius: 16,
|
|
overflow: "hidden",
|
|
backgroundColor: "rgba(255,255,255,0.06)",
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: "rgba(255,255,255,0.08)",
|
|
marginBottom: 12,
|
|
},
|
|
showCardImage: {
|
|
width: "100%",
|
|
height: 160,
|
|
},
|
|
showCardBody: {
|
|
padding: 14,
|
|
gap: 10,
|
|
},
|
|
showCardHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "flex-start",
|
|
gap: 10,
|
|
},
|
|
showCardTitle: {
|
|
color: "#fff",
|
|
fontSize: 17,
|
|
fontWeight: "700",
|
|
letterSpacing: 0.1,
|
|
},
|
|
showCardDescription: {
|
|
color: "rgba(255,255,255,0.45)",
|
|
fontSize: 13,
|
|
lineHeight: 18,
|
|
marginTop: 4,
|
|
},
|
|
liveBadge: {
|
|
backgroundColor: "rgba(255,59,48,0.2)",
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: "rgba(255,59,48,0.4)",
|
|
paddingHorizontal: 8,
|
|
paddingVertical: 3,
|
|
borderRadius: 10,
|
|
},
|
|
liveBadgeText: {
|
|
color: "#ff3b30",
|
|
fontSize: 10,
|
|
fontWeight: "700",
|
|
letterSpacing: 0.5,
|
|
},
|
|
seasonRow: {
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: 6,
|
|
},
|
|
seasonPill: {
|
|
paddingVertical: 5,
|
|
paddingHorizontal: 12,
|
|
borderRadius: 14,
|
|
backgroundColor: "rgba(25,158,219,0.15)",
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: "rgba(25,158,219,0.25)",
|
|
},
|
|
seasonPillText: {
|
|
color: "#199edb",
|
|
fontSize: 12,
|
|
fontWeight: "600",
|
|
},
|
|
});
|