search
This commit is contained in:
@@ -65,3 +65,36 @@ export async function getShows(): Promise<Show[]> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getShowById(showId: number): Promise<Show | null> {
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.EXPO_PUBLIC_API_KEY;
|
||||||
|
const response = await fetch(`${SHOW_API_URL}/${showId}`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-API-Key": apiKey ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Fetch error:", response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const s: RawShow = await response.json();
|
||||||
|
return {
|
||||||
|
id: s.showId,
|
||||||
|
title: s.title,
|
||||||
|
description: s.description,
|
||||||
|
genres: s.genre ? s.genre.split(",").map((g) => g.trim()) : [],
|
||||||
|
thumbnailUri: s.thumbnailUrl,
|
||||||
|
bannerUri: s.bannerUrl ?? "",
|
||||||
|
logoUri: s.logoUrl ?? "",
|
||||||
|
streamingService: s.streamingServices,
|
||||||
|
concept: s.concept,
|
||||||
|
running: s.running,
|
||||||
|
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch error:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,61 +1,116 @@
|
|||||||
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
||||||
import { getSearchResults, SearchResultItem } from "@/apis/searchApi";
|
import { getSearchResults, SearchResultItem } from "@/apis/searchApi";
|
||||||
import styles from "@/app/tabStyles/indexStyles";
|
import styles from "@/app/tabStyles/indexStyles";
|
||||||
|
import { Season, Show } from "@/app/types";
|
||||||
|
import ShowBox from "@/components/discovery/ShowBox";
|
||||||
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
|
import { useDiscoveryContext } from "@/contexts/DiscoveryContext";
|
||||||
import { FontAwesome } from "@expo/vector-icons";
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
import Feather from "@expo/vector-icons/Feather";
|
import Feather from "@expo/vector-icons/Feather";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Keyboard, ScrollView, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
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 { query, setQuery, suggestions } = useDiscoveryContext();
|
||||||
|
|
||||||
const [tags, setTags] = React.useState<AutoCompleteItem[]>([]);
|
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"]) => {
|
// --- helpers ---
|
||||||
switch (type) {
|
const tagStrings = React.useMemo(() => tags.map((t) => t.text), [tags]);
|
||||||
case "PERSON":
|
|
||||||
return "user";
|
|
||||||
case "SHOW":
|
|
||||||
return "television";
|
|
||||||
case "YEAR":
|
|
||||||
return "calendar";
|
|
||||||
default:
|
|
||||||
return "tag";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function tagAdded(tag: AutoCompleteItem) {
|
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);
|
setTags(nextTags);
|
||||||
|
|
||||||
const tagStrings = nextTags.map((t) => t.text);
|
const inputs = nextTags.map((t) => t.text);
|
||||||
|
getSearchResults(inputs, 50)
|
||||||
getSearchResults(tagStrings, 20)
|
.then((items) => {
|
||||||
.then(setSearchResults)
|
setResults(items || []);
|
||||||
|
})
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
|
|
||||||
setQuery("");
|
setQuery("");
|
||||||
Keyboard.dismiss();
|
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 (
|
return (
|
||||||
<View style={[styles.mainContainer]}>
|
<View style={[styles.mainContainer]}>
|
||||||
@@ -64,8 +119,7 @@ export default function TabTwoScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ paddingHorizontal: 10 }}>
|
<View style={{ paddingHorizontal: 10 }}>
|
||||||
|
{/* Search bar */}
|
||||||
|
|
||||||
<View style={styles.searchContainer}>
|
<View style={styles.searchContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={query}
|
value={query}
|
||||||
@@ -80,49 +134,33 @@ export default function TabTwoScreen() {
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
}}
|
}}
|
||||||
returnKeyType="search"
|
returnKeyType="search"
|
||||||
onSubmitEditing={() => console.log("Search:", query)}
|
onSubmitEditing={() => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
tagAdded({ type: "CUSTOM", text: query.trim() });
|
||||||
|
}}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{query.length === 0 ? (
|
{query.length === 0 ? (
|
||||||
<Feather name="search" size={24} color="hsl(221, 39%, 80%)" />
|
<Feather name="search" size={24} color="hsl(221, 39%, 80%)" />
|
||||||
) : (
|
) : (
|
||||||
<Feather
|
<Feather name="x" size={24} color="hsl(221, 39%, 80%)" onPress={() => setQuery("")} />
|
||||||
name="x"
|
|
||||||
size={24}
|
|
||||||
color="hsl(221, 39%, 80%)"
|
|
||||||
onPress={() => setQuery("")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Tag chips */}
|
||||||
<View style={styles.tagContainer}>
|
<View style={styles.tagContainer}>
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<TouchableOpacity
|
<TagChip
|
||||||
key={tag.text}
|
key={tag.text}
|
||||||
onPress={() =>
|
icon={getIconName(tag.type)}
|
||||||
setTags((prev) => prev.filter((t) => t.text !== tag.text))
|
label={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>
|
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Suggestions dropdown */}
|
||||||
{query.length > 0 && (
|
{query.length > 0 && (
|
||||||
<View style={styles.suggestionsSection}>
|
<View style={styles.suggestionsSection}>
|
||||||
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
|
<Text style={styles.suggestionTitle}>Suchvorschläge</Text>
|
||||||
@@ -131,65 +169,65 @@ export default function TabTwoScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={suggestion.text + "_" + idx}
|
key={suggestion.text + "_" + idx}
|
||||||
style={styles.suggestionContainer}
|
style={styles.suggestionContainer}
|
||||||
onPress={() => {
|
onPress={() => tagAdded(suggestion)}
|
||||||
tagAdded(suggestion);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FontAwesome
|
<FontAwesome name={getIconName(suggestion.type)} size={16} color="hsl(0, 0%, 90%)" />
|
||||||
name={getIconName(suggestion.type)}
|
|
||||||
size={16}
|
|
||||||
color="hsl(0, 0%, 90%)"
|
|
||||||
/>
|
|
||||||
<Text style={styles.suggestionLabel}>{suggestion.text}</Text>
|
<Text style={styles.suggestionLabel}>{suggestion.text}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ flex: 1 }} >
|
{/* Results */}
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
<ScrollView keyboardShouldPersistTaps="handled">
|
<ScrollView keyboardShouldPersistTaps="handled">
|
||||||
{searchResults.map((result: SearchResultItem, idx) => {
|
{/* Personen Section (top) */}
|
||||||
switch (result.type) {
|
{persons.length > 0 && (
|
||||||
case "PERSON":
|
<View style={{ width: "100%", paddingHorizontal: 10, marginBottom: 12 }}>
|
||||||
return (
|
<Text style={{ color: "white", fontSize: 18, fontWeight: "600", marginBottom: 6 }}>Personen</Text>
|
||||||
<View key={result.data.id + "_" + idx} style={styles.personRow}>
|
{persons.slice(0, 5).map((p) => (
|
||||||
<View style={styles.avatarCircle}>
|
<PersonRow key={`p-${p.personId ?? p.id}`} person={p} />
|
||||||
<FontAwesome name="user" size={22} color="#ccc" />
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Text */}
|
{/* Staffeln grouped by Show with page view */}
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ width: "100%", paddingHorizontal: 10 }}>
|
||||||
<Text style={styles.personName}>{result.data.name || "Unbekannt"} ({"25"})</Text>
|
<Text style={{ color: "white", fontSize: 18, fontWeight: "600", marginBottom: 6 }}>Staffeln</Text>
|
||||||
<Text style={styles.personMeta}>
|
|
||||||
aus: {"unterscheidlichen Shows"}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Chevron */}
|
{Array.from(seasonsByShowId.entries()).map(([showId, seasons]) => {
|
||||||
<FontAwesome name="chevron-right" size={14} color="#888" />
|
const show = showsById[Number(showId)];
|
||||||
</View>
|
if (!seasons || seasons.length === 0) return null;
|
||||||
);
|
// If show metadata is not yet loaded, render a minimal ShowBox fallback once per page item
|
||||||
case "SHOW":
|
if (!show) {
|
||||||
return (
|
return (
|
||||||
<View key={result.data.id + "_" + idx}>
|
<SeasonCarousel
|
||||||
<Text style={{ color: "skyblue" }}>
|
key={`sc-${showId}`}
|
||||||
{result.data.title} (Show)
|
show={{ showId: showId as any, title: "blaaa", description: "", genre: "", thumbnailUrl: "", running: false } as any}
|
||||||
</Text>
|
seasons={seasons}
|
||||||
</View>
|
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>
|
</ScrollView>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</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
|
||||||
|
};
|
||||||
19
components/discovery/GenreTag.tsx
Normal file
19
components/discovery/GenreTag.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { StyleSheet, Text, TextProps } from 'react-native';
|
||||||
|
|
||||||
|
export default function GenreTag(props: TextProps) {
|
||||||
|
return <Text {...props} style={[props.style, styles.genreTag]} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
genreTag: {
|
||||||
|
fontFamily: 'SpaceMono',
|
||||||
|
fontSize: 12,
|
||||||
|
paddingVertical: 4,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: '#333747',
|
||||||
|
color: '#fff',
|
||||||
|
textAlign: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
49
components/discovery/PersonRow.tsx
Normal file
49
components/discovery/PersonRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export type PersonLite = { id?: number; personId?: number; name?: string; birthDate?: string | null; imageUrl?: string | null };
|
||||||
|
|
||||||
|
const calcAge = (birthDate?: string | null): number | null => {
|
||||||
|
if (!birthDate) return null;
|
||||||
|
const d = new Date(birthDate);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - d.getFullYear();
|
||||||
|
const m = today.getMonth() - d.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && today.getDate() < d.getDate())) age--;
|
||||||
|
return age < 0 || age > 130 ? null : age;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PersonRow({ person }: { person: PersonLite }) {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const age = calcAge(person.birthDate);
|
||||||
|
const id = person.personId ?? person.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
// If your PersonDetail expects a Person object instead of an id, adapt this accordingly
|
||||||
|
// navigation.navigate("PersonDetail" as never, { personId: id } as never);
|
||||||
|
}}
|
||||||
|
style={styles.personRow}
|
||||||
|
>
|
||||||
|
<View style={styles.avatarCircle}>
|
||||||
|
<FontAwesome name="user" size={22} color="#ccc" />
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.personName}>{person.name || "Unbekannt"}{age != null ? ` (${age})` : ""}</Text>
|
||||||
|
<Text style={styles.personMeta}>aus: unterschiedlichen Shows</Text>
|
||||||
|
</View>
|
||||||
|
<FontAwesome name="chevron-right" size={14} color="#888" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
personRow: { width: "100%", flexDirection: "row", alignItems: "center", backgroundColor: "#1b1e2b", borderRadius: 10, paddingHorizontal: 10, paddingVertical: 10, marginBottom: 8 },
|
||||||
|
avatarCircle: { width: 40, height: 40, borderRadius: 999, backgroundColor: "#2a2f45", alignItems: "center", justifyContent: "center", marginRight: 10 },
|
||||||
|
personName: { color: "white", fontSize: 16, fontWeight: "600" },
|
||||||
|
personMeta: { color: "#bbb", fontSize: 12, marginTop: 2 },
|
||||||
|
});
|
||||||
94
components/discovery/SeasonCarousel.tsx
Normal file
94
components/discovery/SeasonCarousel.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Season, Show } from "@/app/types";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { Dimensions, FlatList, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, Pressable, StyleSheet, View } from "react-native";
|
||||||
|
|
||||||
|
const WINDOW_WIDTH = Dimensions.get("window").width;
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number) { return Math.max(min, Math.min(max, n)); }
|
||||||
|
|
||||||
|
export default function SeasonCarousel({
|
||||||
|
show,
|
||||||
|
seasons,
|
||||||
|
renderItem,
|
||||||
|
}: {
|
||||||
|
show: Show;
|
||||||
|
seasons: Season[];
|
||||||
|
renderItem: (season: Season) => React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [currentIndex, setCurrentIndex] = React.useState(0);
|
||||||
|
const [sliderWidth, setSliderWidth] = React.useState(Math.floor(WINDOW_WIDTH - 20));
|
||||||
|
const listRef = React.useRef<FlatList<Season> | null>(null);
|
||||||
|
|
||||||
|
const onLayout = (e: LayoutChangeEvent) => {
|
||||||
|
const w = Math.max(0, Math.floor(e.nativeEvent.layout.width));
|
||||||
|
if (w) setSliderWidth(w);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMomentumEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||||
|
const x = e.nativeEvent.contentOffset.x;
|
||||||
|
const index = clamp(Math.round(x / sliderWidth), 0, Math.max(0, seasons.length - 1));
|
||||||
|
setCurrentIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollTo = (target: number) => {
|
||||||
|
const ref = listRef.current;
|
||||||
|
if (!ref) return;
|
||||||
|
try { ref.scrollToIndex({ index: target, animated: true }); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goPrev = () => {
|
||||||
|
setCurrentIndex((curr) => { const next = clamp(curr - 1, 0, Math.max(0, seasons.length - 1)); if (next !== curr) setTimeout(() => scrollTo(next), 0); return next; });
|
||||||
|
};
|
||||||
|
const goNext = () => {
|
||||||
|
setCurrentIndex((curr) => { const next = clamp(curr + 1, 0, Math.max(0, seasons.length - 1)); if (next !== curr) setTimeout(() => scrollTo(next), 0); return next; });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 10, backgroundColor: "#1b1e2b", borderRadius: 10 }} onLayout={onLayout}>
|
||||||
|
<FlatList
|
||||||
|
ref={(r) => (listRef.current = r)}
|
||||||
|
data={seasons}
|
||||||
|
keyExtractor={(season, idx) => `${show.showId}-${(season as any)?.seasonId ?? `season-${idx}`}`}
|
||||||
|
horizontal
|
||||||
|
pagingEnabled
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
snapToAlignment="start"
|
||||||
|
decelerationRate="fast"
|
||||||
|
onMomentumScrollEnd={onMomentumEnd}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<View style={{ width: sliderWidth }}>
|
||||||
|
{renderItem(item)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{seasons.length > 1 && (
|
||||||
|
<View style={carouselStyles.controls}>
|
||||||
|
<Pressable onPress={goPrev} style={[carouselStyles.arrowButton, currentIndex <= 0 && carouselStyles.arrowDisabled]} disabled={currentIndex <= 0} hitSlop={8}>
|
||||||
|
<FontAwesome name="chevron-left" size={16} color="#bbb" />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={carouselStyles.dotsRow}>
|
||||||
|
{seasons.map((_, i) => (
|
||||||
|
<View key={`dot-${show.showId}-${i}`} style={[carouselStyles.dot, i === currentIndex && carouselStyles.dotActive]} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable onPress={goNext} style={[carouselStyles.arrowButton, currentIndex >= seasons.length - 1 && carouselStyles.arrowDisabled]} disabled={currentIndex >= seasons.length - 1} hitSlop={8}>
|
||||||
|
<FontAwesome name="chevron-right" size={16} color="#bbb" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const carouselStyles = StyleSheet.create({
|
||||||
|
controls: { paddingHorizontal: 8, width: "100%", flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
||||||
|
dotsRow: { flexDirection: "row", alignItems: "center" },
|
||||||
|
dot: { width: 6, height: 6, borderRadius: 999, backgroundColor: "#888", opacity: 0.4, marginHorizontal: 3 },
|
||||||
|
dotActive: { opacity: 1 },
|
||||||
|
arrowButton: { padding: 6, opacity: 0.9 },
|
||||||
|
arrowDisabled: { opacity: 0.3 },
|
||||||
|
});
|
||||||
123
components/discovery/ShowBox.tsx
Normal file
123
components/discovery/ShowBox.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Season, Show } from "@/app/types";
|
||||||
|
import GenreTag from "@/components/discovery/GenreTag";
|
||||||
|
import { useNavigation } from "@react-navigation/native";
|
||||||
|
import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function ShowBox({
|
||||||
|
show,
|
||||||
|
displayedSeason,
|
||||||
|
shadow = true,
|
||||||
|
}: {
|
||||||
|
show: Show;
|
||||||
|
displayedSeason?: Season;
|
||||||
|
shadow?: boolean;
|
||||||
|
}) {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => navigation.navigate("ShowDetail", { show })}
|
||||||
|
style={
|
||||||
|
!shadow
|
||||||
|
? [styles.showContainer, { backgroundColor: "#1b1e2b", paddingBottom: 0 }]
|
||||||
|
: [styles.showContainer, styles.shadow, { backgroundColor: "#1b1e2b" }]
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View style={styles.showImageContainer}>
|
||||||
|
<Image source={{ uri: show.thumbnailUrl }} style={styles.showImage} />
|
||||||
|
{show.running && <Text style={styles.runningTag}>LIVE</Text>}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.showRight}>
|
||||||
|
<Text style={styles.showTitle}>{show.title}</Text>
|
||||||
|
|
||||||
|
{displayedSeason ? (
|
||||||
|
<Text style={{ fontWeight: "bold", color: "#aac0ce" }}>
|
||||||
|
Staffel {displayedSeason.seasonNumber} (
|
||||||
|
{new Date(displayedSeason.startDate).getFullYear()})
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Text style={styles.showDescription} numberOfLines={8} ellipsizeMode="tail">
|
||||||
|
{show.description}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.showGenreTagContainer}>
|
||||||
|
{show.genre.split(", ").map((genre: any) => (
|
||||||
|
<GenreTag key={genre}>{genre}</GenreTag>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
showTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "left",
|
||||||
|
color: "#ffffff",
|
||||||
|
},
|
||||||
|
showDescription: {
|
||||||
|
marginTop: 5,
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "left",
|
||||||
|
flex: 1,
|
||||||
|
color: "#cccccc",
|
||||||
|
},
|
||||||
|
showContainer: {
|
||||||
|
width: "100%",
|
||||||
|
height: 220,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
backgroundColor: "#1b1e2b",
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.25,
|
||||||
|
shadowRadius: 3.84,
|
||||||
|
elevation: 5,
|
||||||
|
},
|
||||||
|
showImageContainer: {
|
||||||
|
width: 140,
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "#2b2e3b",
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
showImage: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
showRight: {
|
||||||
|
flex: 1,
|
||||||
|
height: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "#1b1e2b",
|
||||||
|
paddingLeft: 10,
|
||||||
|
paddingVertical: 2,
|
||||||
|
},
|
||||||
|
showGenreTagContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 5,
|
||||||
|
marginTop: 2,
|
||||||
|
backgroundColor: "#1b1e2b",
|
||||||
|
},
|
||||||
|
runningTag: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 3,
|
||||||
|
left: 3,
|
||||||
|
backgroundColor: "red",
|
||||||
|
opacity: 0.65,
|
||||||
|
color: "white",
|
||||||
|
padding: 5,
|
||||||
|
borderRadius: 90,
|
||||||
|
},
|
||||||
|
});
|
||||||
20
components/discovery/TagChip.tsx
Normal file
20
components/discovery/TagChip.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
import React from "react";
|
||||||
|
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||||
|
|
||||||
|
export default function TagChip({ icon, label, onPress }: { icon: any; label: string; onPress: () => void }) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={onPress}>
|
||||||
|
<View style={styles.tag}>
|
||||||
|
<FontAwesome name={icon} size={16} color="#bbb" style={{ marginRight: 6 }} />
|
||||||
|
<Text style={styles.tagLabel}>{label}</Text>
|
||||||
|
<FontAwesome name="times-circle" size={16} color="#bbb" style={{ marginLeft: 6 }} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tag: { flexDirection: "row", alignItems: "center", backgroundColor: "#333", borderRadius: 999, paddingHorizontal: 10, paddingVertical: 6, marginRight: 8, marginBottom: 8 },
|
||||||
|
tagLabel: { color: "white" },
|
||||||
|
});
|
||||||
50
utils/searchMapping.ts
Normal file
50
utils/searchMapping.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { AutoCompleteItem } from "@/apis/autoCompleteApi";
|
||||||
|
|
||||||
|
export const getIconName = (type: AutoCompleteItem["type"]) => {
|
||||||
|
switch (type) {
|
||||||
|
case "PERSON":
|
||||||
|
return "user";
|
||||||
|
case "SHOW":
|
||||||
|
return "television";
|
||||||
|
case "YEAR":
|
||||||
|
case "CUSTOM":
|
||||||
|
return "calendar";
|
||||||
|
default:
|
||||||
|
return "tag";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helpers that adapt backend searchApi payloads (as in the prompt) to UI types used in our components
|
||||||
|
export function mapApiPersonToUI(data: any) {
|
||||||
|
return {
|
||||||
|
id: data?.id ?? data?.personId,
|
||||||
|
personId: data?.personId ?? data?.id,
|
||||||
|
name: data?.name ?? "",
|
||||||
|
birthDate: data?.birthDate ?? null,
|
||||||
|
imageUrl: data?.imageUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiSeasonToUI(data: any) {
|
||||||
|
return {
|
||||||
|
seasonId: data?.seasonId ?? data?.id,
|
||||||
|
showId: data?.showId,
|
||||||
|
startDate: data?.startDate ?? null,
|
||||||
|
endDate: data?.endDate ?? null,
|
||||||
|
seasonNumber: data?.seasonNumber ?? null,
|
||||||
|
participants: data?.seasonParticipants ?? data?.participants ?? [],
|
||||||
|
moderators: data?.moderators ?? [],
|
||||||
|
teams: data?.teams ?? [],
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapApiShowToUI(data: any) {
|
||||||
|
return {
|
||||||
|
showId: data?.showId ?? data?.id,
|
||||||
|
title: data?.title ?? data?.name ?? `Show #${data?.showId ?? data?.id ?? "?"}`,
|
||||||
|
description: data?.description ?? "",
|
||||||
|
genre: data?.genre ?? "",
|
||||||
|
thumbnailUrl: data?.thumbnailUrl ?? data?.imageUrl ?? "",
|
||||||
|
running: data?.running ?? false,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user