added login and started adding rest api

This commit is contained in:
Ksan 2026-05-09 03:15:02 +02:00
parent 74f7ed047e
commit abd725c274
13 changed files with 960 additions and 459 deletions

View File

@ -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>
); );
} }

View File

@ -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>
);
} }

View File

@ -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>

View File

@ -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
View 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;
}

View File

@ -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
View File

@ -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",

View File

@ -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",

View File

@ -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 &#34;{query}&#34; 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
View 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>
);
}

View File

@ -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
View 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}`),
};

View File

@ -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}",