added login and started adding rest api
This commit is contained in:
parent
74f7ed047e
commit
abd725c274
@ -2,58 +2,64 @@ import { Tabs } from "expo-router";
|
|||||||
import { useColorScheme } from "react-native";
|
import { useColorScheme } from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
const LIGHT = { bg: "#FFFFFF", border: "#E8E2D5", active: "#C4622D", inactive: "#8A8278" };
|
|
||||||
const DARK = { bg: "#161513", border: "#2C2A27", active: "#E07B45", inactive: "#706D67" };
|
|
||||||
|
const LIGHT_TAB = {
|
||||||
|
bg: "#FFFFFF",
|
||||||
|
border: "#E8E2D5",
|
||||||
|
active: "#C4622D",
|
||||||
|
inactive: "#8A8278",
|
||||||
|
};
|
||||||
|
const DARK_TAB = {
|
||||||
|
bg: "#161513",
|
||||||
|
border: "#2C2A27",
|
||||||
|
active: "#E07B45",
|
||||||
|
inactive: "#706D67",
|
||||||
|
};
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const scheme = useColorScheme();
|
const dark = useColorScheme() === "dark";
|
||||||
const dark = scheme === "dark";
|
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||||
const c = dark ? DARK : LIGHT;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={({ route }) => ({
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: c.bg,
|
backgroundColor: tab.bg,
|
||||||
borderTopColor: c.border,
|
borderTopColor: tab.border,
|
||||||
borderTopWidth: 1,
|
borderTopWidth: 1,
|
||||||
height: 64,
|
height: 60,
|
||||||
paddingBottom: 10,
|
paddingBottom: 8,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
tabBarActiveTintColor: c.active,
|
tabBarActiveTintColor: tab.active,
|
||||||
tabBarInactiveTintColor: c.inactive,
|
tabBarInactiveTintColor: tab.inactive,
|
||||||
tabBarLabelStyle: { fontSize: 11, fontWeight: "600" },
|
tabBarLabelStyle: {
|
||||||
}}
|
fontSize: 10,
|
||||||
|
fontWeight: "600",
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
tabBarIcon: ({ focused, color, size }) => {
|
||||||
|
const icons: Record<string, { active: any; inactive: any }> = {
|
||||||
|
index: { active: "bookmark", inactive: "bookmark-outline" },
|
||||||
|
all: { active: "compass", inactive: "compass-outline" },
|
||||||
|
profile: { active: "person-circle", inactive: "person-circle-outline" },
|
||||||
|
};
|
||||||
|
const set = icons[route.name] ?? icons.index;
|
||||||
|
return (
|
||||||
|
<Ionicons
|
||||||
|
name={focused ? set.active : set.inactive}
|
||||||
|
size={focused ? size + 1 : size}
|
||||||
|
color={color}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<Tabs.Screen
|
<Tabs.Screen name="index" options={{ title: "My Feed" }} />
|
||||||
name="index"
|
<Tabs.Screen name="all" options={{ title: "Discover" }} />
|
||||||
options={{
|
<Tabs.Screen name="profile" options={{ title: "Profile" }} />
|
||||||
title: "Subscribed",
|
|
||||||
tabBarIcon: ({ focused, color, size }) => (
|
|
||||||
<Ionicons name={focused ? "bookmark" : "bookmark-outline"} size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="all"
|
|
||||||
options={{
|
|
||||||
title: "All",
|
|
||||||
tabBarIcon: ({ focused, color, size }) => (
|
|
||||||
<Ionicons name={focused ? "compass" : "compass-outline"} size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Tabs.Screen
|
|
||||||
name="profile"
|
|
||||||
options={{
|
|
||||||
title: "Profile",
|
|
||||||
tabBarIcon: ({ focused, color, size }) => (
|
|
||||||
<Ionicons name={focused ? "person-circle" : "person-circle-outline"} size={size} color={color} />
|
|
||||||
),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,6 +1,14 @@
|
|||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import {AuthProvider} from "@/context/AuthContext";
|
||||||
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return <Stack screenOptions={{ headerShown: false }} />;
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<Stack screenOptions={{ headerShown: false }} />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@ -1,44 +1,72 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, TouchableOpacity,
|
||||||
Text,
|
ActivityIndicator, LayoutAnimation, useColorScheme,
|
||||||
TouchableOpacity,
|
|
||||||
Animated,
|
|
||||||
LayoutAnimation,
|
|
||||||
Platform,
|
|
||||||
UIManager,
|
|
||||||
useColorScheme,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import ExpandableItem, { FeedItem } from "./ExpandableItem";
|
import { Entry, entriesApi } from "../services/api";
|
||||||
|
import ExpandableItem from "./ExpandableItem";
|
||||||
|
|
||||||
if (Platform.OS === "android") {
|
// ─── Props ─────────────────────────────────────────────────────────────────
|
||||||
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
// items removed — now fetched from API using groupName
|
||||||
}
|
|
||||||
|
|
||||||
export interface Category {
|
export interface CollapsibleCategoryProps {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
color: string; // accent hex for the icon badge
|
color: string;
|
||||||
darkColor: string;
|
darkColor: string;
|
||||||
items: FeedItem[];
|
groupName: string; // passed to /entries?groupName=
|
||||||
}
|
|
||||||
|
|
||||||
interface CollapsibleCategoryProps {
|
|
||||||
category: Category;
|
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CollapsibleCategory({
|
export default function CollapsibleCategory({
|
||||||
category,
|
label,
|
||||||
|
icon,
|
||||||
|
color,
|
||||||
|
darkColor,
|
||||||
|
groupName,
|
||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
}: CollapsibleCategoryProps) {
|
}: CollapsibleCategoryProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const dark = useColorScheme() === "dark";
|
||||||
const scheme = useColorScheme();
|
const accentColor = dark ? darkColor : color;
|
||||||
const dark = scheme === "dark";
|
|
||||||
|
|
||||||
const accentColor = dark ? category.darkColor : category.color;
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const [items, setItems] = useState<Entry[]>([]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const initialised = useRef(false);
|
||||||
|
|
||||||
|
const fetchPage = useCallback(async (pageNum: number) => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const data = await entriesApi.getEntries({ groupName, page: pageNum });
|
||||||
|
setItems((prev) =>
|
||||||
|
pageNum === 0 ? data.content : [...prev, ...data.content]
|
||||||
|
);
|
||||||
|
setHasMore(!data.last);
|
||||||
|
setPage(pageNum);
|
||||||
|
} catch {
|
||||||
|
setError("Couldn't load entries. Tap to retry.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupName, loading]);
|
||||||
|
|
||||||
|
// Fetch first page the first time the accordion opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !initialised.current) {
|
||||||
|
initialised.current = true;
|
||||||
|
fetchPage(0);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
LayoutAnimation.configureNext({
|
LayoutAnimation.configureNext({
|
||||||
@ -51,23 +79,23 @@ export default function CollapsibleCategory({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="mb-4">
|
<View className="mb-4">
|
||||||
|
{/* ── Header — untouched from your original ── */}
|
||||||
<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
|
mx-4 flex-row items-center px-4 py-3.5 rounded-2xl
|
||||||
${dark
|
${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"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Icon badge */}
|
|
||||||
<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"
|
||||||
style={{ backgroundColor: accentColor + "25" }}
|
style={{ backgroundColor: accentColor + "25" }}
|
||||||
>
|
>
|
||||||
<Ionicons name={category.icon} size={16} color={accentColor} />
|
<Ionicons name={icon} size={16} color={accentColor} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text
|
<Text
|
||||||
@ -75,20 +103,24 @@ export default function CollapsibleCategory({
|
|||||||
dark ? "text-ink-dark" : "text-ink-light"
|
dark ? "text-ink-dark" : "text-ink-light"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{category.label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Count badge */}
|
{/* Count badge — shows loaded count, spins while first load */}
|
||||||
<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" }}
|
||||||
>
|
>
|
||||||
<Text
|
{loading && items.length === 0 ? (
|
||||||
className="text-xs font-sans font-semibold"
|
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
|
||||||
style={{ color: accentColor }}
|
) : (
|
||||||
>
|
<Text
|
||||||
{category.items.length}
|
className="text-xs font-sans font-semibold"
|
||||||
</Text>
|
style={{ color: accentColor }}
|
||||||
|
>
|
||||||
|
{items.length}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Ionicons
|
<Ionicons
|
||||||
@ -98,12 +130,62 @@ export default function CollapsibleCategory({
|
|||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* Items */}
|
{/* ── Body ── */}
|
||||||
{open && (
|
{open && (
|
||||||
<View className="mt-2">
|
<View className="mt-2">
|
||||||
{category.items.map((item) => (
|
{/* Error */}
|
||||||
|
{error !== "" && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => fetchPage(page)}
|
||||||
|
className="mx-4 py-4 items-center"
|
||||||
|
>
|
||||||
|
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items — same ExpandableItem you already have */}
|
||||||
|
{items.map((item) => (
|
||||||
<ExpandableItem key={item.id} item={item} />
|
<ExpandableItem key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{hasMore && items.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => fetchPage(page + 1)}
|
||||||
|
disabled={loading}
|
||||||
|
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${
|
||||||
|
dark
|
||||||
|
? "bg-obsidian-50 border-border-dark"
|
||||||
|
: "bg-parchment-100 border-border-light"
|
||||||
|
}`}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color={accentColor} />
|
||||||
|
) : (
|
||||||
|
<View className="flex-row items-center gap-1.5">
|
||||||
|
<Ionicons name="chevron-down" size={13} color={accentColor} />
|
||||||
|
<Text
|
||||||
|
className="text-xs font-sans font-semibold"
|
||||||
|
style={{ color: accentColor }}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* End of list */}
|
||||||
|
{!hasMore && items.length > 0 && (
|
||||||
|
<Text className={`text-center text-xs font-sans py-3 ${
|
||||||
|
dark ? "text-muted-dark" : "text-muted-light"
|
||||||
|
}`}>
|
||||||
|
All {items.length} entries loaded
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@ -1,39 +1,34 @@
|
|||||||
import React, { useState, useRef } from "react";
|
import React, { useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
Animated, LayoutAnimation, Linking,
|
||||||
Text,
|
Platform, Text, TouchableOpacity,
|
||||||
TouchableOpacity,
|
UIManager, View, useColorScheme,
|
||||||
Animated,
|
|
||||||
LayoutAnimation,
|
|
||||||
Platform,
|
|
||||||
UIManager,
|
|
||||||
useColorScheme,
|
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Entry } from "../services/api";
|
||||||
|
|
||||||
// Enable LayoutAnimation on Android idk honestly probably ok?
|
|
||||||
if (Platform.OS === "android") {
|
if (Platform.OS === "android") {
|
||||||
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedItem {
|
function formatDate(iso: string): string {
|
||||||
id: string;
|
try {
|
||||||
title: string;
|
return new Date(iso).toLocaleDateString("en-GB", {
|
||||||
author: string;
|
day: "numeric", month: "short", year: "numeric",
|
||||||
timestamp: string;
|
});
|
||||||
tag: string;
|
} catch {
|
||||||
paragraphs: string[];
|
return iso;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExpandableItemProps {
|
function fileName(path: string): string {
|
||||||
item: FeedItem;
|
return path.split(/[\\/]/).pop() ?? path;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExpandableItem({ item }: ExpandableItemProps) {
|
export default function ExpandableItem({ item }: { item: Entry }) {
|
||||||
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 scheme = useColorScheme();
|
const dark = useColorScheme() === "dark";
|
||||||
const dark = scheme === "dark";
|
|
||||||
|
|
||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
LayoutAnimation.configureNext({
|
LayoutAnimation.configureNext({
|
||||||
@ -50,7 +45,7 @@ export default function ExpandableItem({ item }: ExpandableItemProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chevronRotation = rotateAnim.interpolate({
|
const chevronRotation = rotateAnim.interpolate({
|
||||||
inputRange: [0, 1],
|
inputRange: [0, 1],
|
||||||
outputRange: ["0deg", "180deg"],
|
outputRange: ["0deg", "180deg"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -58,96 +53,146 @@ export default function ExpandableItem({ item }: ExpandableItemProps) {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={toggle}
|
onPress={toggle}
|
||||||
className={`
|
className={`mx-4 mb-3 rounded-2xl overflow-hidden ${
|
||||||
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"
|
}`}
|
||||||
}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
shadowColor: dark ? "#000" : "#1A1714",
|
shadowColor: dark ? "#000" : "#1A1714",
|
||||||
shadowOpacity: dark ? 0.35 : 0.06,
|
shadowOpacity: dark ? 0.35 : 0.06,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 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">
|
||||||
{/* Tag + timestamp row */}
|
|
||||||
<View className="flex-row items-center mb-1 gap-2">
|
{/* Subject tag + date */}
|
||||||
<View
|
<View className="flex-row items-center mb-1.5 gap-2 flex-wrap">
|
||||||
className={`px-2 py-0.5 rounded-full ${
|
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||||
dark ? "bg-accent-dark/20" : "bg-accent/10"
|
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
|
||||||
}`}
|
{item.subject?.name ?? item.groupName}
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-xs font-sans font-semibold tracking-wide ${
|
|
||||||
dark ? "text-accent-dark" : "text-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.tag}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
|
||||||
className={`text-xs ${
|
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}>
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
<Text className={`text-xs font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
}`}
|
{item.groupName}
|
||||||
>
|
</Text>
|
||||||
{item.timestamp}
|
</View>
|
||||||
|
|
||||||
|
<Text className={`text-xs ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{formatDate(item.timePublished)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<Text
|
<Text
|
||||||
className={`text-base font-sans font-semibold leading-snug ${
|
className={`text-base font-sans font-semibold leading-snug ${dark ? "text-ink-dark" : "text-ink-light"}`}
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
numberOfLines={expanded ? undefined : 2}
|
numberOfLines={expanded ? undefined : 2}
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* Author */}
|
{/* info_entry — always visible as subtitle */}
|
||||||
<Text
|
{item.infoEntry ? (
|
||||||
className={`text-xs mt-1 ${
|
<Text
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
className={`text-xs mt-1 leading-relaxed ${dark ? "text-muted-dark" : "text-muted-light"}`}
|
||||||
}`}
|
numberOfLines={expanded ? undefined : 2}
|
||||||
>
|
>
|
||||||
by {item.author}
|
{item.infoEntry}
|
||||||
</Text>
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={{ transform: [{ rotate: chevronRotation }] }}>
|
<Animated.View style={{ transform: [{ rotate: chevronRotation }] }}>
|
||||||
<Ionicons
|
<Ionicons name="chevron-down" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
name="chevron-down"
|
|
||||||
size={18}
|
|
||||||
color={dark ? "#706D67" : "#8A8278"}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Expanded body */}
|
{/* ── Expanded body ── */}
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<View
|
<View className={`border-t ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||||
className={`px-4 pb-5 pt-1 border-t ${
|
|
||||||
dark ? "border-border-dark" : "border-border-light"
|
{/* paragraph */}
|
||||||
}`}
|
{item.paragraph ? (
|
||||||
>
|
<View className="px-4 pt-3 pb-2">
|
||||||
{item.paragraphs.map((para, i) => (
|
<Text className={`text-xs font-sans font-bold tracking-widest uppercase mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
<Text
|
Content
|
||||||
key={i}
|
</Text>
|
||||||
className={`text-sm font-sans leading-relaxed mb-3 last:mb-0 ${
|
<Text className={`text-sm font-sans leading-relaxed ${dark ? "text-ink-dark/85" : "text-ink-light/85"}`}>
|
||||||
dark ? "text-ink-dark/80" : "text-ink-light/80"
|
{item.paragraph}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Meta row: subject + group + date */}
|
||||||
|
<View className={`mx-4 my-3 p-3 rounded-xl ${dark ? "bg-obsidian-50" : "bg-parchment-100"}`}>
|
||||||
|
<Row label="Subject" value={item.subject?.name} dark={dark} />
|
||||||
|
<Row label="Group" value={item.groupName} dark={dark} />
|
||||||
|
<Row label="Published" value={formatDate(item.timePublished)} dark={dark} last />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* filepath — clickable attachment */}
|
||||||
|
{item.filepath ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => Linking.openURL(item.filepath!)}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${
|
||||||
|
dark
|
||||||
|
? "bg-obsidian-50 border-border-dark"
|
||||||
|
: "bg-parchment-100 border-border-light"
|
||||||
}`}
|
}`}
|
||||||
|
// stop the expand/collapse toggle firing
|
||||||
|
onStartShouldSetResponder={() => true}
|
||||||
>
|
>
|
||||||
{para}
|
<View className={`w-8 h-8 rounded-lg items-center justify-center ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||||
</Text>
|
<Ionicons
|
||||||
))}
|
name="document-attach-outline"
|
||||||
|
size={17}
|
||||||
|
color={dark ? "#E07B45" : "#C4622D"}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="flex-1">
|
||||||
|
<Text className={`text-xs font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{fileName(item.filepath)}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
Tap to open attachment
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Ionicons
|
||||||
|
name="open-outline"
|
||||||
|
size={15}
|
||||||
|
color={dark ? "#706D67" : "#8A8278"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Row({ label, value, dark, last = false }: {
|
||||||
|
label: string;
|
||||||
|
value?: string;
|
||||||
|
dark: boolean;
|
||||||
|
last?: boolean;
|
||||||
|
}) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<View className={`flex-row justify-between py-1.5 ${!last ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""}`}>
|
||||||
|
<Text className={`text-xs font-sans font-semibold ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-xs font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{value}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
58
context/AuthContext.tsx
Normal file
58
context/AuthContext.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
|
import { authApi, LoginPayload, RegisterPayload, JwtResponse } from "../services/api";
|
||||||
|
|
||||||
|
type User = { email: string; token: string };
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (p: LoginPayload) => Promise<void>;
|
||||||
|
register: (p: RegisterPayload) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Restore session on mount
|
||||||
|
useEffect(() => {
|
||||||
|
AsyncStorage.getItem("auth_user")
|
||||||
|
.then((raw) => { if (raw) setUser(JSON.parse(raw)); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const persist = async (u: User | null) => {
|
||||||
|
if (u) await AsyncStorage.setItem("auth_user", JSON.stringify(u));
|
||||||
|
else await AsyncStorage.removeItem("auth_user");
|
||||||
|
setUser(u);
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (payload: LoginPayload) => {
|
||||||
|
const res = await authApi.login(payload);
|
||||||
|
await persist({ email: res.email, token: res.token });
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (payload: RegisterPayload) => {
|
||||||
|
await authApi.register(payload);
|
||||||
|
// Auto-login after register
|
||||||
|
await login({ email: payload.email, password: payload.password });
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => persist(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
12
index.tsx
12
index.tsx
@ -13,6 +13,8 @@ import AllFeed from "@/screens/AllFeed";
|
|||||||
import Profile from "@/screens/Profile";
|
import Profile from "@/screens/Profile";
|
||||||
import {enableScreens} from "react-native-screens";
|
import {enableScreens} from "react-native-screens";
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
|
import AuthGate from "@/screens/AuthGate";
|
||||||
|
import {AuthProvider, useAuth} from "@/context/AuthContext";
|
||||||
|
|
||||||
|
|
||||||
// Must be called before any navigator renders
|
// Must be called before any navigator renders
|
||||||
@ -36,6 +38,11 @@ const DARK_TAB = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function ProfileTab() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
if (loading) return null;
|
||||||
|
return user ? <Profile /> : <AuthGate />;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const scheme = useColorScheme();
|
const scheme = useColorScheme();
|
||||||
@ -47,6 +54,7 @@ export default function App() {
|
|||||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<AuthProvider>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<NavigationContainer theme={navTheme}>
|
<NavigationContainer theme={navTheme}>
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
@ -104,11 +112,13 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<Tab.Screen
|
<Tab.Screen
|
||||||
name="Profile"
|
name="Profile"
|
||||||
component={Profile}
|
component={ProfileTab}
|
||||||
options={{ title: "Profile" }}
|
options={{ title: "Profile" }}
|
||||||
/>
|
/>
|
||||||
</Tab.Navigator>
|
</Tab.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
34
package-lock.json
generated
34
package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
@ -3108,6 +3109,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native-async-storage/async-storage": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"merge-options": "^3.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": "^0.0.0-0 || >=0.65 <1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-native/assets-registry": {
|
"node_modules/@react-native/assets-registry": {
|
||||||
"version": "0.81.5",
|
"version": "0.81.5",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
|
||||||
@ -8382,6 +8395,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-regex": {
|
"node_modules/is-regex": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
@ -9408,6 +9430,18 @@
|
|||||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/merge-options": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-obj": "^2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge-stream": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.0.3",
|
"@expo/vector-icons": "^15.0.3",
|
||||||
|
"@react-native-async-storage/async-storage": "2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^7.4.0",
|
"@react-navigation/bottom-tabs": "^7.4.0",
|
||||||
"@react-navigation/elements": "^2.6.3",
|
"@react-navigation/elements": "^2.6.3",
|
||||||
"@react-navigation/native": "^7.1.8",
|
"@react-navigation/native": "^7.1.8",
|
||||||
|
|||||||
@ -1,107 +1,114 @@
|
|||||||
import React, { useState, useMemo } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView,
|
||||||
Text,
|
ActivityIndicator, useColorScheme, TouchableOpacity,
|
||||||
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 SearchBar from "../components/SearchBar";
|
import SearchBar from "../components/SearchBar";
|
||||||
import CollapsibleCategory, { Category } from "../components/CollapsibleCategory";
|
import CollapsibleCategory, { CollapsibleCategoryProps } from "../components/CollapsibleCategory";
|
||||||
import { ALL_CATEGORIES } from "@/data/mockData";
|
import { entriesApi } from "../services/api";
|
||||||
|
|
||||||
|
// Cycles through as many groups as the API returns
|
||||||
|
const PALETTE: Pick<CollapsibleCategoryProps, "icon" | "color" | "darkColor">[] = [
|
||||||
|
{ icon: "book-outline", color: "#3B7DD8", darkColor: "#5E9EF4" },
|
||||||
|
{ icon: "book-outline", color: "#2E9E6B", darkColor: "#4EC992" },
|
||||||
|
{ icon: "book-outline", color: "#9B4FB8", darkColor: "#C47CE0" },
|
||||||
|
{ icon: "book-outline", color: "#C4622D", darkColor: "#E07B45" },
|
||||||
|
{ icon: "book-outline", color: "#B8834F", darkColor: "#D4A574" },
|
||||||
|
{ icon: "book-outline", color: "#2E7D9E", darkColor: "#4EA8C9" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function AllFeed() {
|
export default function AllFeed() {
|
||||||
const [query, setQuery] = useState("");
|
const dark = useColorScheme() === "dark";
|
||||||
const scheme = useColorScheme();
|
|
||||||
const dark = scheme === "dark";
|
|
||||||
|
|
||||||
const totalCount = ALL_CATEGORIES.reduce(
|
const [groups, setGroups] = useState<string[]>([]);
|
||||||
(sum, cat) => sum + cat.items.length,
|
const [loading, setLoading] = useState(true);
|
||||||
0
|
const [error, setError] = useState("");
|
||||||
);
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
// Filter items within each category based on search query
|
const loadGroups = () => {
|
||||||
const filteredCategories = useMemo((): Category[] => {
|
setLoading(true);
|
||||||
if (!query.trim()) return ALL_CATEGORIES;
|
setError("");
|
||||||
|
entriesApi.getGroups()
|
||||||
|
.then(setGroups)
|
||||||
|
.catch(() => setError("Couldn't load groups."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadGroups(); }, []);
|
||||||
|
|
||||||
|
const visibleGroups = useMemo(() => {
|
||||||
|
if (!query.trim()) return groups;
|
||||||
const lower = query.toLowerCase();
|
const lower = query.toLowerCase();
|
||||||
return ALL_CATEGORIES.map((cat) => ({
|
return groups.filter((g) => g.toLowerCase().includes(lower));
|
||||||
...cat,
|
}, [query, groups]);
|
||||||
items: cat.items.filter(
|
|
||||||
(item) =>
|
|
||||||
item.title.toLowerCase().includes(lower) ||
|
|
||||||
item.author.toLowerCase().includes(lower) ||
|
|
||||||
item.tag.toLowerCase().includes(lower) ||
|
|
||||||
cat.label.toLowerCase().includes(lower)
|
|
||||||
),
|
|
||||||
})).filter((cat) => cat.items.length > 0);
|
|
||||||
}, [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={`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">
|
||||||
<Text
|
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
className={`text-2xl font-display font-bold ${
|
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Discover
|
Discover
|
||||||
</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 ? "Loading…" : `${groups.length} groups`}
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ALL_CATEGORIES.length} categories · {totalCount} articles
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<SearchBar placeholder="Search groups…" onSearch={setQuery} />
|
||||||
<SearchBar
|
|
||||||
placeholder="Search all Subjects and Titles…"
|
|
||||||
onSearch={setQuery}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
{/* States */}
|
||||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 40 }}
|
{loading ? (
|
||||||
showsVerticalScrollIndicator={false}
|
<View className="flex-1 items-center justify-center">
|
||||||
keyboardDismissMode="on-drag"
|
<ActivityIndicator color={dark ? "#E07B45" : "#C4622D"} />
|
||||||
>
|
</View>
|
||||||
{filteredCategories.length === 0 ? (
|
|
||||||
<View className="items-center mt-16 px-8">
|
) : error ? (
|
||||||
<Text className="text-4xl mb-3">🔍</Text>
|
<View className="flex-1 items-center justify-center px-8">
|
||||||
<Text
|
<Text className="text-4xl mb-3">⚠️</Text>
|
||||||
className={`text-base font-sans font-semibold text-center ${
|
<Text className={`text-sm font-sans text-center mb-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
{error}
|
||||||
}`}
|
</Text>
|
||||||
>
|
<TouchableOpacity
|
||||||
No results for "{query}"
|
onPress={loadGroups}
|
||||||
</Text>
|
className="px-5 py-2.5 rounded-xl"
|
||||||
<Text
|
style={{ backgroundColor: dark ? "#E07B45" : "#C4622D" }}
|
||||||
className={`text-sm font-sans text-center mt-1 ${
|
>
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
<Text className="text-sm font-sans font-semibold text-white">Retry</Text>
|
||||||
}`}
|
</TouchableOpacity>
|
||||||
>
|
</View>
|
||||||
Try searching by category name, author, or topic.
|
|
||||||
</Text>
|
) : (
|
||||||
</View>
|
<ScrollView
|
||||||
) : (
|
contentContainerStyle={{ paddingTop: 16, paddingBottom: 40 }}
|
||||||
filteredCategories.map((cat, index) => (
|
showsVerticalScrollIndicator={false}
|
||||||
<CollapsibleCategory
|
keyboardDismissMode="on-drag"
|
||||||
key={cat.id}
|
>
|
||||||
category={cat}
|
{visibleGroups.length === 0 ? (
|
||||||
defaultOpen={index === 0} // first category open by default TODO maybe set option to change what is default
|
<View className="items-center mt-16 px-8">
|
||||||
/>
|
<Text className="text-4xl mb-3">🔍</Text>
|
||||||
))
|
<Text className={`text-base font-sans font-semibold text-center ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
)}
|
No groups match "{query}"
|
||||||
</ScrollView>
|
</Text>
|
||||||
|
<Text className={`text-sm font-sans text-center mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
Try a different search term.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
visibleGroups.map((groupName, index) => (
|
||||||
|
<CollapsibleCategory
|
||||||
|
key={groupName}
|
||||||
|
id={groupName}
|
||||||
|
label={groupName}
|
||||||
|
groupName={groupName}
|
||||||
|
defaultOpen={index === 0}
|
||||||
|
{...PALETTE[index % PALETTE.length]}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
185
screens/AuthGate.tsx
Normal file
185
screens/AuthGate.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import {
|
||||||
|
View, Text, TextInput, TouchableOpacity,
|
||||||
|
ActivityIndicator, KeyboardAvoidingView,
|
||||||
|
Platform, ScrollView, useColorScheme,
|
||||||
|
} from "react-native";
|
||||||
|
import { SafeAreaView } from "react-native-safe-area-context";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
type Mode = "login" | "register";
|
||||||
|
|
||||||
|
export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { const dark = useColorScheme() === "dark";
|
||||||
|
const { login, register } = useAuth();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showPass, setShowPass] = useState(false);
|
||||||
|
|
||||||
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setError("");
|
||||||
|
if (!email || !password) { setError("Please fill in all fields."); return; }
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (mode === "login") await login({ email, password });
|
||||||
|
else await register({ email, password, name });
|
||||||
|
onSuccess?.(); // ← add this
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Something went wrong.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputBase = `
|
||||||
|
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"}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ flexGrow: 1, justifyContent: "center", padding: 24 }}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Logo / header */}
|
||||||
|
<View className="items-center mb-8">
|
||||||
|
<View
|
||||||
|
className="w-16 h-16 rounded-2xl items-center justify-center mb-4"
|
||||||
|
style={{ backgroundColor: accent + "20" }}
|
||||||
|
>
|
||||||
|
<Text className="text-3xl">📰</Text>
|
||||||
|
</View>
|
||||||
|
<Text
|
||||||
|
className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}
|
||||||
|
>
|
||||||
|
{mode === "login" ? "Welcome back" : "Create account"}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
className={`text-sm font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}
|
||||||
|
>
|
||||||
|
{mode === "login"
|
||||||
|
? "Sign in to sync your subscriptions"
|
||||||
|
: "Start reading what matters"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<View
|
||||||
|
className={`rounded-2xl p-5 border ${
|
||||||
|
dark
|
||||||
|
? "bg-obsidian-100 border-border-dark"
|
||||||
|
: "bg-surface-light border-border-light"
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
shadowColor: dark ? "#000" : "#1A1714",
|
||||||
|
shadowOpacity: dark ? 0.3 : 0.06,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
|
||||||
|
<View className="mb-3">
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
EMAIL
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
className={inputBase}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="mb-5">
|
||||||
|
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
PASSWORD
|
||||||
|
</Text>
|
||||||
|
<View className="relative">
|
||||||
|
<TextInput
|
||||||
|
className={inputBase}
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry={!showPass}
|
||||||
|
autoComplete="password"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={submit}
|
||||||
|
disabled={loading}
|
||||||
|
className="py-3.5 rounded-xl items-center"
|
||||||
|
style={{ backgroundColor: accent }}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? <ActivityIndicator color="#fff" />
|
||||||
|
: <Text className="text-sm font-sans font-semibold text-white">
|
||||||
|
{mode === "login" ? "Sign in" : "Create account"}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Toggle mode */}
|
||||||
|
<View className="flex-row justify-center mt-5">
|
||||||
|
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{mode === "login" ? "Don't have an account? " : "Already have an account? "}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => { setMode(mode === "login" ? "register" : "login"); setError(""); }}>
|
||||||
|
<Text className="text-sm font-sans font-semibold" style={{ color: accent }}>
|
||||||
|
{mode === "login" ? "Register" : "Sign in"}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Continue as guest */}
|
||||||
|
<Text
|
||||||
|
className={`text-center text-xs font-sans mt-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}
|
||||||
|
>
|
||||||
|
You can still browse and save locally without signing in.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,17 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
View,
|
View, Text, ScrollView, TouchableOpacity,
|
||||||
Text,
|
Switch, Modal, useColorScheme,
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
useColorScheme,
|
|
||||||
Switch,
|
|
||||||
} 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 { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import AuthGate from "./AuthGate";
|
||||||
|
|
||||||
interface SettingRowProps {
|
// ─── Sub-components ────────────────────────────────────────────────────────
|
||||||
icon: keyof typeof Ionicons.glyphMap;
|
|
||||||
|
type SettingRowProps = {
|
||||||
|
icon: any;
|
||||||
label: string;
|
label: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
toggle?: boolean;
|
toggle?: boolean;
|
||||||
@ -19,34 +19,17 @@ interface SettingRowProps {
|
|||||||
onToggle?: (v: boolean) => void;
|
onToggle?: (v: boolean) => void;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
accent?: string;
|
accent?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
||||||
{/*// TODO add logic idk im still missing a home/login screen*/}
|
const dark = useColorScheme() === "dark";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function SettingRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
toggle,
|
|
||||||
toggleValue,
|
|
||||||
onToggle,
|
|
||||||
onPress,
|
|
||||||
accent,
|
|
||||||
}: SettingRowProps) {
|
|
||||||
const scheme = useColorScheme();
|
|
||||||
const dark = scheme === "dark";
|
|
||||||
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={onPress ? 0.7 : 1}
|
activeOpacity={onPress ? 0.7 : 1}
|
||||||
className={`flex-row items-center px-4 py-3.5 border-b ${
|
className={`flex-row items-center px-4 py-3.5 border-b ${dark ? "border-border-dark" : "border-border-light"}`}
|
||||||
dark ? "border-border-dark" : "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"
|
||||||
@ -54,37 +37,20 @@ function SettingRow({
|
|||||||
>
|
>
|
||||||
<Ionicons name={icon} size={16} color={iconColor} />
|
<Ionicons name={icon} size={16} color={iconColor} />
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
className={`flex-1 text-sm font-sans ${
|
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{toggle ? (
|
{toggle ? (
|
||||||
<Switch
|
<Switch
|
||||||
value={toggleValue}
|
value={toggleValue}
|
||||||
onValueChange={onToggle}
|
onValueChange={onToggle}
|
||||||
trackColor={{
|
trackColor={{ true: dark ? "#E07B45" : "#C4622D", false: dark ? "#2C2A27" : "#E8E2D5" }}
|
||||||
true: dark ? "#E07B45" : "#C4622D",
|
|
||||||
false: dark ? "#2C2A27" : "#E8E2D5",
|
|
||||||
}}
|
|
||||||
thumbColor="#FFFFFF"
|
thumbColor="#FFFFFF"
|
||||||
/>
|
/>
|
||||||
) : value ? (
|
) : value ? (
|
||||||
<Text
|
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>{value}</Text>
|
||||||
className={`text-sm font-sans ${
|
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
) : (
|
) : (
|
||||||
<Ionicons
|
<Ionicons name="chevron-forward" size={14} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
name="chevron-forward"
|
|
||||||
size={14}
|
|
||||||
color={dark ? "#706D67" : "#8A8278"}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
@ -93,167 +59,197 @@ function SettingRow({
|
|||||||
function SectionLabel({ title }: { title: string }) {
|
function SectionLabel({ title }: { title: string }) {
|
||||||
const dark = useColorScheme() === "dark";
|
const dark = useColorScheme() === "dark";
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${
|
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Profile() {
|
// ─── Guest profile (logged out) ────────────────────────────────────────────
|
||||||
const [notifications, setNotifications] = React.useState(true);
|
|
||||||
const [digest, setDigest] = React.useState(false);
|
function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
||||||
const scheme = useColorScheme();
|
const dark = useColorScheme() === "dark";
|
||||||
const dark = scheme === "dark";
|
const accent = dark ? "#E07B45" : "#C4622D";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||||
className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}
|
{/* Avatar block */}
|
||||||
>
|
<View
|
||||||
|
className={`items-center pt-8 pb-6 mx-4 mt-4 rounded-3xl ${dark ? "bg-obsidian-100" : "bg-surface-light"}`}
|
||||||
<ScrollView
|
style={{
|
||||||
contentContainerStyle={{ paddingBottom: 48 }}
|
shadowColor: dark ? "#000" : "#1A1714",
|
||||||
showsVerticalScrollIndicator={false}
|
shadowOpacity: dark ? 0.35 : 0.06,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 3,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Avatar + name block */}
|
{/* Anonymous avatar */}
|
||||||
<View
|
<View
|
||||||
className={`items-center pt-8 pb-6 mx-4 mt-4 rounded-3xl ${
|
className={`w-20 h-20 rounded-full items-center justify-center mb-3 ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||||
dark ? "bg-obsidian-100" : "bg-surface-light"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
shadowColor: dark ? "#000" : "#1A1714",
|
|
||||||
shadowOpacity: dark ? 0.35 : 0.06,
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowRadius: 10,
|
|
||||||
elevation: 3,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/*// TODO*/}
|
<Ionicons name="person-outline" size={36} color={dark ? "#706D67" : "#8A8278"} />
|
||||||
{/* Avatar placeholder */}
|
</View>
|
||||||
<View
|
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
className={`w-20 h-20 rounded-full items-center justify-center mb-3 ${
|
Guest
|
||||||
dark ? "bg-accent-dark/20" : "bg-accent/10"
|
</Text>
|
||||||
}`}
|
<Text className={`text-sm font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
>
|
Sign in to sync your reading
|
||||||
<Text className="text-3xl">📰</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
|
||||||
className={`text-xl font-display font-bold ${
|
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Get Name here idk
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
className={`text-sm font-sans mt-1 ${
|
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/*// TODO*/}
|
|
||||||
aaa@aaaq.com
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Settings available to guests */}
|
||||||
<View className="flex-row mt-5 gap-8">
|
<View
|
||||||
{[
|
className={`mx-4 mt-5 rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"}`}
|
||||||
//TODO add stats tracked
|
>
|
||||||
{ label: "Subscribed", value: "5" },
|
<SectionLabel title="General" />
|
||||||
{ label: "Read", value: "128" },
|
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => {}} />
|
||||||
{ label: "Saved", value: "34" },
|
<SettingRow icon="information-circle-outline" label="About" onPress={() => {}} />
|
||||||
].map((stat) => (
|
</View>
|
||||||
<View key={stat.label} className="items-center">
|
|
||||||
<Text
|
{/* Sign in / Register CTA */}
|
||||||
className={`text-xl font-display font-bold ${
|
<TouchableOpacity
|
||||||
dark ? "text-ink-dark" : "text-ink-light"
|
onPress={onSignIn}
|
||||||
}`}
|
className="mx-4 mt-4 py-4 rounded-2xl items-center"
|
||||||
>
|
style={{ backgroundColor: accent }}
|
||||||
{stat.value}
|
activeOpacity={0.8}
|
||||||
</Text>
|
>
|
||||||
<Text
|
<View className="flex-row items-center gap-2">
|
||||||
className={`text-xs font-sans mt-0.5 ${
|
<Ionicons name="log-in-outline" size={18} color="#fff" />
|
||||||
dark ? "text-muted-dark" : "text-muted-light"
|
<Text className="text-sm font-sans font-semibold text-white">
|
||||||
}`}
|
Sign in / Register
|
||||||
>
|
</Text>
|
||||||
{stat.label}
|
</View>
|
||||||
</Text>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
))}
|
<Text className={`text-center text-xs font-sans mt-3 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
</View>
|
You can still browse and save locally without an account.
|
||||||
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Authenticated profile (logged in) ────────────────────────────────────
|
||||||
|
|
||||||
|
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [notifications, setNotifications] = React.useState(true);
|
||||||
|
const [digest, setDigest] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* Avatar + name block */}
|
||||||
|
<View
|
||||||
|
className={`items-center pt-8 pb-6 mx-4 mt-4 rounded-3xl ${dark ? "bg-obsidian-100" : "bg-surface-light"}`}
|
||||||
|
style={{
|
||||||
|
shadowColor: dark ? "#000" : "#1A1714",
|
||||||
|
shadowOpacity: dark ? 0.35 : 0.06,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowRadius: 10,
|
||||||
|
elevation: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className={`w-20 h-20 rounded-full items-center justify-center mb-3 ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||||
|
<Text className="text-3xl">📰</Text>
|
||||||
|
</View>
|
||||||
|
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||||
|
{user?.email?.split("@")[0] ?? "Reader"}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-sm font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{user?.email}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<View className="flex-row mt-5 gap-8">
|
||||||
|
{[
|
||||||
|
{ 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"}`}>
|
||||||
|
{stat.value}
|
||||||
|
</Text>
|
||||||
|
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||||
|
{stat.label}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Settings sections */}
|
||||||
|
<View
|
||||||
|
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="Notifications" />
|
||||||
|
<SettingRow
|
||||||
|
icon="notifications-outline" label="Push notifications"
|
||||||
|
toggle toggleValue={notifications} onToggle={setNotifications}
|
||||||
|
accent={dark ? "#E07B45" : "#C4622D"}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
icon="mail-outline" label="Daily digest email"
|
||||||
|
toggle toggleValue={digest} onToggle={setDigest}
|
||||||
|
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel title="Subscriptions" />
|
||||||
|
<SettingRow
|
||||||
|
icon="bookmark-outline" label="Manage subscriptions"
|
||||||
|
onPress={() => {}} accent={dark ? "#4EC992" : "#2E9E6B"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SectionLabel title="Account" />
|
||||||
|
<SettingRow icon="person-outline" label="Edit profile" onPress={() => {}} />
|
||||||
|
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => {}} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Sign out */}
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={onSignOut}
|
||||||
|
className={`mx-4 mt-4 py-4 rounded-2xl items-center border ${dark ? "border-red-900/40 bg-red-950/20" : "border-red-200 bg-red-50"}`}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View className="flex-row items-center gap-2">
|
||||||
|
<Ionicons name="log-out-outline" size={16} color="#ef4444" />
|
||||||
|
<Text className="text-sm font-sans font-semibold text-red-500">Sign out</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Root export ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const dark = useColorScheme() === "dark";
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [showAuth, setShowAuth] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
{user
|
||||||
|
? <AuthenticatedProfile onSignOut={logout} />
|
||||||
|
: <GuestProfile onSignIn={() => setShowAuth(true)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* Login / Register modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showAuth}
|
||||||
|
animationType="slide"
|
||||||
|
presentationStyle="pageSheet"
|
||||||
|
onRequestClose={() => setShowAuth(false)}
|
||||||
|
>
|
||||||
|
{/* Close handle */}
|
||||||
|
<View className={`pt-3 pb-1 items-center ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||||
|
<View className={`w-10 h-1 rounded-full ${dark ? "bg-obsidian-50" : "bg-parchment-300"}`} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Settings sections */}
|
{/* AuthGate auto-closes the modal on success because user becomes non-null */}
|
||||||
<View
|
<AuthGate onSuccess={() => setShowAuth(false)} />
|
||||||
className={`mx-4 mt-5 rounded-2xl overflow-hidden border ${
|
</Modal>
|
||||||
dark
|
|
||||||
? "bg-obsidian-100 border-border-dark"
|
|
||||||
: "bg-surface-light border-border-light"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SectionLabel title="Notifications" />
|
|
||||||
<SettingRow
|
|
||||||
icon="notifications-outline"
|
|
||||||
label="Push notifications"
|
|
||||||
toggle
|
|
||||||
toggleValue={notifications}
|
|
||||||
onToggle={setNotifications}
|
|
||||||
accent={dark ? "#E07B45" : "#C4622D"}
|
|
||||||
/>
|
|
||||||
<SettingRow
|
|
||||||
icon="mail-outline"
|
|
||||||
label="Daily digest email"
|
|
||||||
toggle
|
|
||||||
toggleValue={digest}
|
|
||||||
onToggle={setDigest}
|
|
||||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionLabel title="Subscriptions" />
|
|
||||||
<SettingRow
|
|
||||||
icon="bookmark-outline"
|
|
||||||
label="Manage subscriptions"
|
|
||||||
onPress={() => {}}
|
|
||||||
accent={dark ? "#4EC992" : "#2E9E6B"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SectionLabel title="Account" />
|
|
||||||
<SettingRow
|
|
||||||
icon="person-outline"
|
|
||||||
label="Edit profile"
|
|
||||||
onPress={() => {}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/*
|
|
||||||
<SettingRow
|
|
||||||
|
|
||||||
icon="lock-closed-outline"
|
|
||||||
label="Privacy settings"
|
|
||||||
onPress={() => {}}
|
|
||||||
/>
|
|
||||||
*/}
|
|
||||||
<SettingRow
|
|
||||||
icon="help-circle-outline"
|
|
||||||
label="Help & support"
|
|
||||||
onPress={() => {}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Sign out */}
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`mx-4 mt-4 py-4 rounded-2xl items-center border ${
|
|
||||||
dark
|
|
||||||
? "border-red-900/40 bg-red-950/20"
|
|
||||||
: "border-red-200 bg-red-50"
|
|
||||||
}`}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text className="text-sm font-sans font-semibold text-red-500">
|
|
||||||
Sign out
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
70
services/api.tsx
Normal file
70
services/api.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const BASE_URL = "http://192.168.100.6:8080"; // TODO change this
|
||||||
|
|
||||||
|
async function post<T>(path: string, body: object): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
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> {
|
||||||
|
const url = new URL(`${BASE_URL}${path}`);
|
||||||
|
if (params) {
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString());
|
||||||
|
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginPayload = { email: string; password: string };
|
||||||
|
export type RegisterPayload = { email: string; password: string;};
|
||||||
|
export type JwtResponse = { token: string; email: string; message: string };
|
||||||
|
export type UserDTO = { id: number; email: string; name?: string };
|
||||||
|
|
||||||
|
export type Subject = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Entry = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
timePublished: string;
|
||||||
|
groupName: string;
|
||||||
|
infoEntry: string;
|
||||||
|
paragraph: string;
|
||||||
|
filepath?: string;
|
||||||
|
subject: Subject;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpringPage<T> = {
|
||||||
|
content: T[];
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
last: boolean;
|
||||||
|
number: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||||
|
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const entriesApi = {
|
||||||
|
getGroups: () =>
|
||||||
|
get<string[]>("/groups"),
|
||||||
|
|
||||||
|
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||||
|
get<SpringPage<Entry>>("/entries", params),
|
||||||
|
|
||||||
|
getEntry: (id: string) =>
|
||||||
|
get<Entry>(`/entries/${id}`),
|
||||||
|
};
|
||||||
@ -1,6 +1,5 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// NOTE: Update this to include the paths to all files that contain Nativewind classes.
|
|
||||||
content: [
|
content: [
|
||||||
"./app/**/*.{js,jsx,ts,tsx}",
|
"./app/**/*.{js,jsx,ts,tsx}",
|
||||||
"./screens/**/*.{js,jsx,ts,tsx}",
|
"./screens/**/*.{js,jsx,ts,tsx}",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user