moved files to monorepo and updated controllers to start with /api

This commit is contained in:
2026-05-31 21:04:32 +02:00
parent e4cb598193
commit d6657d83dd
118 changed files with 19894 additions and 0 deletions
+269
View File
@@ -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>
);
}