diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 1d2fef1..720d5ff 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -2,58 +2,64 @@ import { Tabs } from "expo-router"; import { useColorScheme } from "react-native"; 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() { - const scheme = useColorScheme(); - const dark = scheme === "dark"; - const c = dark ? DARK : LIGHT; + const dark = useColorScheme() === "dark"; + const tab = dark ? DARK_TAB : LIGHT_TAB; return ( ({ headerShown: false, tabBarStyle: { - backgroundColor: c.bg, - borderTopColor: c.border, - borderTopWidth: 1, - height: 64, - paddingBottom: 10, - paddingTop: 6, + backgroundColor: tab.bg, + borderTopColor: tab.border, + borderTopWidth: 1, + height: 60, + paddingBottom: 8, + paddingTop: 6, }, - tabBarActiveTintColor: c.active, - tabBarInactiveTintColor: c.inactive, - tabBarLabelStyle: { fontSize: 11, fontWeight: "600" }, - }} + tabBarActiveTintColor: tab.active, + tabBarInactiveTintColor: tab.inactive, + tabBarLabelStyle: { + fontSize: 10, + fontWeight: "600", + letterSpacing: 0.3, + }, + tabBarIcon: ({ focused, color, size }) => { + const icons: Record = { + 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 ( + + ); + }, + })} > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> + + + ); -} +} \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx index dc9bd95..7a41b9b 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,6 +1,14 @@ import "../globals.css"; import { Stack } from "expo-router"; +import {AuthProvider} from "@/context/AuthContext"; +import {SafeAreaProvider} from "react-native-safe-area-context"; export default function RootLayout() { - return ; + return ( + + + + + + ); } \ No newline at end of file diff --git a/components/CollapsibleCategory.tsx b/components/CollapsibleCategory.tsx index a134baf..7204045 100644 --- a/components/CollapsibleCategory.tsx +++ b/components/CollapsibleCategory.tsx @@ -1,44 +1,72 @@ -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { - View, - Text, - TouchableOpacity, - Animated, - LayoutAnimation, - Platform, - UIManager, - useColorScheme, + View, Text, TouchableOpacity, + ActivityIndicator, LayoutAnimation, useColorScheme, } from "react-native"; 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") { - UIManager.setLayoutAnimationEnabledExperimental?.(true); -} +// ─── Props ───────────────────────────────────────────────────────────────── +// items removed — now fetched from API using groupName -export interface Category { - id: string; - label: string; - icon: keyof typeof Ionicons.glyphMap; - color: string; // accent hex for the icon badge - darkColor: string; - items: FeedItem[]; -} - -interface CollapsibleCategoryProps { - category: Category; +export interface CollapsibleCategoryProps { + id: string; + label: string; + icon: keyof typeof Ionicons.glyphMap; + color: string; + darkColor: string; + groupName: string; // passed to /entries?groupName= defaultOpen?: boolean; } +// ─── Component ───────────────────────────────────────────────────────────── + export default function CollapsibleCategory({ - category, + label, + icon, + color, + darkColor, + groupName, defaultOpen = false, }: CollapsibleCategoryProps) { - const [open, setOpen] = useState(defaultOpen); - const scheme = useColorScheme(); - const dark = scheme === "dark"; + const dark = useColorScheme() === "dark"; + const accentColor = dark ? darkColor : color; - const accentColor = dark ? category.darkColor : category.color; + const [open, setOpen] = useState(defaultOpen); + const [items, setItems] = useState([]); + 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 = () => { LayoutAnimation.configureNext({ @@ -51,23 +79,23 @@ export default function CollapsibleCategory({ return ( + {/* ── Header — untouched from your original ── */} - {/* Icon badge */} - + - {category.label} + {label} - {/* Count badge */} + {/* Count badge — shows loaded count, spins while first load */} - - {category.items.length} - + {loading && items.length === 0 ? ( + + ) : ( + + {items.length} + + )} - {/* Items */} + {/* ── Body ── */} {open && ( - {category.items.map((item) => ( + {/* Error */} + {error !== "" && ( + fetchPage(page)} + className="mx-4 py-4 items-center" + > + + {error} + + + )} + + {/* Items — same ExpandableItem you already have */} + {items.map((item) => ( ))} + + {/* Load more */} + {hasMore && items.length > 0 && ( + 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 ? ( + + ) : ( + + + + Load more + + + )} + + )} + + {/* End of list */} + {!hasMore && items.length > 0 && ( + + All {items.length} entries loaded + + )} )} diff --git a/components/ExpandableItem.tsx b/components/ExpandableItem.tsx index 12e5909..792de10 100644 --- a/components/ExpandableItem.tsx +++ b/components/ExpandableItem.tsx @@ -1,39 +1,34 @@ -import React, { useState, useRef } from "react"; +import React, { useRef, useState } from "react"; import { - View, - Text, - TouchableOpacity, - Animated, - LayoutAnimation, - Platform, - UIManager, - useColorScheme, + Animated, LayoutAnimation, Linking, + Platform, Text, TouchableOpacity, + UIManager, View, useColorScheme, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { Entry } from "../services/api"; -// Enable LayoutAnimation on Android idk honestly probably ok? if (Platform.OS === "android") { UIManager.setLayoutAnimationEnabledExperimental?.(true); } -export interface FeedItem { - id: string; - title: string; - author: string; - timestamp: string; - tag: string; - paragraphs: string[]; +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", month: "short", year: "numeric", + }); + } catch { + return iso; + } } -interface ExpandableItemProps { - item: FeedItem; +function fileName(path: string): string { + return path.split(/[\\/]/).pop() ?? path; } -export default function ExpandableItem({ item }: ExpandableItemProps) { +export default function ExpandableItem({ item }: { item: Entry }) { const [expanded, setExpanded] = useState(false); const rotateAnim = useRef(new Animated.Value(0)).current; - const scheme = useColorScheme(); - const dark = scheme === "dark"; + const dark = useColorScheme() === "dark"; const toggle = () => { LayoutAnimation.configureNext({ @@ -50,7 +45,7 @@ export default function ExpandableItem({ item }: ExpandableItemProps) { }; const chevronRotation = rotateAnim.interpolate({ - inputRange: [0, 1], + inputRange: [0, 1], outputRange: ["0deg", "180deg"], }); @@ -58,96 +53,146 @@ export default function ExpandableItem({ item }: ExpandableItemProps) { - {/* Header */} + {/* ── Collapsed header ── */} - {/* Tag + timestamp row */} - - - - {item.tag} + + {/* Subject tag + date */} + + + + {item.subject?.name ?? item.groupName} - - {item.timestamp} + + + + {item.groupName} + + + + + {formatDate(item.timePublished)} {/* Title */} {item.title} - {/* Author */} - - by {item.author} - + {/* info_entry — always visible as subtitle */} + {item.infoEntry ? ( + + {item.infoEntry} + + ) : null} - + - {/* Expanded body */} + {/* ── Expanded body ── */} {expanded && ( - - {item.paragraphs.map((para, i) => ( - + + {/* paragraph */} + {item.paragraph ? ( + + + Content + + + {item.paragraph} + + + ) : null} + + {/* Meta row: subject + group + date */} + + + + + + + {/* filepath — clickable attachment */} + {item.filepath ? ( + 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} - - ))} + + + + + + {fileName(item.filepath)} + + + Tap to open attachment + + + + + ) : null} )} ); +} + + +function Row({ label, value, dark, last = false }: { + label: string; + value?: string; + dark: boolean; + last?: boolean; +}) { + if (!value) return null; + return ( + + + {label} + + + {value} + + + ); } \ No newline at end of file diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx new file mode 100644 index 0000000..2449079 --- /dev/null +++ b/context/AuthContext.tsx @@ -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; + register: (p: RegisterPayload) => Promise; + logout: () => Promise; +}; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); + return ctx; +} \ No newline at end of file diff --git a/index.tsx b/index.tsx index d3a17a1..bdcbf54 100644 --- a/index.tsx +++ b/index.tsx @@ -13,6 +13,8 @@ import AllFeed from "@/screens/AllFeed"; import Profile from "@/screens/Profile"; import {enableScreens} from "react-native-screens"; 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 @@ -36,6 +38,11 @@ const DARK_TAB = { }; +function ProfileTab() { + const { user, loading } = useAuth(); + if (loading) return null; + return user ? : ; +} export default function App() { const scheme = useColorScheme(); @@ -47,6 +54,7 @@ export default function App() { : { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } }; return ( + + + ); } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 052034a..09c2725 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@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": { "version": "0.81.5", "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" } }, + "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": { "version": "1.2.1", "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==", "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", diff --git a/package.json b/package.json index 40fe5c8..6a77d57 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@expo/vector-icons": "^15.0.3", + "@react-native-async-storage/async-storage": "2.2.0", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", diff --git a/screens/AllFeed.tsx b/screens/AllFeed.tsx index ef516e2..b2491d9 100644 --- a/screens/AllFeed.tsx +++ b/screens/AllFeed.tsx @@ -1,107 +1,114 @@ -import React, { useState, useMemo } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { - View, - Text, - ScrollView, - useColorScheme, + View, Text, ScrollView, + ActivityIndicator, useColorScheme, TouchableOpacity, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import SearchBar from "../components/SearchBar"; -import CollapsibleCategory, { Category } from "../components/CollapsibleCategory"; -import { ALL_CATEGORIES } from "@/data/mockData"; +import CollapsibleCategory, { CollapsibleCategoryProps } from "../components/CollapsibleCategory"; +import { entriesApi } from "../services/api"; + +// Cycles through as many groups as the API returns +const PALETTE: Pick[] = [ + { 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() { - const [query, setQuery] = useState(""); - const scheme = useColorScheme(); - const dark = scheme === "dark"; + const dark = useColorScheme() === "dark"; - const totalCount = ALL_CATEGORIES.reduce( - (sum, cat) => sum + cat.items.length, - 0 - ); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [query, setQuery] = useState(""); - // Filter items within each category based on search query - const filteredCategories = useMemo((): Category[] => { - if (!query.trim()) return ALL_CATEGORIES; + const loadGroups = () => { + setLoading(true); + 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(); - return ALL_CATEGORIES.map((cat) => ({ - ...cat, - 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 groups.filter((g) => g.toLowerCase().includes(lower)); + }, [query, groups]); return ( - - - + + {/* Header */} + - + Discover - - {ALL_CATEGORIES.length} categories · {totalCount} articles + + {loading ? "Loading…" : `${groups.length} groups`} - - + - - {filteredCategories.length === 0 ? ( - - 🔍 - - No results for "{query}" - - - Try searching by category name, author, or topic. - - - ) : ( - filteredCategories.map((cat, index) => ( - - )) - )} - + {/* States */} + {loading ? ( + + + + + ) : error ? ( + + ⚠️ + + {error} + + + Retry + + + + ) : ( + + {visibleGroups.length === 0 ? ( + + 🔍 + + No groups match "{query}" + + + Try a different search term. + + + ) : ( + visibleGroups.map((groupName, index) => ( + + )) + )} + + )} ); } \ No newline at end of file diff --git a/screens/AuthGate.tsx b/screens/AuthGate.tsx new file mode 100644 index 0000000..7fb4720 --- /dev/null +++ b/screens/AuthGate.tsx @@ -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("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 ( + + + + {/* Logo / header */} + + + 📰 + + + {mode === "login" ? "Welcome back" : "Create account"} + + + {mode === "login" + ? "Sign in to sync your subscriptions" + : "Start reading what matters"} + + + + {/* Card */} + + + + + + EMAIL + + + + + + + PASSWORD + + + + setShowPass((v) => !v)} + className="absolute right-3 top-3.5" + > + + + + + + {error !== "" && ( + + {error} + + )} + + + {loading + ? + : + {mode === "login" ? "Sign in" : "Create account"} + + } + + + + {/* Toggle mode */} + + + {mode === "login" ? "Don't have an account? " : "Already have an account? "} + + { setMode(mode === "login" ? "register" : "login"); setError(""); }}> + + {mode === "login" ? "Register" : "Sign in"} + + + + + {/* Continue as guest */} + + You can still browse and save locally without signing in. + + + + + ); +} \ No newline at end of file diff --git a/screens/Profile.tsx b/screens/Profile.tsx index 9cc4160..5a21708 100644 --- a/screens/Profile.tsx +++ b/screens/Profile.tsx @@ -1,17 +1,17 @@ import React from "react"; import { - View, - Text, - ScrollView, - TouchableOpacity, - useColorScheme, - Switch, + View, Text, ScrollView, TouchableOpacity, + Switch, Modal, useColorScheme, } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { Ionicons } from "@expo/vector-icons"; +import { useAuth } from "../context/AuthContext"; +import AuthGate from "./AuthGate"; -interface SettingRowProps { - icon: keyof typeof Ionicons.glyphMap; +// ─── Sub-components ──────────────────────────────────────────────────────── + +type SettingRowProps = { + icon: any; label: string; value?: string; toggle?: boolean; @@ -19,34 +19,17 @@ interface SettingRowProps { onToggle?: (v: boolean) => void; onPress?: () => void; accent?: string; -} +}; - -{/*// TODO add logic idk im still missing a home/login screen*/} - - - -function SettingRow({ - icon, - label, - value, - toggle, - toggleValue, - onToggle, - onPress, - accent, - }: SettingRowProps) { - const scheme = useColorScheme(); - const dark = scheme === "dark"; +function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) { + const dark = useColorScheme() === "dark"; const iconColor = accent ?? (dark ? "#706D67" : "#8A8278"); return ( - + {label} {toggle ? ( ) : value ? ( - - {value} - + {value} ) : ( - + )} ); @@ -93,167 +59,197 @@ function SettingRow({ function SectionLabel({ title }: { title: string }) { const dark = useColorScheme() === "dark"; return ( - + {title} ); } -export default function Profile() { - const [notifications, setNotifications] = React.useState(true); - const [digest, setDigest] = React.useState(false); - const scheme = useColorScheme(); - const dark = scheme === "dark"; +// ─── Guest profile (logged out) ──────────────────────────────────────────── + +function GuestProfile({ onSignIn }: { onSignIn: () => void }) { + const dark = useColorScheme() === "dark"; + const accent = dark ? "#E07B45" : "#C4622D"; return ( - - - + {/* Avatar block */} + - {/* Avatar + name block */} + {/* Anonymous avatar */} - {/*// TODO*/} - {/* Avatar placeholder */} - - 📰 - - - Get Name here idk - - - {/*// TODO*/} - aaa@aaaq.com - + + + + Guest + + + Sign in to sync your reading + + - {/* Stats row */} - - {[ - //TODO add stats tracked - { label: "Subscribed", value: "5" }, - { label: "Read", value: "128" }, - { label: "Saved", value: "34" }, - ].map((stat) => ( - - - {stat.value} - - - {stat.label} - - - ))} - + {/* Settings available to guests */} + + + {}} /> + {}} /> + + + {/* Sign in / Register CTA */} + + + + + Sign in / Register + + + + + + You can still browse and save locally without an account. + + + ); +} + +// ─── 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 ( + + {/* Avatar + name block */} + + + 📰 + + + {user?.email?.split("@")[0] ?? "Reader"} + + + {user?.email} + + + {/* Stats row */} + + {[ + { label: "Subscribed", value: "5" }, + { label: "Read", value: "128" }, + { label: "Saved", value: "34" }, + ].map((stat) => ( + + + {stat.value} + + + {stat.label} + + + ))} + + + + {/* Settings sections */} + + + + + + + {}} accent={dark ? "#4EC992" : "#2E9E6B"} + /> + + + {}} /> + {}} /> + + + {/* Sign out */} + + + + Sign out + + + + ); +} + +// ─── Root export ─────────────────────────────────────────────────────────── + +export default function Profile() { + const dark = useColorScheme() === "dark"; + const { user, logout } = useAuth(); + const [showAuth, setShowAuth] = React.useState(false); + + return ( + + {user + ? + : setShowAuth(true)} /> + } + + {/* Login / Register modal */} + setShowAuth(false)} + > + {/* Close handle */} + + - {/* Settings sections */} - - - - - - - {}} - accent={dark ? "#4EC992" : "#2E9E6B"} - /> - - - {}} - /> - - {/* - {}} - /> - */} - {}} - /> - - - {/* Sign out */} - - - Sign out - - - + {/* AuthGate auto-closes the modal on success because user becomes non-null */} + setShowAuth(false)} /> + ); } \ No newline at end of file diff --git a/services/api.tsx b/services/api.tsx new file mode 100644 index 0000000..ec1aae5 --- /dev/null +++ b/services/api.tsx @@ -0,0 +1,70 @@ +const BASE_URL = "http://192.168.100.6:8080"; // TODO change this + +async function post(path: string, body: object): Promise { + 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(path: string, params?: Record): Promise { + 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 = { + content: T[]; + totalPages: number; + totalElements: number; + last: boolean; + number: number; +}; + + +export const authApi = { + login: (p: LoginPayload) => post("/login", p), + register: (p: RegisterPayload) => post("/register", p), +}; + + +export const entriesApi = { + getGroups: () => + get("/groups"), + + getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) => + get>("/entries", params), + + getEntry: (id: string) => + get(`/entries/${id}`), +}; \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 1637420..5369ee2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,6 +1,5 @@ /** @type {import('tailwindcss').Config} */ module.exports = { - // NOTE: Update this to include the paths to all files that contain Nativewind classes. content: [ "./app/**/*.{js,jsx,ts,tsx}", "./screens/**/*.{js,jsx,ts,tsx}",