added stuff
This commit is contained in:
parent
abd725c274
commit
0233f8670e
@ -4,46 +4,43 @@ import {
|
|||||||
ActivityIndicator, LayoutAnimation, useColorScheme,
|
ActivityIndicator, LayoutAnimation, useColorScheme,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Entry, entriesApi } from "../services/api";
|
import { Entry, entriesApi } from "@/services/api";
|
||||||
import ExpandableItem from "./ExpandableItem";
|
import ExpandableItem from "./ExpandableItem";
|
||||||
|
|
||||||
// ─── Props ─────────────────────────────────────────────────────────────────
|
|
||||||
// items removed — now fetched from API using groupName
|
|
||||||
|
|
||||||
export interface CollapsibleCategoryProps {
|
export interface CollapsibleCategoryProps {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
color: string;
|
color: string;
|
||||||
darkColor: string;
|
darkColor: string;
|
||||||
groupName: string; // passed to /entries?groupName=
|
groupName: string;
|
||||||
defaultOpen?: boolean;
|
refreshKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Component ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function CollapsibleCategory({
|
export default function CollapsibleCategory({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
color,
|
color,
|
||||||
darkColor,
|
darkColor,
|
||||||
groupName,
|
groupName,
|
||||||
defaultOpen = false,
|
refreshKey = 0,
|
||||||
}: CollapsibleCategoryProps) {
|
}: CollapsibleCategoryProps) {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const accentColor = dark ? darkColor : color;
|
const accentColor = dark ? darkColor : color;
|
||||||
|
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState();
|
||||||
const [items, setItems] = useState<Entry[]>([]);
|
const [items, setItems] = useState<Entry[]>([]);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
const initialised = useRef(false);
|
// Stable fetch — won't re-create when loading changes
|
||||||
|
const loadingRef = useRef(false);
|
||||||
|
|
||||||
const fetchPage = useCallback(async (pageNum: number) => {
|
const fetchPage = useCallback(async (pageNum: number) => {
|
||||||
if (loading) return;
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@ -56,17 +53,19 @@ export default function CollapsibleCategory({
|
|||||||
} catch {
|
} catch {
|
||||||
setError("Couldn't load entries. Tap to retry.");
|
setError("Couldn't load entries. Tap to retry.");
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [groupName, loading]);
|
}, [groupName]);
|
||||||
|
|
||||||
// Fetch first page the first time the accordion opens
|
// Fetch page 0 on mount AND whenever refreshKey changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && !initialised.current) {
|
setItems([]);
|
||||||
initialised.current = true;
|
setPage(0);
|
||||||
|
setHasMore(true);
|
||||||
|
setError("");
|
||||||
fetchPage(0);
|
fetchPage(0);
|
||||||
}
|
}, [refreshKey]); // refreshKey bump = full reset
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
LayoutAnimation.configureNext({
|
LayoutAnimation.configureNext({
|
||||||
@ -74,22 +73,20 @@ export default function CollapsibleCategory({
|
|||||||
create: { type: "easeInEaseOut", property: "opacity" },
|
create: { type: "easeInEaseOut", property: "opacity" },
|
||||||
update: { type: "spring", springDamping: 0.85 },
|
update: { type: "spring", springDamping: 0.85 },
|
||||||
});
|
});
|
||||||
|
// @ts-ignore bla bla bla
|
||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
{/* ── Header — untouched from your original ── */}
|
{/* ── Header ── */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={toggle}
|
onPress={toggle}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
className={`
|
className={`mx-4 flex-row items-center px-4 py-3.5 rounded-2xl ${dark
|
||||||
mx-4 flex-row items-center px-4 py-3.5 rounded-2xl
|
|
||||||
${dark
|
|
||||||
? "bg-obsidian-50 border border-border-dark"
|
? "bg-obsidian-50 border border-border-dark"
|
||||||
: "bg-parchment-100 border border-border-light"
|
: "bg-parchment-100 border border-border-light"
|
||||||
}
|
}`}
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
|
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
|
||||||
@ -98,15 +95,11 @@ export default function CollapsibleCategory({
|
|||||||
<Ionicons name={icon} size={16} color={accentColor} />
|
<Ionicons name={icon} size={16} color={accentColor} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text className={`flex-1 text-sm font-sans font-bold tracking-wide ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
className={`flex-1 text-sm font-sans font-bold tracking-wide ${
|
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Count badge — shows loaded count, spins while first load */}
|
{/* Count badge — spinner while loading first page */}
|
||||||
<View
|
<View
|
||||||
className="px-2 py-0.5 rounded-full mr-3"
|
className="px-2 py-0.5 rounded-full mr-3"
|
||||||
style={{ backgroundColor: accentColor + "20" }}
|
style={{ backgroundColor: accentColor + "20" }}
|
||||||
@ -114,10 +107,7 @@ export default function CollapsibleCategory({
|
|||||||
{loading && items.length === 0 ? (
|
{loading && items.length === 0 ? (
|
||||||
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
|
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
|
||||||
) : (
|
) : (
|
||||||
<Text
|
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
|
||||||
className="text-xs font-sans font-semibold"
|
|
||||||
style={{ color: accentColor }}
|
|
||||||
>
|
|
||||||
{items.length}
|
{items.length}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@ -130,10 +120,10 @@ export default function CollapsibleCategory({
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* Body */}
|
||||||
{open && (
|
{open && (
|
||||||
<View className="mt-2">
|
<View className="mt-2">
|
||||||
{/* Error */}
|
{/* Error with retry */}
|
||||||
{error !== "" && (
|
{error !== "" && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => fetchPage(page)}
|
onPress={() => fetchPage(page)}
|
||||||
@ -145,7 +135,6 @@ export default function CollapsibleCategory({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Items — same ExpandableItem you already have */}
|
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ExpandableItem key={item.id} item={item} />
|
<ExpandableItem key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
@ -155,8 +144,7 @@ export default function CollapsibleCategory({
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => fetchPage(page + 1)}
|
onPress={() => fetchPage(page + 1)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${
|
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${dark
|
||||||
dark
|
|
||||||
? "bg-obsidian-50 border-border-dark"
|
? "bg-obsidian-50 border-border-dark"
|
||||||
: "bg-parchment-100 border-border-light"
|
: "bg-parchment-100 border-border-light"
|
||||||
}`}
|
}`}
|
||||||
@ -167,10 +155,7 @@ export default function CollapsibleCategory({
|
|||||||
) : (
|
) : (
|
||||||
<View className="flex-row items-center gap-1.5">
|
<View className="flex-row items-center gap-1.5">
|
||||||
<Ionicons name="chevron-down" size={13} color={accentColor} />
|
<Ionicons name="chevron-down" size={13} color={accentColor} />
|
||||||
<Text
|
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
|
||||||
className="text-xs font-sans font-semibold"
|
|
||||||
style={{ color: accentColor }}
|
|
||||||
>
|
|
||||||
Load more
|
Load more
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@ -178,11 +163,8 @@ export default function CollapsibleCategory({
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* End of list */}
|
|
||||||
{!hasMore && items.length > 0 && (
|
{!hasMore && items.length > 0 && (
|
||||||
<Text className={`text-center text-xs font-sans py-3 ${
|
<Text className={`text-center text-xs font-sans py-3 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}>
|
|
||||||
All {items.length} entries loaded
|
All {items.length} entries loaded
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
UIManager, View, useColorScheme,
|
UIManager, View, useColorScheme,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Entry } from "../services/api";
|
import { Entry, Subject } from "@/services/api";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import SubjectSheet from "@/components/SubjectSheet";
|
||||||
|
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
||||||
@ -26,6 +28,11 @@ function fileName(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpandableItem({ item }: { item: Entry }) {
|
export default function ExpandableItem({ item }: { item: Entry }) {
|
||||||
|
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [sheetSubject, setSheetSubject] = useState<Subject | null>(null);
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
@ -50,11 +57,11 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={toggle}
|
onPress={toggle}
|
||||||
className={`mx-4 mb-3 rounded-2xl overflow-hidden ${
|
className={`mx-4 mb-3 rounded-2xl overflow-hidden ${dark
|
||||||
dark
|
|
||||||
? "bg-obsidian-100 border border-border-dark"
|
? "bg-obsidian-100 border border-border-dark"
|
||||||
: "bg-surface-light border border-border-light"
|
: "bg-surface-light border border-border-light"
|
||||||
}`}
|
}`}
|
||||||
@ -66,18 +73,35 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
elevation: 2,
|
elevation: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Collapsed header ── */}
|
{/* Collapsed header */}
|
||||||
<View className="flex-row items-center px-4 py-4">
|
<View className="flex-row items-center px-4 py-4">
|
||||||
<View className="flex-1 pr-3">
|
<View className="flex-1 pr-3">
|
||||||
|
|
||||||
{/* Subject tag + date */}
|
{/* Tag + date row */}
|
||||||
<View className="flex-row items-center mb-1.5 gap-2 flex-wrap">
|
<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"}`}>
|
<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"}`}>
|
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
|
||||||
{item.subject?.name ?? item.groupName}
|
{item.subject?.name ?? item.groupName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Group pill */}
|
||||||
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}>
|
<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"}`}>
|
<Text className={`text-xs font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
{item.groupName}
|
{item.groupName}
|
||||||
@ -89,6 +113,7 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-sans font-semibold leading-snug ${dark ? "text-ink-dark" : "text-ink-light"}`}
|
className={`text-base font-sans font-semibold leading-snug ${dark ? "text-ink-dark" : "text-ink-light"}`}
|
||||||
@ -113,7 +138,7 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* ── Expanded body ── */}
|
{/* Expanded body */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View className={`border-t ${dark ? "border-border-dark" : "border-border-light"}`}>
|
<View className={`border-t ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||||
|
|
||||||
@ -141,13 +166,11 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => Linking.openURL(item.filepath!)}
|
onPress={() => Linking.openURL(item.filepath!)}
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${
|
className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${dark
|
||||||
dark
|
|
||||||
? "bg-obsidian-50 border-border-dark"
|
? "bg-obsidian-50 border-border-dark"
|
||||||
: "bg-parchment-100 border-border-light"
|
: "bg-parchment-100 border-border-light"
|
||||||
}`}
|
}`}
|
||||||
// stop the expand/collapse toggle firing
|
|
||||||
onStartShouldSetResponder={() => true}
|
|
||||||
>
|
>
|
||||||
<View className={`w-8 h-8 rounded-lg items-center justify-center ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
<View className={`w-8 h-8 rounded-lg items-center justify-center ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@ -173,7 +196,12 @@ export default function ExpandableItem({ item }: { item: Entry }) {
|
|||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<SubjectSheet
|
||||||
|
subject={sheetSubject}
|
||||||
|
onClose={() => setSheetSubject(null)}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ interface SearchBarProps {
|
|||||||
onSearch?: (text: string) => void;
|
onSearch?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//TODO make cirilic==latinic when searching
|
||||||
export default function SearchBar({
|
export default function SearchBar({
|
||||||
placeholder = "Search…",
|
placeholder = "Search…",
|
||||||
onSearch,
|
onSearch,
|
||||||
@ -42,8 +43,7 @@ export default function SearchBar({
|
|||||||
style={{ marginRight: 8 }}
|
style={{ marginRight: 8 }}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
className={`flex-1 text-sm font-sans ${
|
className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
}`}
|
||||||
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
|||||||
117
components/SubjectSheet.tsx
Normal file
117
components/SubjectSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,8 +1,17 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { authApi, LoginPayload, RegisterPayload, JwtResponse } from "../services/api";
|
import {
|
||||||
|
authApi,
|
||||||
|
|
||||||
type User = { email: string; token: string };
|
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
||||||
|
} from "@/services/api";
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
token: string;
|
||||||
|
subscribedSubjectIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@ -10,6 +19,10 @@ type AuthContextValue = {
|
|||||||
login: (p: LoginPayload) => Promise<void>;
|
login: (p: LoginPayload) => Promise<void>;
|
||||||
register: (p: RegisterPayload) => Promise<void>;
|
register: (p: RegisterPayload) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
subscribe: (subjectId: string) => Promise<void>;
|
||||||
|
unsubscribe: (subjectId: string) => Promise<void>;
|
||||||
|
isSubscribed: (subjectId: string) => boolean;
|
||||||
|
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
@ -18,7 +31,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Restore session on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
AsyncStorage.getItem("auth_user")
|
AsyncStorage.getItem("auth_user")
|
||||||
.then((raw) => { if (raw) setUser(JSON.parse(raw)); })
|
.then((raw) => { if (raw) setUser(JSON.parse(raw)); })
|
||||||
@ -33,19 +45,75 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const login = async (payload: LoginPayload) => {
|
const login = async (payload: LoginPayload) => {
|
||||||
const res = await authApi.login(payload);
|
const res = await authApi.login(payload);
|
||||||
await persist({ email: res.email, token: res.token });
|
|
||||||
|
// Gracefully fetch subscriptions — don't block login if this fails
|
||||||
|
let subscribedSubjectIds: string[] = [];
|
||||||
|
if (res.userId) {
|
||||||
|
try {
|
||||||
|
const dto = await authApi.getUser(res.userId, res.token);
|
||||||
|
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
||||||
|
} catch {
|
||||||
|
// aaaa
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persist({
|
||||||
|
id: res.userId ?? "",
|
||||||
|
email: res.email,
|
||||||
|
token: res.token,
|
||||||
|
subscribedSubjectIds,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (payload: RegisterPayload) => {
|
const register = async (payload: RegisterPayload) => {
|
||||||
await authApi.register(payload);
|
await authApi.register(payload);
|
||||||
// Auto-login after register
|
|
||||||
await login({ email: payload.email, password: payload.password });
|
await login({ email: payload.email, password: payload.password });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
const body: UserUpdateDTO = {
|
||||||
|
email: user.email,
|
||||||
|
newEmail: newEmail || undefined,
|
||||||
|
password: newPassword || undefined,
|
||||||
|
subjectSet: user.subscribedSubjectIds.map((id) => ({ id })),
|
||||||
|
};
|
||||||
|
const dto = await authApi.updateUser(body, user.token);
|
||||||
|
const updated: User = {
|
||||||
|
...user,
|
||||||
|
email: newEmail ?? user.email,
|
||||||
|
subscribedSubjectIds: dto.subjectSet?.map((s: any) => s.id) ?? user.subscribedSubjectIds,
|
||||||
|
};
|
||||||
|
await persist(updated);
|
||||||
|
};
|
||||||
|
|
||||||
const logout = () => persist(null);
|
const logout = () => persist(null);
|
||||||
|
|
||||||
|
const subscribe = async (subjectId: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token);
|
||||||
|
const updated = {
|
||||||
|
...user,
|
||||||
|
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
||||||
|
};
|
||||||
|
await persist(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = async (subjectId: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token);
|
||||||
|
const updated = {
|
||||||
|
...user,
|
||||||
|
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
||||||
|
};
|
||||||
|
await persist(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSubscribed = (subjectId: string) =>
|
||||||
|
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
196
data/mockData.ts
196
data/mockData.ts
@ -1,196 +0,0 @@
|
|||||||
import { FeedItem } from "@/components/ExpandableItem";
|
|
||||||
import { Category } from "@/components/CollapsibleCategory";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// random generated data to check the UI before I connect to the server and fetch data
|
|
||||||
|
|
||||||
// ── 1st Year Subjects ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const YEAR_1_SUBJECTS = [
|
|
||||||
{
|
|
||||||
id: "y1-maths",
|
|
||||||
title: "Mathematics I",
|
|
||||||
author: "Prof. John Doe",
|
|
||||||
timestamp: "2 hours ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: [
|
|
||||||
"Introduction to Calculus, Linear Algebra, and Differential Equations.",
|
|
||||||
"Topics include Limits, Continuity, and Integration techniques."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "y1-physics",
|
|
||||||
title: "Physics I",
|
|
||||||
author: "Prof. Jane Smith",
|
|
||||||
timestamp: "5 hours ago",
|
|
||||||
tag: "Physics",
|
|
||||||
paragraphs: [
|
|
||||||
"Fundamentals of Mechanics, Thermodynamics, and Waves.",
|
|
||||||
"Topics include Newton’s Laws, Energy, and Thermodynamics."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── 2nd Year Subjects ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const YEAR_2_SUBJECTS = [
|
|
||||||
{
|
|
||||||
id: "y2-maths",
|
|
||||||
title: "Mathematics II",
|
|
||||||
author: "Prof. Alan Walker",
|
|
||||||
timestamp: "1 day ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: [
|
|
||||||
"Advanced Calculus, Linear Algebra, and Complex Analysis.",
|
|
||||||
"Topics include Eigenvalues, Eigenvectors, and Fourier Series."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "y2-electronics",
|
|
||||||
title: "Basic Electronics",
|
|
||||||
author: "Prof. Emma Stone",
|
|
||||||
timestamp: "Yesterday",
|
|
||||||
tag: "Electronics",
|
|
||||||
paragraphs: [
|
|
||||||
"Introduction to basic electrical circuits, components, and semiconductor devices.",
|
|
||||||
"Topics include Resistors, Capacitors, Diodes, and Transistors."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── 3rd Year Subjects ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const YEAR_3_SUBJECTS = [
|
|
||||||
{
|
|
||||||
id: "y3-maths",
|
|
||||||
title: "Mathematics III",
|
|
||||||
author: "Prof. Robert Brown",
|
|
||||||
timestamp: "3 days ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: [
|
|
||||||
"Partial Differential Equations, Laplace Transforms, and Numerical Methods.",
|
|
||||||
"Topics include PDEs, Laplace Transforms, and Optimization Techniques."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "y3-physics",
|
|
||||||
title: "Physics III",
|
|
||||||
author: "Prof. Alice White",
|
|
||||||
timestamp: "Yesterday",
|
|
||||||
tag: "Physics",
|
|
||||||
paragraphs: [
|
|
||||||
"Quantum Mechanics, Relativity, and Solid-State Physics.",
|
|
||||||
"Topics include Schrödinger’s Equation, Quantum States, and Relativity."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── 4th Year Subjects ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const YEAR_4_SUBJECTS = [
|
|
||||||
{
|
|
||||||
id: "y4-electronics",
|
|
||||||
title: "Electronics Engineering Design",
|
|
||||||
author: "Prof. Kate Johnson",
|
|
||||||
timestamp: "1 week ago",
|
|
||||||
tag: "Electronics",
|
|
||||||
paragraphs: [
|
|
||||||
"Project-based learning and design of electronic systems.",
|
|
||||||
"Topics include Embedded Systems, PCB Design, and Signal Processing."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "y4-physics",
|
|
||||||
title: "Physics IV",
|
|
||||||
author: "Prof. Michael Harris",
|
|
||||||
timestamp: "5 days ago",
|
|
||||||
tag: "Physics",
|
|
||||||
paragraphs: [
|
|
||||||
"Advanced topics in Particle Physics, Astrophysics, and Nuclear Physics.",
|
|
||||||
"Topics include Quantum Field Theory, Astrophysics, and Particle Accelerators."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── Subscribed Feed ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const SUBSCRIBED_ITEMS: FeedItem[] = [
|
|
||||||
{
|
|
||||||
id: "s1",
|
|
||||||
title: "Mathematics I",
|
|
||||||
author: "Prof. John Doe",
|
|
||||||
timestamp: "2 hours ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: YEAR_1_SUBJECTS[0].paragraphs,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s2",
|
|
||||||
title: "Physics I",
|
|
||||||
author: "Prof. Jane Smith",
|
|
||||||
timestamp: "5 hours ago",
|
|
||||||
tag: "Physics",
|
|
||||||
paragraphs: YEAR_1_SUBJECTS[1].paragraphs,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s3",
|
|
||||||
title: "Mathematics II",
|
|
||||||
author: "Prof. Alan Walker",
|
|
||||||
timestamp: "1 day ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: YEAR_2_SUBJECTS[0].paragraphs,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s4",
|
|
||||||
title: "Basic Electronics",
|
|
||||||
author: "Prof. Emma Stone",
|
|
||||||
timestamp: "Yesterday",
|
|
||||||
tag: "Electronics",
|
|
||||||
paragraphs: YEAR_2_SUBJECTS[1].paragraphs,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "s5",
|
|
||||||
title: "Mathematics III",
|
|
||||||
author: "Prof. Robert Brown",
|
|
||||||
timestamp: "3 days ago",
|
|
||||||
tag: "Mathematics",
|
|
||||||
paragraphs: YEAR_3_SUBJECTS[0].paragraphs,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ── All Feed: Years & Subjects ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const ALL_CATEGORIES: Category[] = [
|
|
||||||
{
|
|
||||||
id: "cat-year-1",
|
|
||||||
label: "1st Year",
|
|
||||||
icon: "book-outline",
|
|
||||||
color: "#3B7DD8",
|
|
||||||
darkColor: "#5E9EF4",
|
|
||||||
items: YEAR_1_SUBJECTS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cat-year-2",
|
|
||||||
label: "2nd Year",
|
|
||||||
icon: "book-outline",
|
|
||||||
color: "#2E9E6B",
|
|
||||||
darkColor: "#4EC992",
|
|
||||||
items: YEAR_2_SUBJECTS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cat-year-3",
|
|
||||||
label: "3rd Year",
|
|
||||||
icon: "book-outline",
|
|
||||||
color: "#9B4FB8",
|
|
||||||
darkColor: "#C47CE0",
|
|
||||||
items: YEAR_3_SUBJECTS,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "cat-year-4",
|
|
||||||
label: "4th Year",
|
|
||||||
icon: "book-outline",
|
|
||||||
color: "#C4622D",
|
|
||||||
darkColor: "#E07B45",
|
|
||||||
items: YEAR_4_SUBJECTS,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,85 +1,80 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView,
|
View, Text, ScrollView, TouchableOpacity,
|
||||||
ActivityIndicator, useColorScheme, TouchableOpacity,
|
ActivityIndicator, useColorScheme,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import CollapsibleCategory, { CollapsibleCategoryProps } from "../components/CollapsibleCategory";
|
import CollapsibleCategory from "../components/CollapsibleCategory";
|
||||||
import { entriesApi } from "../services/api";
|
|
||||||
|
|
||||||
// Cycles through as many groups as the API returns
|
|
||||||
const PALETTE: Pick<CollapsibleCategoryProps, "icon" | "color" | "darkColor">[] = [
|
const GROUPS = [
|
||||||
{ icon: "book-outline", color: "#3B7DD8", darkColor: "#5E9EF4" },
|
{ id: "1-year", label: "Prva godina", groupName: "Прва година", icon: "book-outline" as const, color: "#3B7DD8", darkColor: "#5E9EF4" },
|
||||||
{ icon: "book-outline", color: "#2E9E6B", darkColor: "#4EC992" },
|
{ id: "2-year", label: "Druga godina", groupName: "Друга година", icon: "book-outline" as const, color: "#2E9E6B", darkColor: "#4EC992" },
|
||||||
{ icon: "book-outline", color: "#9B4FB8", darkColor: "#C47CE0" },
|
{ id: "3-year", label: "Treća godina", groupName: "Трећа година", icon: "book-outline" as const, color: "#9B4FB8", darkColor: "#C47CE0" },
|
||||||
{ icon: "book-outline", color: "#C4622D", darkColor: "#E07B45" },
|
{ id: "4-year", label: "Četvrta godina", groupName: "Четврта година", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" },
|
||||||
{ icon: "book-outline", color: "#B8834F", darkColor: "#D4A574" },
|
|
||||||
{ icon: "book-outline", color: "#2E7D9E", darkColor: "#4EA8C9" },
|
{ id: "2-cycle", label: "Drugi ciklus", groupName: "Други циклус", icon: "book-outline" as const, color: "#3B7DD8", darkColor: "#5E9EF4" },
|
||||||
|
{ id: "3-cycle", label: "Treći ciklus", groupName: "Трећи циклус", icon: "book-outline" as const, color: "#2E9E6B", darkColor: "#4EC992" },
|
||||||
|
{ id: "postgraduate", label: "Postdiplomski studij", groupName: "Постдипломски студиј", icon: "book-outline" as const, color: "#9B4FB8", darkColor: "#C47CE0" },
|
||||||
|
// Tbh need to fix something on the server side for some reason its grouping defence as third ciclus but ill fix it later if i feel like it for now idc
|
||||||
|
//{ id: "defenses", label: "Odbrane zavrsšnih radova", groupName: "Одбране завршних радова", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" },
|
||||||
|
{ id: "defenses", label: "Odbrane završnih radova", groupName: "Трећи циклус", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
export default function AllFeed() {
|
export default function AllFeed() {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
const [groups, setGroups] = useState<string[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const loadGroups = () => {
|
const visibleGroups = query.trim()
|
||||||
setLoading(true);
|
? GROUPS.filter((g) => g.label.toLowerCase().includes(query.toLowerCase()))
|
||||||
setError("");
|
: GROUPS;
|
||||||
entriesApi.getGroups()
|
|
||||||
.then(setGroups)
|
const handleRefresh = () => {
|
||||||
.catch(() => setError("Couldn't load groups."))
|
setRefreshing(true);
|
||||||
.finally(() => setLoading(false));
|
setRefreshKey((k) => k + 1);
|
||||||
|
setTimeout(() => setRefreshing(false), 800);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { loadGroups(); }, []);
|
|
||||||
|
|
||||||
const visibleGroups = useMemo(() => {
|
|
||||||
if (!query.trim()) return groups;
|
|
||||||
const lower = query.toLowerCase();
|
|
||||||
return groups.filter((g) => g.toLowerCase().includes(lower));
|
|
||||||
}, [query, groups]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
|
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||||
<View className="px-4 pt-2 pb-1">
|
<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"}`}>
|
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
Discover
|
Discover
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
{loading ? "Loading…" : `${groups.length} groups`}
|
{GROUPS.length} groups
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<SearchBar placeholder="Search groups…" onSearch={setQuery} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* States */}
|
{/* Refresh button */}
|
||||||
{loading ? (
|
|
||||||
<View className="flex-1 items-center justify-center">
|
|
||||||
<ActivityIndicator color={dark ? "#E07B45" : "#C4622D"} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
) : error ? (
|
|
||||||
<View className="flex-1 items-center justify-center px-8">
|
|
||||||
<Text className="text-4xl mb-3">⚠️</Text>
|
|
||||||
<Text className={`text-sm font-sans text-center mb-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={loadGroups}
|
onPress={handleRefresh}
|
||||||
className="px-5 py-2.5 rounded-xl"
|
disabled={refreshing}
|
||||||
style={{ backgroundColor: dark ? "#E07B45" : "#C4622D" }}
|
className={`w-9 h-9 rounded-xl items-center justify-center ${
|
||||||
|
dark ? "bg-obsidian-50" : "bg-parchment-200"
|
||||||
|
}`}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Text className="text-sm font-sans font-semibold text-white">Retry</Text>
|
{refreshing
|
||||||
|
? <ActivityIndicator size="small" color={accent} />
|
||||||
|
: <Ionicons name="refresh-outline" size={18} color={accent} />
|
||||||
|
}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
) : (
|
<SearchBar placeholder="Search groups…" onSearch={setQuery} />
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 40 }}
|
contentContainerStyle={{ paddingTop: 16, paddingBottom: 40 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
@ -89,26 +84,19 @@ export default function AllFeed() {
|
|||||||
<View className="items-center mt-16 px-8">
|
<View className="items-center mt-16 px-8">
|
||||||
<Text className="text-4xl mb-3">🔍</Text>
|
<Text className="text-4xl mb-3">🔍</Text>
|
||||||
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
No groups match "{query}"
|
No groups match "{query}"
|
||||||
</Text>
|
|
||||||
<Text className={`text-sm font-sans text-center mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
|
||||||
Try a different search term.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
visibleGroups.map((groupName, index) => (
|
visibleGroups.map((group, index) => (
|
||||||
<CollapsibleCategory
|
<CollapsibleCategory
|
||||||
key={groupName}
|
key={group.id}
|
||||||
id={groupName}
|
{...group}
|
||||||
label={groupName}
|
refreshKey={refreshKey}
|
||||||
groupName={groupName}
|
|
||||||
defaultOpen={index === 0}
|
|
||||||
{...PALETTE[index % PALETTE.length]}
|
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -10,7 +10,8 @@ import { useAuth } from "../context/AuthContext";
|
|||||||
|
|
||||||
type Mode = "login" | "register";
|
type Mode = "login" | "register";
|
||||||
|
|
||||||
export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { const dark = useColorScheme() === "dark";
|
export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
const { login, register } = useAuth();
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>("login");
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
@ -31,7 +32,7 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
try {
|
try {
|
||||||
if (mode === "login") await login({ email, password });
|
if (mode === "login") await login({ email, password });
|
||||||
else await register({ email, password, name });
|
else await register({ email, password, name });
|
||||||
onSuccess?.(); // ← add this
|
onSuccess?.();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message ?? "Something went wrong.");
|
setError(e.message ?? "Something went wrong.");
|
||||||
} finally {
|
} finally {
|
||||||
@ -80,8 +81,7 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
|
|||||||
|
|
||||||
{/* Card */}
|
{/* Card */}
|
||||||
<View
|
<View
|
||||||
className={`rounded-2xl p-5 border ${
|
className={`rounded-2xl p-5 border ${dark
|
||||||
dark
|
|
||||||
? "bg-obsidian-100 border-border-dark"
|
? "bg-obsidian-100 border-border-dark"
|
||||||
: "bg-surface-light border-border-light"
|
: "bg-surface-light border-border-light"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
185
screens/EditProfile.tsx
Normal file
185
screens/EditProfile.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity,
|
||||||
|
ActivityIndicator, ScrollView, useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function EditProfile({ onClose }: { onClose: () => void }) {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
const { user, updateUser } = useAuth();
|
||||||
|
|
||||||
|
const [newEmail, setNewEmail] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPass, setConfirmPass] = useState("");
|
||||||
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
const inputClass = `rounded-xl px-4 py-3.5 text-sm font-sans border ${
|
||||||
|
dark
|
||||||
|
? "bg-obsidian-100 border-border-dark text-ink-dark"
|
||||||
|
: "bg-parchment-100 border-border-light text-ink-light"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
|
||||||
|
if (newPassword && newPassword !== confirmPass) {
|
||||||
|
setError("Passwords don't match.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newEmail && !newPassword) {
|
||||||
|
setError("Enter a new email or password to update.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateUser(
|
||||||
|
newEmail || undefined,
|
||||||
|
newPassword || undefined,
|
||||||
|
);
|
||||||
|
setSuccess("Profile updated successfully.");
|
||||||
|
setNewEmail("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPass("");
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Update failed. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className={`flex-row items-center px-4 py-4 border-b ${
|
||||||
|
dark ? "border-border-dark" : "border-border-light"
|
||||||
|
}`}>
|
||||||
|
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
Edit Profile
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Current email (read-only) */}
|
||||||
|
<View className={`mb-5 px-4 py-3.5 rounded-xl border ${
|
||||||
|
dark ? "bg-obsidian-50 border-border-dark" : "bg-parchment-200 border-border-light"
|
||||||
|
}`}>
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
CURRENT EMAIL
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Change email
|
||||||
|
TODO fix this show if email is taken and there is also a bug when trying to
|
||||||
|
change email many times ill fix it later if i feel like it or someone else can
|
||||||
|
*/}
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
NEW EMAIL
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`${inputClass} mb-5`}
|
||||||
|
placeholder="Leave blank to keep current"
|
||||||
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
|
value={newEmail}
|
||||||
|
onChangeText={setNewEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<View className={`h-px mb-5 ${dark ? "bg-border-dark" : "bg-border-light"}`} />
|
||||||
|
|
||||||
|
{/* New password */}
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
NEW PASSWORD
|
||||||
|
</Text>
|
||||||
|
<View className="relative mb-3">
|
||||||
|
<TextInput
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Leave blank to keep current"
|
||||||
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
|
value={newPassword}
|
||||||
|
onChangeText={setNewPassword}
|
||||||
|
secureTextEntry={!showPass}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowPass((v) => !v)}
|
||||||
|
className="absolute right-3 top-3.5"
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={showPass ? "eye-off-outline" : "eye-outline"}
|
||||||
|
size={18}
|
||||||
|
color={dark ? "#706D67" : "#8A8278"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Confirm password */}
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
CONFIRM NEW PASSWORD
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={`${inputClass} mb-5`}
|
||||||
|
placeholder="Repeat new password"
|
||||||
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
|
value={confirmPass}
|
||||||
|
onChangeText={setConfirmPass}
|
||||||
|
secureTextEntry={!showPass}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error !== "" && (
|
||||||
|
<View className="mb-4 px-3 py-2.5 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||||
|
<Text className="text-xs font-sans text-red-500">{error}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{success !== "" && (
|
||||||
|
<View className="mb-4 px-3 py-2.5 rounded-xl"
|
||||||
|
style={{ backgroundColor: dark ? "#2E9E6B20" : "#2E9E6B15",
|
||||||
|
borderWidth: 1, borderColor: "#2E9E6B30" }}>
|
||||||
|
<Text className="text-xs font-sans" style={{ color: "#2E9E6B" }}>
|
||||||
|
{success}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={save}
|
||||||
|
disabled={loading}
|
||||||
|
className="py-4 rounded-2xl items-center"
|
||||||
|
style={{ backgroundColor: accent }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#fff" />
|
||||||
|
: <Text className="text-sm font-sans font-semibold text-white">Save changes</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
screens/HelpSupport.tsx
Normal file
174
screens/HelpSupport.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
View, Text, TouchableOpacity,
|
||||||
|
Linking, ScrollView, useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
const CONTACT_EMAIL = "contact@ksan.dev";
|
||||||
|
//TODO make github page because yea
|
||||||
|
const GITHUB_URL = "https://git.ksan.dev/ksan";
|
||||||
|
const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export default function HelpSupport({ onClose }: { onClose: () => void }) {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
|
const contactLinks = [
|
||||||
|
{
|
||||||
|
icon: "mail-outline" as const,
|
||||||
|
label: "Contact support",
|
||||||
|
sub: CONTACT_EMAIL,
|
||||||
|
color: dark ? "#5E9EF4" : "#3B7DD8",
|
||||||
|
onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: "logo-github" as const,
|
||||||
|
label: "Submit a fix on GitHub",
|
||||||
|
sub: "Open a pull request or report a bug",
|
||||||
|
color: dark ? "#4EC992" : "#2E9E6B",
|
||||||
|
onPress: () => Linking.openURL(GITHUB_URL),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const aboutRows = [
|
||||||
|
{ label: "Version", value: APP_VERSION },
|
||||||
|
{ label: "Platform", value: "React Native / Expo" },
|
||||||
|
{ label: "Source code", value: "GitHub", onPress: () => Linking.openURL(GITHUB_URL) },
|
||||||
|
{ label: "Contact", value: CONTACT_EMAIL, onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className={`flex-row items-center px-4 py-4 border-b ${dark ? "border-border-dark" : "border-border-light"
|
||||||
|
}`}>
|
||||||
|
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
Help & Support
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* ── Contact section ── */}
|
||||||
|
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||||||
|
}`}>
|
||||||
|
Contact
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className={`px-1 text-sm font-sans leading-relaxed mb-4 ${dark ? "text-muted-dark" : "text-muted-light"
|
||||||
|
}`}>
|
||||||
|
Need help or found something broken? Reach out directly or open an issue on GitHub.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View className={`rounded-2xl overflow-hidden border mb-6 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||||
|
}`}>
|
||||||
|
{contactLinks.map((link, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={link.label}
|
||||||
|
onPress={link.onPress}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
className={`flex-row items-center px-4 py-4 ${index < contactLinks.length - 1
|
||||||
|
? `border-b ${dark ? "border-border-dark" : "border-border-light"}`
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-10 h-10 rounded-xl items-center justify-center mr-3"
|
||||||
|
style={{ backgroundColor: link.color + "20" }}
|
||||||
|
>
|
||||||
|
<Ionicons name={link.icon} size={20} color={link.color} />
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className={`text-sm font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{link.label}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{link.sub}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons name="open-outline" size={15} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* About section */}
|
||||||
|
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||||||
|
}`}>
|
||||||
|
About
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* App identity card */}
|
||||||
|
<View
|
||||||
|
className={`items-center py-6 rounded-2xl border mb-4 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-16 h-16 rounded-2xl items-center justify-center mb-3"
|
||||||
|
style={{ backgroundColor: accent + "20" }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 32 }}>📰</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={`text-lg font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
ETF Oglasi
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-xs font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
Version {APP_VERSION}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* About rows */}
|
||||||
|
<View className={`rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||||
|
}`}>
|
||||||
|
{aboutRows.map((row, index) => {
|
||||||
|
const isLast = index === aboutRows.length - 1;
|
||||||
|
const Inner = (
|
||||||
|
<View className={`flex-row items-center px-4 py-3.5 ${!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
|
||||||
|
}`}>
|
||||||
|
<Text className={`flex-1 text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{row.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className="text-sm font-sans font-semibold"
|
||||||
|
style={{ color: row.onPress ? accent : dark ? "#F0EDE8" : "#1A1714" }}
|
||||||
|
>
|
||||||
|
{row.value}
|
||||||
|
</Text>
|
||||||
|
{row.onPress && (
|
||||||
|
<Ionicons
|
||||||
|
name="open-outline"
|
||||||
|
size={13}
|
||||||
|
color={accent}
|
||||||
|
style={{ marginLeft: 4 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return row.onPress ? (
|
||||||
|
<TouchableOpacity key={row.label} onPress={row.onPress} activeOpacity={0.7}>
|
||||||
|
{Inner}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : (
|
||||||
|
<View key={row.label}>{Inner}</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Footer note */}
|
||||||
|
<Text className={`text-center text-xs font-sans mt-6 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
Built with ♥ — contributions welcome on GitHub
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
screens/ManageSubscriptions.tsx
Normal file
130
screens/ManageSubscriptions.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useEffect, 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 { Subject, subjectsApi } from "../services/api";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export default function ManageSubscriptions({ onClose }: { onClose: () => void }) {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
|
const { user, isSubscribed, subscribe, unsubscribe } = useAuth();
|
||||||
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [toggling, setToggling] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
subjectsApi.getAll(user?.token)
|
||||||
|
.then(setSubjects)
|
||||||
|
.catch(() => setError("Couldn't load subjects."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = async (subject: Subject) => {
|
||||||
|
setToggling(subject.id);
|
||||||
|
try {
|
||||||
|
if (isSubscribed(subject.id)) await unsubscribe(subject.id);
|
||||||
|
else await subscribe(subject.id);
|
||||||
|
} finally {
|
||||||
|
setToggling(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<View className={`flex-row items-center px-4 py-4 border-b ${
|
||||||
|
dark ? "border-border-dark" : "border-border-light"
|
||||||
|
}`}>
|
||||||
|
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
Manage Subscriptions
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onClose}
|
||||||
|
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{loading ? (
|
||||||
|
<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 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View className={`rounded-2xl overflow-hidden border ${
|
||||||
|
dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||||
|
}`}>
|
||||||
|
{subjects.map((subject, index) => {
|
||||||
|
const subscribed = isSubscribed(subject.id);
|
||||||
|
const busy = toggling === subject.id;
|
||||||
|
const isLast = index === subjects.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={subject.id}
|
||||||
|
className={`flex-row items-center px-4 py-3.5 ${
|
||||||
|
!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Subscribed indicator dot */}
|
||||||
|
<View
|
||||||
|
className="w-2 h-2 rounded-full mr-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: subscribed ? accent : "transparent",
|
||||||
|
borderWidth: subscribed ? 0 : 1.5,
|
||||||
|
borderColor: dark ? "#706D67" : "#8A8278",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{subject.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => toggle(subject)}
|
||||||
|
disabled={!!toggling}
|
||||||
|
className="px-3 py-1.5 rounded-xl"
|
||||||
|
style={{
|
||||||
|
backgroundColor: subscribed
|
||||||
|
? dark ? "#2C2A27" : "#F0EDE8"
|
||||||
|
: accent + "20",
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator size="small" color={accent} style={{ width: 40 }} />
|
||||||
|
) : (
|
||||||
|
<Text className="text-xs font-sans font-semibold" style={{ color: accent }}>
|
||||||
|
{subscribed ? "Unsubscribe" : "Subscribe"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
View, Text, ScrollView, TouchableOpacity,
|
View, Text, ScrollView, TouchableOpacity,
|
||||||
Switch, Modal, useColorScheme,
|
Switch, Modal, useColorScheme,
|
||||||
@ -7,8 +7,10 @@ import { SafeAreaView } from "react-native-safe-area-context";
|
|||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import AuthGate from "./AuthGate";
|
import AuthGate from "./AuthGate";
|
||||||
|
import ManageSubscriptions from "@/screens/ManageSubscriptions";
|
||||||
|
import EditProfile from "@/screens/EditProfile";
|
||||||
|
import HelpSupport from "@/screens/HelpSupport";
|
||||||
|
|
||||||
// ─── Sub-components ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SettingRowProps = {
|
type SettingRowProps = {
|
||||||
icon: any;
|
icon: any;
|
||||||
@ -65,13 +67,14 @@ function SectionLabel({ title }: { title: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Guest profile (logged out) ────────────────────────────────────────────
|
|
||||||
|
|
||||||
function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const accent = dark ? "#E07B45" : "#C4622D";
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||||
{/* Avatar block */}
|
{/* Avatar block */}
|
||||||
<View
|
<View
|
||||||
@ -84,10 +87,7 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
|||||||
elevation: 3,
|
elevation: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Anonymous avatar */}
|
<View className={`w-20 h-20 rounded-full items-center justify-center mb-3 ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}>
|
||||||
<View
|
|
||||||
className={`w-20 h-20 rounded-full items-center justify-center mb-3 ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
|
||||||
>
|
|
||||||
<Ionicons name="person-outline" size={36} color={dark ? "#706D67" : "#8A8278"} />
|
<Ionicons name="person-outline" size={36} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
</View>
|
</View>
|
||||||
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
@ -99,12 +99,19 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Settings available to guests */}
|
{/* Settings available to guests */}
|
||||||
<View
|
<View className={`mx-4 mt-5 rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||||
className={`mx-4 mt-5 rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"}`}
|
}`}>
|
||||||
>
|
|
||||||
<SectionLabel title="General" />
|
<SectionLabel title="General" />
|
||||||
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => {}} />
|
<SettingRow
|
||||||
<SettingRow icon="information-circle-outline" label="About" onPress={() => {}} />
|
icon="help-circle-outline"
|
||||||
|
label="Help & support"
|
||||||
|
onPress={() => setShowHelp(true)}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="information-circle-outline"
|
||||||
|
label="About"
|
||||||
|
onPress={() => setShowHelp(true)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Sign in / Register CTA */}
|
{/* Sign in / Register CTA */}
|
||||||
@ -126,18 +133,29 @@ function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
|||||||
You can still browse and save locally without an account.
|
You can still browse and save locally without an account.
|
||||||
</Text>
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showHelp}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={() => setShowHelp(false)}
|
||||||
|
>
|
||||||
|
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Authenticated profile (logged in) ────────────────────────────────────
|
|
||||||
|
|
||||||
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||||
|
const [showSubs, setShowSubs] = useState(false);
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [notifications, setNotifications] = React.useState(true);
|
const [notifications, setNotifications] = React.useState(true);
|
||||||
const [digest, setDigest] = React.useState(false);
|
const [digest, setDigest] = React.useState(false);
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||||
{/* Avatar + name block */}
|
{/* Avatar + name block */}
|
||||||
<View
|
<View
|
||||||
@ -162,20 +180,14 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
|||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<View className="flex-row mt-5 gap-8">
|
<View className="flex-row mt-5 gap-8">
|
||||||
{[
|
<View className="items-center">
|
||||||
{ label: "Subscribed", value: "5" },
|
|
||||||
{ label: "Read", value: "128" },
|
|
||||||
{ label: "Saved", value: "34" },
|
|
||||||
].map((stat) => (
|
|
||||||
<View key={stat.label} className="items-center">
|
|
||||||
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
{stat.value}
|
{user?.subscribedSubjectIds?.length ?? 0}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
{stat.label}
|
Subscribed
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -185,25 +197,27 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
|||||||
>
|
>
|
||||||
<SectionLabel title="Notifications" />
|
<SectionLabel title="Notifications" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="notifications-outline" label="Push notifications"
|
icon="notifications-outline" label="Push notifications (TODO)"
|
||||||
toggle toggleValue={notifications} onToggle={setNotifications}
|
toggle toggleValue={notifications} onToggle={setNotifications}
|
||||||
accent={dark ? "#E07B45" : "#C4622D"}
|
accent={dark ? "#E07B45" : "#C4622D"}
|
||||||
/>
|
/>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="mail-outline" label="Daily digest email"
|
icon="mail-outline" label="Daily digest email (TODO)"
|
||||||
toggle toggleValue={digest} onToggle={setDigest}
|
toggle toggleValue={digest} onToggle={setDigest}
|
||||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionLabel title="Subscriptions" />
|
<SectionLabel title="Subscriptions" />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
icon="bookmark-outline" label="Manage subscriptions"
|
icon="bookmark-outline"
|
||||||
onPress={() => {}} accent={dark ? "#4EC992" : "#2E9E6B"}
|
label="Manage subscriptions"
|
||||||
|
onPress={() => setShowSubs(true)}
|
||||||
|
accent={dark ? "#4EC992" : "#2E9E6B"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SectionLabel title="Account" />
|
<SectionLabel title="Account" />
|
||||||
<SettingRow icon="person-outline" label="Edit profile" onPress={() => {}} />
|
<SettingRow icon="person-outline" label="Edit profile" onPress={() => setShowEdit(true)} />
|
||||||
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => {}} />
|
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => setShowHelp(true)} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Sign out */}
|
{/* Sign out */}
|
||||||
@ -217,11 +231,31 @@ function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
|||||||
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
||||||
|
<EditProfile onClose={() => setShowEdit(false)} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
||||||
|
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
visible={showSubs}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={() => setShowSubs(false)}
|
||||||
|
>
|
||||||
|
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Root export ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
@ -231,7 +265,11 @@ export default function Profile() {
|
|||||||
return (
|
return (
|
||||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
{user
|
{user
|
||||||
? <AuthenticatedProfile onSignOut={logout} />
|
? <AuthenticatedProfile onSignOut={logout}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
: <GuestProfile onSignIn={() => setShowAuth(true)} />
|
: <GuestProfile onSignIn={() => setShowAuth(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,92 +1,269 @@
|
|||||||
import React, { useState, useMemo } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity,
|
||||||
Text,
|
ActivityIndicator, useColorScheme,
|
||||||
ScrollView,
|
|
||||||
useColorScheme,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import ExpandableItem from "../components/ExpandableItem";
|
import ExpandableItem from "../components/ExpandableItem";
|
||||||
import { SUBSCRIBED_ITEMS } from "@/data/mockData";
|
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() {
|
export default function SubscribedFeed() {
|
||||||
const [query, setQuery] = useState("");
|
const dark = useColorScheme() === "dark";
|
||||||
const scheme = useColorScheme();
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
const dark = scheme === "dark";
|
const { user } = useAuth();
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const [entries, setEntries] = useState<Entry[]>([]);
|
||||||
if (!query.trim()) return SUBSCRIBED_ITEMS;
|
const [loading, setLoading] = useState(false);
|
||||||
const lower = query.toLowerCase();
|
const [error, setError] = useState("");
|
||||||
return SUBSCRIBED_ITEMS.filter(
|
const [query, setQuery] = useState("");
|
||||||
(item) =>
|
const [sortMode, setSortMode] = useState<SortMode>("time");
|
||||||
item.title.toLowerCase().includes(lower) ||
|
|
||||||
item.author.toLowerCase().includes(lower) ||
|
const fetchFeed = useCallback(async () => {
|
||||||
item.tag.toLowerCase().includes(lower)
|
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>
|
||||||
);
|
);
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
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
|
<View className="flex-1">
|
||||||
className={`border-b ${
|
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
dark ? "border-border-dark" : "border-border-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<View className="px-4 pt-2 pb-1">
|
|
||||||
<Text
|
|
||||||
className={`text-2xl font-display font-bold ${
|
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Your Feed
|
Your Feed
|
||||||
</Text>
|
</Text>
|
||||||
<Text
|
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
className={`text-xs mt-0.5 font-sans ${
|
{loading
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
? "Loading…"
|
||||||
}`}
|
: `${entries.length} entries · ${user.subscribedSubjectIds.length} subjects`}
|
||||||
>
|
|
||||||
{SUBSCRIBED_ITEMS.length} subscriptions · updated just now
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
<SearchBar
|
onPress={fetchFeed}
|
||||||
placeholder="Search your subscribed subjects…"
|
disabled={loading}
|
||||||
onSearch={setQuery}
|
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>
|
</View>
|
||||||
|
|
||||||
{/* Scrollable list */}
|
{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
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingTop: 12, paddingBottom: 32 }}
|
contentContainerStyle={{ paddingBottom: 32 }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardDismissMode="on-drag"
|
keyboardDismissMode="on-drag"
|
||||||
>
|
>
|
||||||
|
{/* Toggle */}
|
||||||
|
<View className="pt-3">
|
||||||
|
<SortToggle
|
||||||
|
mode={sortMode}
|
||||||
|
onChange={setSortMode}
|
||||||
|
dark={dark}
|
||||||
|
accent={accent}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<View className="items-center mt-16 px-8">
|
<View className="items-center mt-16 px-8">
|
||||||
<Text className="text-4xl mb-3">🔍</Text>
|
<Text className="text-4xl mb-3">🔍</Text>
|
||||||
<Text
|
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
className={`text-base font-sans font-semibold text-center ${
|
No results for "{query}"
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
No results for "{query}"
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className={`text-sm font-sans text-center mt-1 ${
|
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Try a different title, author, or topic tag.
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((item) => <ExpandableItem key={item.id} item={item} />)
|
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>
|
</ScrollView>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
111
services/api.tsx
111
services/api.tsx
@ -1,37 +1,84 @@
|
|||||||
const BASE_URL = "http://192.168.100.6:8080"; // TODO change this
|
import React from "react";
|
||||||
|
|
||||||
async function post<T>(path: string, body: object): Promise<T> {
|
const BASE_URL = "http://192.168.100.163:8080"; // TODO change this
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
function authHeader(token?: string) {
|
||||||
body: JSON.stringify(body),
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get<T>(path: string, params?: Record<string, any>): Promise<T> {
|
async function request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
token?: string,
|
||||||
|
body?: object,
|
||||||
|
params?: Record<string, any>,
|
||||||
|
): Promise<T> {
|
||||||
const url = new URL(`${BASE_URL}${path}`);
|
const url = new URL(`${BASE_URL}${path}`);
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const res = await fetch(url.toString());
|
const res = await fetch(url.toString(), {
|
||||||
|
method,
|
||||||
|
// @ts-ignore
|
||||||
|
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
||||||
|
...(body ? { body: JSON.stringify(body) } : {}),
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoginPayload = { email: string; password: string };
|
export function get<T>(path: string, token?: string, params?: Record<string, any>) {
|
||||||
export type RegisterPayload = { email: string; password: string;};
|
return request<T>("GET", path, token, undefined, params);
|
||||||
export type JwtResponse = { token: string; email: string; message: string };
|
}
|
||||||
export type UserDTO = { id: number; email: string; name?: string };
|
|
||||||
|
export function post<T>(path: string, body: object, token?: string) {
|
||||||
|
return request<T>("POST", path, token, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function put<T>(
|
||||||
|
path: string,
|
||||||
|
body?: object,
|
||||||
|
token?: string
|
||||||
|
) {
|
||||||
|
return request<T>("PUT", path, token, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function del<T>(path: string, token?: string) {
|
||||||
|
return request<T>("DELETE", path, token);
|
||||||
|
}
|
||||||
|
|
||||||
export type Subject = {
|
export type Subject = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JwtResponse = {
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
userId: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserDTO = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
subjectSet: Subject[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type UserUpdateDTO = {
|
||||||
|
email: string;
|
||||||
|
newEmail?: string;
|
||||||
|
password?: string;
|
||||||
|
subjectSet: { id: string }[]; // pass existing subscriptions so they aren't wiped
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type Entry = {
|
export type Entry = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -46,25 +93,43 @@ export type Entry = {
|
|||||||
export type SpringPage<T> = {
|
export type SpringPage<T> = {
|
||||||
content: T[];
|
content: T[];
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalElements: number;
|
|
||||||
last: boolean;
|
last: boolean;
|
||||||
number: number;
|
number: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type LoginPayload = { email: string; password: string };
|
||||||
|
export type RegisterPayload = { email: string; password: string; name?: string };
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||||
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||||
|
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
|
||||||
|
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const subjectsApi = {
|
||||||
|
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const subscriptionsApi = {
|
||||||
|
subscribe: (userId: string, subjectId: string, token: string) =>
|
||||||
|
put<UserDTO>(
|
||||||
|
`/users/${userId}/subjects/${subjectId}`,
|
||||||
|
{}, // body placeholder
|
||||||
|
token
|
||||||
|
),
|
||||||
|
|
||||||
|
unsubscribe: (userId: string, subjectId: string, token: string) =>
|
||||||
|
del<UserDTO>(
|
||||||
|
`/users/${userId}/subjects/${subjectId}`,
|
||||||
|
token
|
||||||
|
),
|
||||||
|
};
|
||||||
export const entriesApi = {
|
export const entriesApi = {
|
||||||
getGroups: () =>
|
|
||||||
get<string[]>("/groups"),
|
|
||||||
|
|
||||||
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||||
get<SpringPage<Entry>>("/entries", params),
|
get<SpringPage<Entry>>("/entries", undefined, params),
|
||||||
|
getEntry: (id: string) => get<Entry>(`/entries/${id}`),
|
||||||
getEntry: (id: string) =>
|
|
||||||
get<Entry>(`/entries/${id}`),
|
|
||||||
};
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user