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

270 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
View, Text, ScrollView, TouchableOpacity,
ActivityIndicator, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import SearchBar from "../components/SearchBar";
import ExpandableItem from "../components/ExpandableItem";
import { useAuth } from "../context/AuthContext";
import { Entry, entriesApi } from "../services/api";
type SortMode = "time" | "subject";
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric", month: "short", year: "numeric",
});
}
function groupBySubject(entries: Entry[]): { label: string; items: Entry[] }[] {
const map = new Map<string, Entry[]>();
entries.forEach((e) => {
const key = e.subject?.name ?? e.groupName;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
});
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
}
function groupByDate(entries: Entry[]): { label: string; items: Entry[] }[] {
const map = new Map<string, Entry[]>();
entries.forEach((e) => {
const key = formatDate(e.timePublished);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(e);
});
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
}
function SectionHeader({ label, dark }: { label: string; dark: boolean }) {
return (
<Text className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
}`}>
{label}
</Text>
);
}
function SortToggle({
mode, onChange, dark, accent,
}: {
mode: SortMode;
onChange: (m: SortMode) => void;
dark: boolean;
accent: string;
}) {
return (
<View
className={`flex-row mx-4 mb-3 rounded-xl p-1 ${dark ? "bg-obsidian-100" : "bg-parchment-200"
}`}
>
{(["time", "subject"] as SortMode[]).map((m) => {
const active = mode === m;
return (
<TouchableOpacity
key={m}
onPress={() => onChange(m)}
className="flex-1 flex-row items-center justify-center gap-1.5 py-2 rounded-lg"
style={{ backgroundColor: active ? accent : "transparent" }}
activeOpacity={0.7}
>
<Ionicons
name={m === "time" ? "time-outline" : "albums-outline"}
size={14}
color={active ? "#fff" : dark ? "#706D67" : "#8A8278"}
/>
<Text
className="text-xs font-sans font-semibold"
style={{ color: active ? "#fff" : dark ? "#706D67" : "#8A8278" }}
>
{m === "time" ? "By time" : "By subject"}
</Text>
</TouchableOpacity>
);
})}
</View>
);
}
export default function SubscribedFeed() {
const dark = useColorScheme() === "dark";
const accent = dark ? "#E07B45" : "#C4622D";
const { user } = useAuth();
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [sortMode, setSortMode] = useState<SortMode>("time");
const fetchFeed = useCallback(async () => {
if (!user || user.subscribedSubjectIds.length === 0) { setEntries([]); return; }
setLoading(true);
setError("");
try {
const pages = await Promise.all(
user.subscribedSubjectIds.map((subjectId) =>
entriesApi.getEntries({ subjectId, page: 0 })
)
);
const merged = pages
.flatMap((p) => p.content)
.sort((a, b) =>
new Date(b.timePublished).getTime() - new Date(a.timePublished).getTime()
);
setEntries(merged);
} catch {
setError("Couldn't load your feed.");
} finally {
setLoading(false);
}
}, [user?.subscribedSubjectIds]);
useEffect(() => { fetchFeed(); }, [fetchFeed]);
// Filter then group
const filtered = useMemo(() => {
if (!query.trim()) return entries;
const lower = query.toLowerCase();
return entries.filter((e) =>
e.title.toLowerCase().includes(lower) ||
e.subject?.name?.toLowerCase().includes(lower) ||
e.groupName?.toLowerCase().includes(lower)
);
}, [query, entries]);
const grouped = useMemo(() =>
sortMode === "time"
? groupByDate(filtered)
: groupBySubject(filtered),
[filtered, sortMode]);
if (!user) return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
</View>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-4xl mb-3">📰</Text>
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
Sign in to see your feed
</Text>
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Subscribe to subjects and their latest entries will appear here.
</Text>
</View>
</SafeAreaView>
);
if (!loading && user.subscribedSubjectIds.length === 0) return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
</View>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-4xl mb-3">🔖</Text>
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
No subscriptions yet
</Text>
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Go to Discover and tap a subject tag to subscribe.
</Text>
</View>
</SafeAreaView>
);
return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */}
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
<View className="px-4 pt-2 pb-1 flex-row items-center">
<View className="flex-1">
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
Your Feed
</Text>
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{loading
? "Loading…"
: `${entries.length} entries · ${user.subscribedSubjectIds.length} subjects`}
</Text>
</View>
<TouchableOpacity
onPress={fetchFeed}
disabled={loading}
className={`w-9 h-9 rounded-xl items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
activeOpacity={0.7}
>
{loading
? <ActivityIndicator size="small" color={accent} />
: <Ionicons name="refresh-outline" size={18} color={accent} />
}
</TouchableOpacity>
</View>
<SearchBar placeholder="Search your feed…" onSearch={setQuery} />
</View>
{loading && entries.length === 0 ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color={accent} />
</View>
) : error ? (
<View className="flex-1 items-center justify-center px-8">
<Text className="text-3xl mb-3"></Text>
<Text className={`text-sm font-sans text-center mb-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{error}
</Text>
<TouchableOpacity
onPress={fetchFeed}
className="px-5 py-2.5 rounded-xl"
style={{ backgroundColor: accent }}
>
<Text className="text-sm font-sans font-semibold text-white">Retry</Text>
</TouchableOpacity>
</View>
) : (
<ScrollView
contentContainerStyle={{ paddingBottom: 32 }}
showsVerticalScrollIndicator={false}
keyboardDismissMode="on-drag"
>
{/* Toggle */}
<View className="pt-3">
<SortToggle
mode={sortMode}
onChange={setSortMode}
dark={dark}
accent={accent}
/>
</View>
{filtered.length === 0 ? (
<View className="items-center mt-16 px-8">
<Text className="text-4xl mb-3">🔍</Text>
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
No results for "{query}"
</Text>
</View>
) : (
grouped.map((section) => (
<View key={section.label}>
<SectionHeader label={section.label} dark={dark} />
{section.items.map((item) => (
<ExpandableItem key={item.id} item={item} />
))}
</View>
))
)}
</ScrollView>
)}
</SafeAreaView>
);
}