mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
refactor(native): replace runOnJS with scheduleOnRN and custom timeAgo with date-fns
- Swap `runOnJS` for `scheduleOnRN` from `react-native-worklets` in gesture handler `.onEnd()` callbacks (HeroBanner, PosterCard) - Delete custom `timeAgo` util; use `date-fns` `formatDistanceToNow` in integration status strings - Enhance person detail screen: formatted birth date with age/death calculation, place of birth with MapPin icon, department label mapping, CalendarIcon for dates - Fix `ExpandableText` truncation detection by measuring line count on a hidden full-text node rather than the clamped one; add `actionColor` prop so title accent color can theme the toggle - Polish PosterCard metadata row: remove icon opacity, tighten gaps
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tanstack/react-query-persist-client": "5.90.24",
|
||||
"better-auth": "catalog:",
|
||||
"date-fns": "catalog:",
|
||||
"expo": "55.0.6",
|
||||
"expo-application": "55.0.9",
|
||||
"expo-clipboard": "55.0.8",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCalendar,
|
||||
IconMapPin,
|
||||
IconMovie,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react-native";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { FlatList, Pressable, useWindowDimensions, View } from "react-native";
|
||||
@@ -21,6 +24,25 @@ import { orpc } from "@/lib/orpc";
|
||||
const FILMOGRAPHY_GAP = 12;
|
||||
const FILMOGRAPHY_PADDING = 16;
|
||||
|
||||
function calculateAge(birthday: string, deathday?: string | null): number {
|
||||
const birth = new Date(birthday);
|
||||
const end = deathday ? new Date(deathday) : new Date();
|
||||
let age = end.getFullYear() - birth.getFullYear();
|
||||
const m = end.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && end.getDate() < birth.getDate())) {
|
||||
age--;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
|
||||
const departmentLabels: Record<string, string> = {
|
||||
Acting: "Actor",
|
||||
Directing: "Director",
|
||||
Writing: "Writer",
|
||||
Production: "Producer",
|
||||
Editing: "Editor",
|
||||
};
|
||||
|
||||
export default function PersonDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -30,6 +52,7 @@ export default function PersonDetailScreen() {
|
||||
(screenWidth - FILMOGRAPHY_PADDING * 2 - FILMOGRAPHY_GAP) / 2;
|
||||
|
||||
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
|
||||
const primaryColor = useCSSVariable("--color-primary") as string;
|
||||
|
||||
const { data, isPending, isError } = useQuery(
|
||||
orpc.people.detail.queryOptions({ input: { id } }),
|
||||
@@ -52,14 +75,6 @@ export default function PersonDetailScreen() {
|
||||
userStatus={data?.userStatuses?.[credit.titleId] ?? null}
|
||||
width={undefined}
|
||||
/>
|
||||
{credit.character ? (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
className="mt-1 text-center text-[11px] text-muted-foreground"
|
||||
>
|
||||
as {credit.character}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
),
|
||||
[columnWidth, data?.userStatuses],
|
||||
@@ -165,17 +180,38 @@ export default function PersonDetailScreen() {
|
||||
|
||||
{person.knownForDepartment ? (
|
||||
<View className="mt-2 rounded-full bg-secondary px-3 py-1">
|
||||
<Text className="text-muted-foreground text-xs">
|
||||
{person.knownForDepartment}
|
||||
<Text className="text-muted-foreground text-xs uppercase tracking-wider">
|
||||
{departmentLabels[person.knownForDepartment] ??
|
||||
person.knownForDepartment}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{person.birthday || person.deathday ? (
|
||||
<Text selectable className="mt-2 text-[13px] text-muted-foreground">
|
||||
{person.birthday?.slice(0, 4)}
|
||||
{person.deathday ? ` — ${person.deathday.slice(0, 4)}` : ""}
|
||||
</Text>
|
||||
{person.birthday || person.placeOfBirth ? (
|
||||
<View className="mt-3 items-center gap-1.5">
|
||||
{person.birthday ? (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<IconCalendar size={14} color={primaryColor} />
|
||||
<Text selectable className="text-[13px] text-muted-foreground">
|
||||
{format(parseISO(person.birthday), "MMMM d, yyyy")}
|
||||
{(() => {
|
||||
const age = calculateAge(person.birthday, person.deathday);
|
||||
return person.deathday
|
||||
? ` (died at ${age})`
|
||||
: ` (age ${age})`;
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{person.placeOfBirth ? (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<IconMapPin size={14} color={primaryColor} />
|
||||
<Text selectable className="text-[13px] text-muted-foreground">
|
||||
{person.placeOfBirth}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</Animated.View>
|
||||
|
||||
|
||||
@@ -457,7 +457,7 @@ export default function TitleDetailScreen() {
|
||||
{title.overview ? (
|
||||
<Animated.View entering={FadeIn.duration(300).delay(300)}>
|
||||
<View className="mt-5 px-4">
|
||||
<ExpandableText text={title.overview} />
|
||||
<ExpandableText text={title.overview} actionColor={titleAccent} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null}
|
||||
|
||||
@@ -7,11 +7,11 @@ import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
@@ -60,7 +60,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(handlePress)();
|
||||
scheduleOnRN(handlePress);
|
||||
});
|
||||
|
||||
const useGlass = isLiquidGlassAvailable();
|
||||
|
||||
@@ -6,11 +6,11 @@ import { View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import { Image } from "@/components/ui/image";
|
||||
import { Text } from "@/components/ui/text";
|
||||
@@ -59,7 +59,7 @@ export function HeroBanner({ item }: { item: HeroBannerItem }) {
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(handlePress)();
|
||||
scheduleOnRN(handlePress);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import type { SvgProps } from "react-native-svg";
|
||||
import {
|
||||
EmbyIcon,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
SonarrIcon,
|
||||
} from "@/components/settings/icons";
|
||||
import { getServerUrl } from "@/lib/server-url";
|
||||
import { timeAgo } from "@/utils/time-ago";
|
||||
|
||||
export interface IntegrationConfig {
|
||||
provider: "plex" | "jellyfin" | "emby" | "sonarr" | "radarr";
|
||||
@@ -23,13 +23,13 @@ export interface IntegrationConfig {
|
||||
|
||||
function webhookStatus(lastEventAt: string | null): string {
|
||||
return lastEventAt
|
||||
? `Last event ${timeAgo(lastEventAt)}`
|
||||
? `Last event ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
||||
: "Ready — nothing received yet";
|
||||
}
|
||||
|
||||
function listStatus(lastEventAt: string | null): string {
|
||||
return lastEventAt
|
||||
? `Last polled ${timeAgo(lastEventAt)}`
|
||||
? `Last polled ${formatDistanceToNow(new Date(lastEventAt), { addSuffix: true })}`
|
||||
: "Ready — not polled yet";
|
||||
}
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import { Text } from "@/components/ui/text";
|
||||
export function ExpandableText({
|
||||
text,
|
||||
maxLines = 3,
|
||||
actionColor,
|
||||
}: {
|
||||
text: string;
|
||||
maxLines?: number;
|
||||
actionColor?: string;
|
||||
}) {
|
||||
const prevTextRef = useRef(text);
|
||||
let expanded: boolean;
|
||||
@@ -27,7 +29,7 @@ export function ExpandableText({
|
||||
needsTruncation = needsTruncationState;
|
||||
}
|
||||
|
||||
const onTextLayout = useCallback(
|
||||
const onMeasureLayout = useCallback(
|
||||
(e: NativeSyntheticEvent<TextLayoutEventData>) => {
|
||||
setNeedsTruncation(e.nativeEvent.lines.length > maxLines);
|
||||
},
|
||||
@@ -36,17 +38,26 @@ export function ExpandableText({
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Hidden text without numberOfLines to measure true line count */}
|
||||
<Text
|
||||
onTextLayout={onMeasureLayout}
|
||||
className="pointer-events-none absolute text-[14px] leading-[22px] opacity-0"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
<Text
|
||||
selectable
|
||||
numberOfLines={expanded ? undefined : maxLines}
|
||||
onTextLayout={onTextLayout}
|
||||
className="text-[14px] text-foreground leading-[22px]"
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{needsTruncation && (
|
||||
<Pressable onPress={() => setExpanded(!expanded)} className="mt-1">
|
||||
<Text className="font-sans-medium text-[13px] text-primary">
|
||||
<Text
|
||||
className="font-sans-medium text-[13px] text-primary"
|
||||
style={actionColor ? { color: actionColor } : undefined}
|
||||
>
|
||||
{expanded ? "Show less" : "Show more"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -15,11 +15,11 @@ import { Pressable, View } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
runOnJS,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useCSSVariable } from "uniwind";
|
||||
import * as ContextMenu from "zeego/context-menu";
|
||||
import { Image } from "@/components/ui/image";
|
||||
@@ -210,19 +210,19 @@ export function PosterCard({
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mt-1 flex-row items-center gap-2">
|
||||
<View className="mt-1 flex-row items-center gap-1">
|
||||
{type === "movie" ? (
|
||||
<IconMovie size={12} color={primaryColor} opacity={0.6} />
|
||||
<IconMovie size={12} color={primaryColor} />
|
||||
) : (
|
||||
<IconDeviceTv size={12} color={primaryColor} opacity={0.6} />
|
||||
<IconDeviceTv size={12} color={primaryColor} />
|
||||
)}
|
||||
{year ? (
|
||||
<Text className="text-[11px] text-muted-foreground">{year}</Text>
|
||||
) : null}
|
||||
{voteAverage != null && voteAverage > 0 && (
|
||||
<View className="ml-auto flex-row items-center gap-0.5">
|
||||
<IconStarFilled size={10} color={primaryColor} opacity={0.8} />
|
||||
<Text className="text-[11px] text-primary/80">
|
||||
<View className="ml-auto flex-row items-center gap-1">
|
||||
<IconStarFilled size={10} color={primaryColor} />
|
||||
<Text className="text-[11px] text-primary">
|
||||
{voteAverage.toFixed(1)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -336,7 +336,7 @@ export function PosterCard({
|
||||
pressed.set(withSpring(0, { damping: 15, stiffness: 300 }));
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(handleResolve)();
|
||||
scheduleOnRN(handleResolve);
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
const MINUTE = 60;
|
||||
const HOUR = 3600;
|
||||
const DAY = 86400;
|
||||
const WEEK = 604800;
|
||||
const MONTH = 2592000;
|
||||
const YEAR = 31536000;
|
||||
|
||||
export function timeAgo(dateString: string): string {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateString).getTime()) / 1000,
|
||||
);
|
||||
|
||||
if (seconds < MINUTE) return "just now";
|
||||
if (seconds < HOUR) {
|
||||
const m = Math.floor(seconds / MINUTE);
|
||||
return `${m} ${m === 1 ? "minute" : "minutes"} ago`;
|
||||
}
|
||||
if (seconds < DAY) {
|
||||
const h = Math.floor(seconds / HOUR);
|
||||
return `${h} ${h === 1 ? "hour" : "hours"} ago`;
|
||||
}
|
||||
if (seconds < WEEK) {
|
||||
const d = Math.floor(seconds / DAY);
|
||||
return `${d} ${d === 1 ? "day" : "days"} ago`;
|
||||
}
|
||||
if (seconds < MONTH) {
|
||||
const w = Math.floor(seconds / WEEK);
|
||||
return `${w} ${w === 1 ? "week" : "weeks"} ago`;
|
||||
}
|
||||
if (seconds < YEAR) {
|
||||
const mo = Math.floor(seconds / MONTH);
|
||||
return `${mo} ${mo === 1 ? "month" : "months"} ago`;
|
||||
}
|
||||
const y = Math.floor(seconds / YEAR);
|
||||
return `${y} ${y === 1 ? "year" : "years"} ago`;
|
||||
}
|
||||
Reference in New Issue
Block a user