etfoglasi-frontend/components/CollapsibleCategory.tsx
2026-05-12 21:33:41 +02:00

176 lines
6.5 KiB
TypeScript

import React, { useCallback, useEffect, useRef, useState } from "react";
import {
View, Text, TouchableOpacity,
ActivityIndicator, LayoutAnimation, useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Entry, entriesApi } from "@/services/api";
import ExpandableItem from "./ExpandableItem";
export interface CollapsibleCategoryProps {
id: string;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
darkColor: string;
groupName: string;
refreshKey?: number;
}
export default function CollapsibleCategory({
label,
icon,
color,
darkColor,
groupName,
refreshKey = 0,
}: CollapsibleCategoryProps) {
const dark = useColorScheme() === "dark";
const accentColor = dark ? darkColor : color;
const [open, setOpen] = useState();
const [items, setItems] = useState<Entry[]>([]);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Stable fetch — won't re-create when loading changes
const loadingRef = useRef(false);
const fetchPage = useCallback(async (pageNum: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
setError("");
try {
const data = await entriesApi.getEntries({ groupName, page: pageNum });
setItems((prev) =>
pageNum === 0 ? data.content : [...prev, ...data.content]
);
setHasMore(!data.last);
setPage(pageNum);
} catch {
setError("Couldn't load entries. Tap to retry.");
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [groupName]);
// Fetch page 0 on mount AND whenever refreshKey changes
useEffect(() => {
setItems([]);
setPage(0);
setHasMore(true);
setError("");
fetchPage(0);
}, [refreshKey]); // refreshKey bump = full reset
const toggle = () => {
LayoutAnimation.configureNext({
duration: 260,
create: { type: "easeInEaseOut", property: "opacity" },
update: { type: "spring", springDamping: 0.85 },
});
// @ts-ignore bla bla bla
setOpen((v) => !v);
};
return (
<View className="mb-4">
{/* ── Header ── */}
<TouchableOpacity
onPress={toggle}
activeOpacity={0.8}
className={`mx-4 flex-row items-center px-4 py-3.5 rounded-2xl ${dark
? "bg-obsidian-50 border border-border-dark"
: "bg-parchment-100 border border-border-light"
}`}
>
<View
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
style={{ backgroundColor: accentColor + "25" }}
>
<Ionicons name={icon} size={16} color={accentColor} />
</View>
<Text className={`flex-1 text-sm font-sans font-bold tracking-wide ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{label}
</Text>
{/* Count badge — spinner while loading first page */}
<View
className="px-2 py-0.5 rounded-full mr-3"
style={{ backgroundColor: accentColor + "20" }}
>
{loading && items.length === 0 ? (
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
) : (
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
{items.length}
</Text>
)}
</View>
<Ionicons
name={open ? "chevron-up" : "chevron-down"}
size={16}
color={dark ? "#706D67" : "#8A8278"}
/>
</TouchableOpacity>
{/* Body */}
{open && (
<View className="mt-2">
{/* Error with retry */}
{error !== "" && (
<TouchableOpacity
onPress={() => fetchPage(page)}
className="mx-4 py-4 items-center"
>
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{error}
</Text>
</TouchableOpacity>
)}
{items.map((item) => (
<ExpandableItem key={item.id} item={item} />
))}
{/* Load more */}
{hasMore && items.length > 0 && (
<TouchableOpacity
onPress={() => fetchPage(page + 1)}
disabled={loading}
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${dark
? "bg-obsidian-50 border-border-dark"
: "bg-parchment-100 border-border-light"
}`}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator size="small" color={accentColor} />
) : (
<View className="flex-row items-center gap-1.5">
<Ionicons name="chevron-down" size={13} color={accentColor} />
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
Load more
</Text>
</View>
)}
</TouchableOpacity>
)}
{!hasMore && items.length > 0 && (
<Text className={`text-center text-xs font-sans py-3 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
All {items.length} entries loaded
</Text>
)}
</View>
)}
</View>
);
}