search
This commit is contained in:
@@ -1,71 +1,125 @@
|
||||
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
||||
import { getSearchResults, SearchResultItem } from "@/apis/searchApi";
|
||||
import styles from "@/app/tabStyles/indexStyles";
|
||||
import { Season, Show } from "@/app/types";
|
||||
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,
|
||||
View
|
||||
} from "react-native";
|
||||
import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity, 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 { getIconName, mapApiPersonToUI, mapApiSeasonToUI, mapApiShowToUI } from "@/utils/searchMapping";
|
||||
|
||||
export default function TabTwoScreen() {
|
||||
export default function ExploreScreen() {
|
||||
const { query, setQuery, suggestions } = useDiscoveryContext();
|
||||
|
||||
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
|
||||
const [results, setResults] = React.useState<SearchResultItem[]>([]);
|
||||
|
||||
const [searchResults, setSearchResults] = React.useState<SearchResultItem[]>([]);
|
||||
// Show metadata cache by id (filled from SHOW results and lazy-loaded by id)
|
||||
const [showsById, setShowsById] = React.useState<Record<number, Show>>({});
|
||||
|
||||
const getIconName = (type: AutoCompleteItem["type"]) => {
|
||||
switch (type) {
|
||||
case "PERSON":
|
||||
return "user";
|
||||
case "SHOW":
|
||||
return "television";
|
||||
case "YEAR":
|
||||
return "calendar";
|
||||
default:
|
||||
return "tag";
|
||||
}
|
||||
};
|
||||
// --- helpers ---
|
||||
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
|
||||
|
||||
function tagAdded(tag: AutoCompleteItem) {
|
||||
console.log("Tag added:", tag);
|
||||
const nextTags = tags.some((t) => t.text === tag.text)
|
||||
? tags
|
||||
: [...tags, tag];
|
||||
|
||||
const nextTags = tags.some((t) => t.text === tag.text) ? tags : [...tags, tag];
|
||||
setTags(nextTags);
|
||||
|
||||
const tagStrings = nextTags.map((t) => t.text);
|
||||
|
||||
getSearchResults(tagStrings, 20)
|
||||
.then(setSearchResults)
|
||||
const inputs = nextTags.map((t) => t.text);
|
||||
getSearchResults(inputs, 50)
|
||||
.then((items) => {
|
||||
setResults(items || []);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
setQuery("");
|
||||
Keyboard.dismiss();
|
||||
|
||||
console.log("Searching with tags:", tagStrings);
|
||||
}
|
||||
|
||||
// Keep our local show cache in sync with SHOW items returned by search
|
||||
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]);
|
||||
|
||||
// Group SEASON results by showId
|
||||
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);
|
||||
}
|
||||
// sort seasons per show by startDate asc
|
||||
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]);
|
||||
|
||||
// Lazy fetch missing shows needed for Season carousels
|
||||
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[Number((s as any).showId)] = s as Show;
|
||||
}
|
||||
if (Object.keys(next).length) setShowsById((prev) => ({ ...prev, ...next }));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [seasonsByShowId, showsById]);
|
||||
|
||||
// PERSON hits shown at the top (like old screen)
|
||||
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]);
|
||||
|
||||
return (
|
||||
<View style={[styles.mainContainer]}>
|
||||
<View style={[styles.mainContainer]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { fontSize: 28 }]}>Durchsuchen</Text>
|
||||
</View>
|
||||
|
||||
<View style={{ paddingHorizontal: 10 }}>
|
||||
|
||||
|
||||
{/* Search bar */}
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
value={query}
|
||||
@@ -80,49 +134,33 @@ export default function TabTwoScreen() {
|
||||
height: "100%",
|
||||
}}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={() => console.log("Search:", query)}
|
||||
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("")}
|
||||
/>
|
||||
<Feather name="x" size={24} color="hsl(221, 39%, 80%)" onPress={() => setQuery("")} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Tag chips */}
|
||||
<View style={styles.tagContainer}>
|
||||
{tags.map((tag) => (
|
||||
<TouchableOpacity
|
||||
<TagChip
|
||||
key={tag.text}
|
||||
onPress={() =>
|
||||
setTags((prev) => prev.filter((t) => t.text !== tag.text))
|
||||
}
|
||||
>
|
||||
<View style={styles.tag}>
|
||||
<FontAwesome
|
||||
name={getIconName(tag.type)}
|
||||
size={16}
|
||||
color="#bbb"
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text style={styles.tagLabel}>{tag.text}</Text>
|
||||
<FontAwesome
|
||||
name="times-circle"
|
||||
size={16}
|
||||
color="#bbb"
|
||||
style={{ marginLeft: 6 }}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
icon={getIconName(tag.type)}
|
||||
label={tag.text}
|
||||
onPress={() => setTags((prev) => prev.filter((t) => t.text !== tag.text))}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Suggestions dropdown */}
|
||||
{query.length > 0 && (
|
||||
<View style={styles.suggestionsSection}>
|
||||
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
|
||||
@@ -131,65 +169,65 @@ export default function TabTwoScreen() {
|
||||
<TouchableOpacity
|
||||
key={suggestion.text + "_" + idx}
|
||||
style={styles.suggestionContainer}
|
||||
onPress={() => {
|
||||
tagAdded(suggestion);
|
||||
}}
|
||||
onPress={() => tagAdded(suggestion)}
|
||||
>
|
||||
<FontAwesome
|
||||
name={getIconName(suggestion.type)}
|
||||
size={16}
|
||||
color="hsl(0, 0%, 90%)"
|
||||
/>
|
||||
<FontAwesome name={getIconName(suggestion.type)} size={16} color="hsl(0, 0%, 90%)" />
|
||||
<Text style={styles.suggestionLabel}>{suggestion.text}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
</ScrollView>
|
||||
|
||||
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ flex: 1 }} >
|
||||
|
||||
{/* Results */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView keyboardShouldPersistTaps="handled">
|
||||
{searchResults.map((result: SearchResultItem, idx) => {
|
||||
switch (result.type) {
|
||||
case "PERSON":
|
||||
return (
|
||||
<View key={result.data.id + "_" + idx} style={styles.personRow}>
|
||||
<View style={styles.avatarCircle}>
|
||||
<FontAwesome name="user" size={22} color="#ccc" />
|
||||
</View>
|
||||
{/* Personen Section (top) */}
|
||||
{persons.length > 0 && (
|
||||
<View style={{ width: "100%", paddingHorizontal: 10, marginBottom: 12 }}>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600", marginBottom: 6 }}>Personen</Text>
|
||||
{persons.slice(0, 5).map((p) => (
|
||||
<PersonRow key={`p-${p.personId ?? p.id}`} person={p} />
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.personName}>{result.data.name || "Unbekannt"} ({"25"})</Text>
|
||||
<Text style={styles.personMeta}>
|
||||
aus: {"unterscheidlichen Shows"}
|
||||
</Text>
|
||||
</View>
|
||||
{/* Staffeln grouped by Show with page view */}
|
||||
<View style={{ width: "100%", paddingHorizontal: 10 }}>
|
||||
<Text style={{ color: "white", fontSize: 18, fontWeight: "600", marginBottom: 6 }}>Staffeln</Text>
|
||||
|
||||
{/* Chevron */}
|
||||
<FontAwesome name="chevron-right" size={14} color="#888" />
|
||||
</View>
|
||||
);
|
||||
case "SHOW":
|
||||
{Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => {
|
||||
const show = showsById[Number(showId)];
|
||||
if (!seasons || seasons.length === 0) return null;
|
||||
// If show metadata is not yet loaded, render a minimal ShowBox fallback once per page item
|
||||
if (!show) {
|
||||
return (
|
||||
<View key={result.data.id + "_" + idx}>
|
||||
<Text style={{ color: "skyblue" }}>
|
||||
{result.data.title} (Show)
|
||||
</Text>
|
||||
</View>
|
||||
<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} />}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
}
|
||||
return (
|
||||
<SeasonCarousel
|
||||
key={`sc-${showId}`}
|
||||
show={show}
|
||||
seasons={seasons}
|
||||
renderItem={(s) => <ShowBox show={show} displayedSeason={s} shadow={false} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{seasonsByShowId.size === 0 && (
|
||||
<Text style={{ color: "white", fontSize: 16, textAlign: "center", marginTop: 14 }}>
|
||||
Keine Staffeln gefunden. Passe deine Tags an.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
32
app/types.ts
Normal file
32
app/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export type Person = {
|
||||
personId: number;
|
||||
name: string;
|
||||
birthDate?: string | null;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type Season = {
|
||||
seasonId: number;
|
||||
showId: number;
|
||||
startDate?: string | null;
|
||||
endDate?: string | null;
|
||||
seasonNumber?: number | null;
|
||||
participants?: Person[];
|
||||
moderators?: Person[];
|
||||
};
|
||||
|
||||
|
||||
export type Show = {
|
||||
showId: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
genre?: string;
|
||||
thumbnailUrl: string;
|
||||
logoUrl?: string;
|
||||
bannerUrl?: string;
|
||||
running?: boolean;
|
||||
streamingServices?: string;
|
||||
concept?: string
|
||||
};
|
||||
Reference in New Issue
Block a user