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
+175
View File
@@ -0,0 +1,175 @@
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>
);
}
+226
View File
@@ -0,0 +1,226 @@
import React, { useRef, useState } from "react";
import {
Animated, LayoutAnimation, Linking,
Platform, Text, TouchableOpacity,
UIManager, View, useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Entry, Subject } from "@/services/api";
import { useAuth } from "@/context/AuthContext";
import SubjectSheet from "@/components/SubjectSheet";
if (Platform.OS === "android") {
UIManager.setLayoutAnimationEnabledExperimental?.(true);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("en-GB", {
day: "numeric", month: "short", year: "numeric",
});
} catch {
return iso;
}
}
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
export default function ExpandableItem({ item }: { item: Entry }) {
const { user } = useAuth();
const [sheetSubject, setSheetSubject] = useState<Subject | null>(null);
const [expanded, setExpanded] = useState(false);
const rotateAnim = useRef(new Animated.Value(0)).current;
const dark = useColorScheme() === "dark";
const toggle = () => {
LayoutAnimation.configureNext({
duration: 280,
create: { type: "easeInEaseOut", property: "opacity" },
update: { type: "spring", springDamping: 0.8 },
});
Animated.timing(rotateAnim, {
toValue: expanded ? 0 : 1,
duration: 250,
useNativeDriver: true,
}).start();
setExpanded((v) => !v);
};
const chevronRotation = rotateAnim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "180deg"],
});
return (
<>
<TouchableOpacity
activeOpacity={0.85}
onPress={toggle}
className={`mx-4 mb-3 rounded-2xl overflow-hidden ${dark
? "bg-obsidian-100 border border-border-dark"
: "bg-surface-light border border-border-light"
}`}
style={{
shadowColor: dark ? "#000" : "#1A1714",
shadowOpacity: dark ? 0.35 : 0.06,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 8,
elevation: 2,
}}
>
{/* Collapsed header */}
<View className="flex-row items-center px-4 py-4">
<View className="flex-1 pr-3">
{/* Tag + date row */}
<View className="flex-row items-center mb-1.5 gap-2 flex-wrap">
{/* Subject tag */}
<View onStartShouldSetResponder={() => true}>
{user ? (
<TouchableOpacity
onPress={() => setSheetSubject(item.subject)}
className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}
activeOpacity={0.7}
>
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
{item.subject?.name ?? item.groupName}
</Text>
</TouchableOpacity>
) : (
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
{item.subject?.name ?? item.groupName}
</Text>
</View>
)}
</View>
{/* Group pill */}
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}>
<Text className={`text-xs font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{item.groupName}
</Text>
</View>
<Text className={`text-xs ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{formatDate(item.timePublished)}
</Text>
</View>
{/* Title */}
<Text
className={`text-base font-sans font-semibold leading-snug ${dark ? "text-ink-dark" : "text-ink-light"}`}
numberOfLines={expanded ? undefined : 2}
>
{item.title}
</Text>
{/* info_entry — always visible as subtitle */}
{item.infoEntry ? (
<Text
className={`text-xs mt-1 leading-relaxed ${dark ? "text-muted-dark" : "text-muted-light"}`}
numberOfLines={expanded ? undefined : 2}
>
{item.infoEntry}
</Text>
) : null}
</View>
<Animated.View style={{ transform: [{ rotate: chevronRotation }] }}>
<Ionicons name="chevron-down" size={18} color={dark ? "#706D67" : "#8A8278"} />
</Animated.View>
</View>
{/* Expanded body */}
{expanded && (
<View className={`border-t ${dark ? "border-border-dark" : "border-border-light"}`}>
{/* paragraph */}
{item.paragraph ? (
<View className="px-4 pt-3 pb-2">
<Text className={`text-xs font-sans font-bold tracking-widest uppercase mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Content
</Text>
<Text className={`text-sm font-sans leading-relaxed ${dark ? "text-ink-dark/85" : "text-ink-light/85"}`}>
{item.paragraph}
</Text>
</View>
) : null}
{/* Meta row: subject + group + date */}
<View className={`mx-4 my-3 p-3 rounded-xl ${dark ? "bg-obsidian-50" : "bg-parchment-100"}`}>
<Row label="Subject" value={item.subject?.name} dark={dark} />
<Row label="Group" value={item.groupName} dark={dark} />
<Row label="Published" value={formatDate(item.timePublished)} dark={dark} last />
</View>
{/* filepath — clickable attachment */}
{item.filepath ? (
<TouchableOpacity
onPress={() => Linking.openURL(item.filepath!)}
activeOpacity={0.75}
className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${dark
? "bg-obsidian-50 border-border-dark"
: "bg-parchment-100 border-border-light"
}`}
>
<View className={`w-8 h-8 rounded-lg items-center justify-center ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
<Ionicons
name="document-attach-outline"
size={17}
color={dark ? "#E07B45" : "#C4622D"}
/>
</View>
<View className="flex-1">
<Text className={`text-xs font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{fileName(item.filepath)}
</Text>
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Tap to open attachment
</Text>
</View>
<Ionicons
name="open-outline"
size={15}
color={dark ? "#706D67" : "#8A8278"}
/>
</TouchableOpacity>
) : null}
</View>
)}
<SubjectSheet
subject={sheetSubject}
onClose={() => setSheetSubject(null)}
/>
</TouchableOpacity>
</>
);
}
function Row({ label, value, dark, last = false }: {
label: string;
value?: string;
dark: boolean;
last?: boolean;
}) {
if (!value) return null;
return (
<View className={`flex-row justify-between py-1.5 ${!last ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""}`}>
<Text className={`text-xs font-sans font-semibold ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{label}
</Text>
<Text className={`text-xs font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{value}
</Text>
</View>
);
}
+66
View File
@@ -0,0 +1,66 @@
import React, { useState } from "react";
import {
View,
TextInput,
TouchableOpacity,
useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface SearchBarProps {
placeholder?: string;
onSearch?: (text: string) => void;
}
//TODO make cirilic==latinic when searching
export default function SearchBar({
placeholder = "Search…",
onSearch,
}: SearchBarProps) {
const [query, setQuery] = useState("");
const scheme = useColorScheme();
const dark = scheme === "dark";
const handleChange = (text: string) => {
setQuery(text);
onSearch?.(text);
};
return (
<View
className={`
flex-row items-center mx-4 my-3 px-4 rounded-2xl h-11
${dark
? "bg-obsidian-100 border border-border-dark"
: "bg-parchment-100 border border-border-light"
}
`}
>
<Ionicons
name="search"
size={16}
color={dark ? "#706D67" : "#8A8278"}
style={{ marginRight: 8 }}
/>
<TextInput
className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"
}`}
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
placeholder={placeholder}
value={query}
onChangeText={handleChange}
returnKeyType="search"
clearButtonMode="while-editing"
/>
{query.length > 0 && (
<TouchableOpacity onPress={() => handleChange("")} hitSlop={8}>
<Ionicons
name="close-circle"
size={16}
color={dark ? "#706D67" : "#8A8278"}
/>
</TouchableOpacity>
)}
</View>
);
}
+117
View File
@@ -0,0 +1,117 @@
import React, { useState } from "react";
import {
View, Text, Modal, TouchableOpacity,
ActivityIndicator, useColorScheme,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Subject } from "../services/api";
import { useAuth } from "../context/AuthContext";
interface Props {
subject: Subject | null; // null = closed
onClose: () => void;
}
export default function SubjectSheet({ subject, onClose }: Props) {
const dark = useColorScheme() === "dark";
const { isSubscribed, subscribe, unsubscribe, user } = useAuth();
const [loading, setLoading] = useState(false);
if (!subject) return null;
const subscribed = isSubscribed(subject.id);
const accent = dark ? "#E07B45" : "#C4622D";
const toggle = async () => {
setLoading(true);
try {
if (subscribed) await unsubscribe(subject.id);
else await subscribe(subject.id);
} finally {
setLoading(false);
}
};
return (
<Modal
visible={!!subject}
transparent
animationType="fade"
onRequestClose={onClose}
>
{/* Backdrop */}
<TouchableOpacity
className="flex-1 justify-end"
style={{ backgroundColor: "rgba(0,0,0,0.45)" }}
activeOpacity={1}
onPress={onClose}
>
{/* Sheet — stop tap propagation */}
<TouchableOpacity activeOpacity={1} onPress={() => { }}>
<View
className={`rounded-t-3xl px-6 pt-4 pb-10 ${dark ? "bg-obsidian-100" : "bg-surface-light"
}`}
>
{/* Drag handle */}
<View className={`w-10 h-1 rounded-full self-center mb-5 ${dark ? "bg-obsidian-50" : "bg-parchment-300"}`} />
{/* Group pill */}
<View
className="self-start px-2.5 py-1 rounded-full mb-3"
style={{ backgroundColor: accent + "20" }}
>
</View>
{/* Subject name */}
<Text className={`text-2xl font-display font-bold mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{subject.name}
</Text>
<Text className={`text-sm font-sans mb-6 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{subscribed
? "You're subscribed to this subject."
: "Subscribe to see this subject in your feed."}
</Text>
{/* Subscribe / Unsubscribe button */}
{user ? (
<TouchableOpacity
onPress={toggle}
disabled={loading}
className="py-4 rounded-2xl items-center flex-row justify-center gap-2"
style={{
backgroundColor: subscribed
? dark ? "#2C2A27" : "#F0EDE8"
: accent,
}}
activeOpacity={0.8}
>
{loading ? (
<ActivityIndicator color={subscribed ? accent : "#fff"} />
) : (
<>
<Ionicons
name={subscribed ? "bookmark" : "bookmark-outline"}
size={17}
color={subscribed ? accent : "#fff"}
/>
<Text
className="text-sm font-sans font-semibold"
style={{ color: subscribed ? accent : "#fff" }}
>
{subscribed ? "Unsubscribe" : "Subscribe"}
</Text>
</>
)}
</TouchableOpacity>
) : (
<Text className={`text-center text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Sign in to subscribe to subjects.
</Text>
)}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Modal>
);
}