final: added personHistory

This commit is contained in:
Cron1cle
2025-10-23 03:17:46 +02:00
parent 73d883ae29
commit d40b90de41
7 changed files with 603 additions and 97 deletions

View File

@@ -176,7 +176,7 @@ export default function HomeScreen() {
concept: show.concept,
genres: show.genres,
streamingService: show.streamingService,
logoUri: show.logoUri,
logoUri: show.logoUrl,
running: String(show.running),
},
})

View File

@@ -22,7 +22,6 @@ export default function RootLayout() {
<Stack.Screen
name="participant"
options={{
presentation: "fullScreenModal",
headerShown: false,
}}
/>

View File

@@ -2,56 +2,188 @@ import styles from "@/app/stackStyles/participantStyles";
import { useShowContext } from "@/contexts/ShowContext";
import Ionicons from "@expo/vector-icons/Ionicons";
import { router, useLocalSearchParams } from "expo-router";
import React, { useMemo, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import React from "react";
import { Text, TouchableOpacity, View, Image, Dimensions } from "react-native";
import {
getPersonHistory,
type PersonMini,
type PersonHistoryRecord,
} from "@/apis/personHistoryApi";
import { getShowById } from "@/apis/showApi";
import {
GestureHandlerRootView,
ScrollView,
} from "react-native-gesture-handler";
export default function ParticipantScreen() {
const [appearances] = useState<
{
showId: number;
seasons: number[];
}[]
>([]);
const { shows } = useShowContext();
const { name } = useLocalSearchParams();
type SeasonEntry = {
seasonNumber: number;
partner: PersonMini | null;
participants: PersonMini[];
startDate: string | null;
};
const resolved = useMemo(
() =>
(appearances as any[])
.map((a) => {
const show = shows.find((s) => s.id === a.showId);
if (!show) return null;
type AppearanceGroup = {
show: {
id: number;
title: string;
bannerUri: string;
thumbnailUri: string;
};
seasons: SeasonEntry[];
};
export default function ParticipantScreen() {
const { shows } = useShowContext();
const { name, participantId } = useLocalSearchParams();
const pid = Array.isArray(participantId)
? Number(participantId[0])
: Number(participantId);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [appearances, setAppearances] = React.useState<AppearanceGroup[]>([]);
const formatYear = (iso?: string | null) => {
if (!iso) return null;
const [y] = iso.split("-");
return y || null;
};
React.useEffect(() => {
if (!pid || Number.isNaN(pid)) return;
const controller = new AbortController();
setLoading(true);
setError(null);
(async () => {
try {
const hist = await getPersonHistory(pid, controller.signal);
const grouped = new Map<number, Map<number, SeasonEntry>>();
for (const h of hist) {
if (!Number.isFinite(h.showId) || h.showId <= 0) continue;
const seasonsForShow =
grouped.get(h.showId) ?? new Map<number, SeasonEntry>();
const existing = seasonsForShow.get(h.seasonNumber);
if (existing) {
seasonsForShow.set(h.seasonNumber, {
seasonNumber: h.seasonNumber,
partner: existing.partner ?? h.partner ?? null,
participants: existing.participants.length
? existing.participants
: (h.seasonParticipants ?? []),
startDate: existing.startDate ?? h.startDate ?? null,
});
} else {
seasonsForShow.set(h.seasonNumber, {
seasonNumber: h.seasonNumber,
partner: h.partner ?? null,
participants: h.seasonParticipants ?? [],
startDate: h.startDate ?? null,
});
}
grouped.set(h.showId, seasonsForShow);
}
const showIds = Array.from(grouped.keys());
const fromContext = showIds
.map((id) => shows.find((s) => s.id === id))
.filter((s): s is (typeof shows)[number] => !!s);
const missingIds = showIds.filter(
(id) => !fromContext.some((s) => s.id === id)
);
const fetched = await Promise.all(
missingIds.map(async (id) => {
try {
const s = await getShowById(id);
return s;
} catch {
return null;
}
})
);
const allShows = [
...fromContext,
...fetched.filter(Boolean),
] as typeof shows;
const result: AppearanceGroup[] = allShows.map((s) => {
const seasonsMap = grouped.get(s.id)!;
const seasonsSorted = Array.from(seasonsMap.values()).sort(
(a, b) => a.seasonNumber - b.seasonNumber
);
return {
show,
seasons: a.seasons as number[],
partners: a.partners as {
seasonNumber: number;
partner?: { id: number; name: string; imageUrl?: string | null };
}[],
show: {
id: s.id,
title: s.title,
bannerUri: s.bannerUri,
thumbnailUri: s.thumbnailUri,
},
seasons: seasonsSorted,
};
})
.filter(
(
v
): v is {
show: (typeof shows)[number];
seasons: number[];
partners: {
seasonNumber: number;
partner?: { id: number; name: string; imageUrl?: string | null };
}[];
} => !!v
),
[appearances, shows]
});
result.sort((a, b) =>
a.show.title.localeCompare(b.show.title, "de", {
sensitivity: "base",
})
);
setAppearances(result);
} catch (e: any) {
if (!controller.signal.aborted)
setError(e?.message || "Fehler beim Laden");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, [pid, shows]);
const [expandedShows, setExpandedShows] = React.useState<Set<number>>(
new Set()
);
const toggleExpand = React.useCallback((showId: number) => {
setExpandedShows((prev) => {
const next = new Set(prev);
if (next.has(showId)) next.delete(showId);
else next.add(showId);
return next;
});
}, []);
const goToShow = React.useCallback((id: number) => {
router.push({ pathname: "/showDetails", params: { id: String(id) } });
}, []);
const goToPerson = React.useCallback(
(p: PersonMini) => {
if (!p?.personId) return;
if (p.personId === pid) return;
router.push({
pathname: "/participant",
params: { participantId: String(p.personId), name: p.name },
});
},
[pid]
);
return (
<GestureHandlerRootView style={styles.mainContainer}>
<ScrollView showsVerticalScrollIndicator={false}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 20, paddingTop: 10 }}
>
<Text style={styles.participantName}>{name}</Text>
<TouchableOpacity
style={styles.closeIcon}
@@ -63,42 +195,152 @@ export default function ParticipantScreen() {
<View style={styles.performedShowsSection}>
<Text style={styles.performedShowsTitle}>Auftritte:</Text>
<View style={styles.showContainer}></View>
{/* <ScrollView
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={{
width: "100%",
marginTop: 15,
style={{ flex: 1 }}
contentContainerStyle={{
gap: 20,
paddingHorizontal: 15,
paddingLeft: 30,
}}
>
{resolved.map(({ show, seasons, partners }) => {
const seasonPartnerLines = partners.map((p) => {
const label = `Staffel ${p.seasonNumber}`;
if (!p.partner) return label;
return `${label} • Partner: ${p.partner.name}`;
});
{appearances.map(({ show, seasons }) => {
const partners = Array.from(
new Map(
seasons
.map((s) => s.partner)
.filter((p): p is NonNullable<typeof p> => !!p)
.map((p) => [p.personId, p])
).values()
);
const allParticipants = Array.from(
new Map(
seasons
.flatMap((s) => s.participants)
.filter((p) => p.personId !== pid)
.map((p) => [p.personId, p])
).values()
);
const isExpanded = expandedShows.has(show.id);
const visible = isExpanded
? allParticipants
: allParticipants.slice(0, 12);
const restCount = Math.max(
allParticipants.length - visible.length,
0
);
return (
<TouchableOpacity key={show.id} style={styles.showContainer}>
<Image
source={{ uri: show.thumbnailUri }}
style={styles.showImage}
/>
<Text style={styles.showTitle} numberOfLines={2}>
<View key={show.id} style={styles.card}>
<TouchableOpacity
style={styles.showContainer}
onPress={() => goToShow(show.id)}
>
<Image
source={{ uri: show.bannerUri || show.thumbnailUri }}
style={styles.showImage}
resizeMode="cover"
/>
</TouchableOpacity>
<Text style={styles.showTitle} numberOfLines={1}>
{show.title}
</Text>
<Text
style={styles.showSeason}
numberOfLines={seasonPartnerLines.length}
>
{seasonPartnerLines.join("\n")}
<Text style={styles.showSeason}>
({formatYear(seasons[0]?.startDate)})
</Text>
</TouchableOpacity>
<Text style={styles.showSeason}>
Staffel {seasons.map((s) => s.seasonNumber).join(" und ")}
</Text>
<View style={styles.horizontalLine} />
<Text style={[styles.participantLabel, { marginTop: 10 }]}>
Weitere Teilnehmer
</Text>
<View style={styles.participantContainer}>
<View style={styles.participantRow}>
{visible.map((p) => (
<TouchableOpacity
key={p.personId}
style={styles.participantChip}
onPress={() => goToPerson(p)}
>
<Text
style={styles.participantChipText}
numberOfLines={1}
>
{p.name}
</Text>
</TouchableOpacity>
))}
{!isExpanded && restCount > 0 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>
+{restCount} mehr
</Text>
</TouchableOpacity>
)}
{isExpanded && allParticipants.length > 12 && (
<TouchableOpacity
onPress={() => toggleExpand(show.id)}
style={styles.moreChip}
>
<Text style={styles.moreChipText}>Weniger</Text>
</TouchableOpacity>
)}
</View>
</View>
{partners.length > 0 && (
<>
<View style={styles.horizontalLine} />
<Text
style={[styles.participantLabel, { marginTop: 10 }]}
>
Partner
</Text>
<View
style={[
styles.showContainer,
{
backgroundColor: "hsl(221, 39%, 12%)",
width: 150,
marginTop: 20,
},
]}
>
<Image
style={styles.showImage}
blurRadius={20}
source={{
uri: `https://i.pravatar.cc/300?img=${Math.floor(Math.random() * 70)}`,
}}
/>
</View>
{partners.map((p) => (
<Text
key={p.personId}
style={styles.partnerLabel}
numberOfLines={1}
>
{p.name}
</Text>
))}
</>
)}
</View>
);
})}
</ScrollView> */}
</ScrollView>
</View>
</ScrollView>
</GestureHandlerRootView>

View File

@@ -4,7 +4,6 @@ import StackHeader from "@/components/ui/StackHeader";
import { useSeasonContext } from "@/contexts/SeasonContext";
import { router, useLocalSearchParams } from "expo-router";
import React from "react";
import {
Dimensions,
Image,
@@ -99,6 +98,21 @@ export default function ShowDetails() {
});
}, [startDate]);
const handleOpenParticipant = React.useCallback(
(p: { id: number; name: string }) => {
router.push({
pathname: "/participant",
params: {
participantId: p.id,
name: p.name,
originShowId: String(showId),
originSeason: String(selectedSeason),
},
});
},
[showId, selectedSeason]
);
return (
<View style={styles.mainContainer}>
<StackHeader />
@@ -210,23 +224,11 @@ export default function ShowDetails() {
styles.participantContainer,
{ backgroundColor: "hsl(336, 79%, 63%)" },
]}
onPress={() =>
router.push({
pathname: "/participant",
params: {
participantId: p.id,
name: p.name,
},
})
}
onPress={() => handleOpenParticipant(p)}
>
<Image
source={{ uri: p.imageUri }}
style={{
width: "100%",
height: "100%",
borderRadius: 10,
}}
style={{ width: "100%", height: "100%", borderRadius: 10 }}
resizeMode="cover"
blurRadius={p.imageUri.includes("pravatar") ? 16 : 0}
/>

View File

@@ -7,15 +7,15 @@ const styles = StyleSheet.create({
},
closeIcon: {
position: "absolute",
top: Dimensions.get("window").height * 0.07,
top: Dimensions.get("window").height * 0.065,
right: 15,
},
participantName: {
color: "white",
fontSize: 24,
fontSize: 20,
fontWeight: "600",
textAlign: "center",
marginTop: Dimensions.get("window").height * 0.075,
marginTop: Dimensions.get("window").height * 0.06,
},
participantImage: {
width: "100%",
@@ -49,7 +49,7 @@ const styles = StyleSheet.create({
},
performedShowsSection: {
width: "100%",
height: Dimensions.get("window").height,
height: "100%",
backgroundColor: "hsl(221, 39%, 0%)",
marginTop: 20,
},
@@ -60,14 +60,7 @@ const styles = StyleSheet.create({
marginTop: 15,
marginLeft: 15,
},
showContainer: {
width: "85%",
height: 180,
backgroundColor: "hsl(336, 79%, 63%)",
borderRadius: 10,
alignSelf: "center",
marginTop: 15,
},
showImage: {
width: "100%",
height: "100%",
@@ -94,14 +87,89 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: "600",
textAlign: "center",
marginTop: 10,
marginTop: 15,
},
showSeason: {
color: "hsl(0, 0%, 80%)",
fontSize: 12,
fontWeight: "400",
textAlign: "center",
marginTop: 3,
marginTop: 5,
},
showContainer: {
width: Dimensions.get("window").width - 75,
height: 200,
borderRadius: 15,
marginTop: 20,
alignItems: "center",
backgroundColor: "hsl(336, 79%, 63%)",
},
card: {
width: Dimensions.get("window").width - 75,
alignItems: "center",
},
horizontalLine: {
height: 50,
width: 2,
backgroundColor: "hsl(0, 0%, 70%)",
marginTop: 10,
alignSelf: "center",
},
partnerLabel: {
color: "hsl(0, 0%, 80%)",
fontSize: 12,
fontWeight: "400",
textAlign: "center",
marginTop: 10,
},
participantContainer: {
width: "auto",
height: "auto",
borderRadius: 15,
marginTop: 15,
alignItems: "center",
justifyContent: "center",
backgroundColor: "hsl(221, 39%, 12%)",
padding: 10,
},
participantLabel: {
color: "white",
fontSize: 12,
},
participantRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 6,
alignItems: "center",
justifyContent: "flex-start",
},
participantChip: {
paddingVertical: 4,
paddingHorizontal: 8,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 18%)",
maxWidth: 160,
},
participantChipText: {
color: "hsl(0, 0%, 85%)",
fontSize: 11,
},
moreChip: {
paddingVertical: 4,
paddingHorizontal: 10,
borderRadius: 12,
backgroundColor: "hsl(221, 39%, 28%)",
},
moreChipText: {
color: "white",
fontSize: 11,
fontWeight: "600",
},
});