moved files to monorepo and updated controllers to start with /api
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user