moved files to monorepo and updated controllers to start with /api
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
@@ -0,0 +1,28 @@
|
||||
# Welcome to idk im building something because im lazy lol
|
||||
|
||||
this is just the frontend of this project but why not
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
1. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the app
|
||||
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
In the output, you'll find options to open the app in a
|
||||
|
||||
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||
|
||||
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "etfoglasi-frontend",
|
||||
"slug": "etfoglasi-frontend",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "etfoglasifrontend",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"output": "static",
|
||||
"bundler": "metro",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff",
|
||||
"dark": {
|
||||
"backgroundColor": "#000000"
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
|
||||
|
||||
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 dark = useColorScheme() === "dark";
|
||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: tab.bg,
|
||||
borderTopColor: tab.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 6,
|
||||
},
|
||||
tabBarActiveTintColor: tab.active,
|
||||
tabBarInactiveTintColor: tab.inactive,
|
||||
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 name="index" options={{ title: "My Feed" }} />
|
||||
<Tabs.Screen name="all" options={{ title: "Discover" }} />
|
||||
<Tabs.Screen name="profile" options={{ title: "Profile" }} />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import AllFeed from "@/screens/AllFeed";
|
||||
export default AllFeed;
|
||||
@@ -0,0 +1,3 @@
|
||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||
export default SubscribedFeed;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
import Profile from "@/screens/Profile";
|
||||
export default Profile;
|
||||
@@ -0,0 +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 (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<Stack screenOptions={{ headerShown: false }} />
|
||||
</SafeAreaProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,9 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
["babel-preset-expo", { jsxImportSource: "nativewind" }],
|
||||
"nativewind/babel",
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
View, Text, TouchableOpacity,
|
||||
ActivityIndicator, LayoutAnimation, useColorScheme,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Entry, entriesApi } from "@/services/api";
|
||||
import ExpandableItem from "./ExpandableItem";
|
||||
|
||||
export interface CollapsibleCategoryProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: keyof typeof Ionicons.glyphMap;
|
||||
color: string;
|
||||
darkColor: string;
|
||||
groupName: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function CollapsibleCategory({
|
||||
label,
|
||||
icon,
|
||||
color,
|
||||
darkColor,
|
||||
groupName,
|
||||
refreshKey = 0,
|
||||
}: CollapsibleCategoryProps) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accentColor = dark ? darkColor : color;
|
||||
|
||||
const [open, setOpen] = useState();
|
||||
const [items, setItems] = useState<Entry[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Stable fetch — won't re-create when loading changes
|
||||
const loadingRef = useRef(false);
|
||||
|
||||
const fetchPage = useCallback(async (pageNum: number) => {
|
||||
if (loadingRef.current) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await entriesApi.getEntries({ groupName, page: pageNum });
|
||||
setItems((prev) =>
|
||||
pageNum === 0 ? data.content : [...prev, ...data.content]
|
||||
);
|
||||
setHasMore(!data.last);
|
||||
setPage(pageNum);
|
||||
} catch {
|
||||
setError("Couldn't load entries. Tap to retry.");
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupName]);
|
||||
|
||||
// Fetch page 0 on mount AND whenever refreshKey changes
|
||||
useEffect(() => {
|
||||
setItems([]);
|
||||
setPage(0);
|
||||
setHasMore(true);
|
||||
setError("");
|
||||
fetchPage(0);
|
||||
}, [refreshKey]); // refreshKey bump = full reset
|
||||
|
||||
const toggle = () => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 260,
|
||||
create: { type: "easeInEaseOut", property: "opacity" },
|
||||
update: { type: "spring", springDamping: 0.85 },
|
||||
});
|
||||
// @ts-ignore bla bla bla
|
||||
setOpen((v) => !v);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className="mb-4">
|
||||
{/* ── Header ── */}
|
||||
<TouchableOpacity
|
||||
onPress={toggle}
|
||||
activeOpacity={0.8}
|
||||
className={`mx-4 flex-row items-center px-4 py-3.5 rounded-2xl ${dark
|
||||
? "bg-obsidian-50 border border-border-dark"
|
||||
: "bg-parchment-100 border border-border-light"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
|
||||
style={{ backgroundColor: accentColor + "25" }}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color={accentColor} />
|
||||
</View>
|
||||
|
||||
<Text className={`flex-1 text-sm font-sans font-bold tracking-wide ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
{/* Count badge — spinner while loading first page */}
|
||||
<View
|
||||
className="px-2 py-0.5 rounded-full mr-3"
|
||||
style={{ backgroundColor: accentColor + "20" }}
|
||||
>
|
||||
{loading && items.length === 0 ? (
|
||||
<ActivityIndicator size="small" color={accentColor} style={{ width: 24 }} />
|
||||
) : (
|
||||
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
|
||||
{items.length}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Ionicons
|
||||
name={open ? "chevron-up" : "chevron-down"}
|
||||
size={16}
|
||||
color={dark ? "#706D67" : "#8A8278"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Body */}
|
||||
{open && (
|
||||
<View className="mt-2">
|
||||
{/* Error with retry */}
|
||||
{error !== "" && (
|
||||
<TouchableOpacity
|
||||
onPress={() => fetchPage(page)}
|
||||
className="mx-4 py-4 items-center"
|
||||
>
|
||||
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{error}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{items.map((item) => (
|
||||
<ExpandableItem key={item.id} item={item} />
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && items.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => fetchPage(page + 1)}
|
||||
disabled={loading}
|
||||
className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${dark
|
||||
? "bg-obsidian-50 border-border-dark"
|
||||
: "bg-parchment-100 border-border-light"
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={accentColor} />
|
||||
) : (
|
||||
<View className="flex-row items-center gap-1.5">
|
||||
<Ionicons name="chevron-down" size={13} color={accentColor} />
|
||||
<Text className="text-xs font-sans font-semibold" style={{ color: accentColor }}>
|
||||
Load more
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{!hasMore && items.length > 0 && (
|
||||
<Text className={`text-center text-xs font-sans py-3 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
All {items.length} entries loaded
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import {
|
||||
Animated, LayoutAnimation, Linking,
|
||||
Platform, Text, TouchableOpacity,
|
||||
UIManager, View, useColorScheme,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Entry, Subject } from "@/services/api";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import SubjectSheet from "@/components/SubjectSheet";
|
||||
|
||||
if (Platform.OS === "android") {
|
||||
UIManager.setLayoutAnimationEnabledExperimental?.(true);
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
day: "numeric", month: "short", year: "numeric",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[\\/]/).pop() ?? path;
|
||||
}
|
||||
|
||||
export default function ExpandableItem({ item }: { item: Entry }) {
|
||||
|
||||
|
||||
const { user } = useAuth();
|
||||
const [sheetSubject, setSheetSubject] = useState<Subject | null>(null);
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const rotateAnim = useRef(new Animated.Value(0)).current;
|
||||
const dark = useColorScheme() === "dark";
|
||||
|
||||
const toggle = () => {
|
||||
LayoutAnimation.configureNext({
|
||||
duration: 280,
|
||||
create: { type: "easeInEaseOut", property: "opacity" },
|
||||
update: { type: "spring", springDamping: 0.8 },
|
||||
});
|
||||
Animated.timing(rotateAnim, {
|
||||
toValue: expanded ? 0 : 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
setExpanded((v) => !v);
|
||||
};
|
||||
|
||||
const chevronRotation = rotateAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ["0deg", "180deg"],
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.85}
|
||||
onPress={toggle}
|
||||
className={`mx-4 mb-3 rounded-2xl overflow-hidden ${dark
|
||||
? "bg-obsidian-100 border border-border-dark"
|
||||
: "bg-surface-light border border-border-light"
|
||||
}`}
|
||||
style={{
|
||||
shadowColor: dark ? "#000" : "#1A1714",
|
||||
shadowOpacity: dark ? 0.35 : 0.06,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
}}
|
||||
>
|
||||
{/* Collapsed header */}
|
||||
<View className="flex-row items-center px-4 py-4">
|
||||
<View className="flex-1 pr-3">
|
||||
|
||||
{/* Tag + date row */}
|
||||
<View className="flex-row items-center mb-1.5 gap-2 flex-wrap">
|
||||
|
||||
{/* Subject tag */}
|
||||
<View onStartShouldSetResponder={() => true}>
|
||||
{user ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => setSheetSubject(item.subject)}
|
||||
className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
|
||||
{item.subject?.name ?? item.groupName}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||
<Text className={`text-xs font-sans font-semibold tracking-wide ${dark ? "text-accent-dark" : "text-accent"}`}>
|
||||
{item.subject?.name ?? item.groupName}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Group pill */}
|
||||
<View className={`px-2 py-0.5 rounded-full ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}>
|
||||
<Text className={`text-xs font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{item.groupName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text className={`text-xs ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{formatDate(item.timePublished)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Title */}
|
||||
<Text
|
||||
className={`text-base font-sans font-semibold leading-snug ${dark ? "text-ink-dark" : "text-ink-light"}`}
|
||||
numberOfLines={expanded ? undefined : 2}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
|
||||
{/* info_entry — always visible as subtitle */}
|
||||
{item.infoEntry ? (
|
||||
<Text
|
||||
className={`text-xs mt-1 leading-relaxed ${dark ? "text-muted-dark" : "text-muted-light"}`}
|
||||
numberOfLines={expanded ? undefined : 2}
|
||||
>
|
||||
{item.infoEntry}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Animated.View style={{ transform: [{ rotate: chevronRotation }] }}>
|
||||
<Ionicons name="chevron-down" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Expanded body */}
|
||||
{expanded && (
|
||||
<View className={`border-t ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||
|
||||
{/* paragraph */}
|
||||
{item.paragraph ? (
|
||||
<View className="px-4 pt-3 pb-2">
|
||||
<Text className={`text-xs font-sans font-bold tracking-widest uppercase mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Content
|
||||
</Text>
|
||||
<Text className={`text-sm font-sans leading-relaxed ${dark ? "text-ink-dark/85" : "text-ink-light/85"}`}>
|
||||
{item.paragraph}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* Meta row: subject + group + date */}
|
||||
<View className={`mx-4 my-3 p-3 rounded-xl ${dark ? "bg-obsidian-50" : "bg-parchment-100"}`}>
|
||||
<Row label="Subject" value={item.subject?.name} dark={dark} />
|
||||
<Row label="Group" value={item.groupName} dark={dark} />
|
||||
<Row label="Published" value={formatDate(item.timePublished)} dark={dark} last />
|
||||
</View>
|
||||
|
||||
{/* filepath — clickable attachment */}
|
||||
{item.filepath ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => Linking.openURL(item.filepath!)}
|
||||
activeOpacity={0.75}
|
||||
className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${dark
|
||||
? "bg-obsidian-50 border-border-dark"
|
||||
: "bg-parchment-100 border-border-light"
|
||||
}`}
|
||||
|
||||
>
|
||||
<View className={`w-8 h-8 rounded-lg items-center justify-center ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`}>
|
||||
<Ionicons
|
||||
name="document-attach-outline"
|
||||
size={17}
|
||||
color={dark ? "#E07B45" : "#C4622D"}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className={`text-xs font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{fileName(item.filepath)}
|
||||
</Text>
|
||||
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Tap to open attachment
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons
|
||||
name="open-outline"
|
||||
size={15}
|
||||
color={dark ? "#706D67" : "#8A8278"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
<SubjectSheet
|
||||
subject={sheetSubject}
|
||||
onClose={() => setSheetSubject(null)}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function Row({ label, value, dark, last = false }: {
|
||||
label: string;
|
||||
value?: string;
|
||||
dark: boolean;
|
||||
last?: boolean;
|
||||
}) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<View className={`flex-row justify-between py-1.5 ${!last ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""}`}>
|
||||
<Text className={`text-xs font-sans font-semibold ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text className={`text-xs font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{value}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
useColorScheme,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
onSearch?: (text: string) => void;
|
||||
}
|
||||
|
||||
//TODO make cirilic==latinic when searching
|
||||
export default function SearchBar({
|
||||
placeholder = "Search…",
|
||||
onSearch,
|
||||
}: SearchBarProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const scheme = useColorScheme();
|
||||
const dark = scheme === "dark";
|
||||
|
||||
const handleChange = (text: string) => {
|
||||
setQuery(text);
|
||||
onSearch?.(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`
|
||||
flex-row items-center mx-4 my-3 px-4 rounded-2xl h-11
|
||||
${dark
|
||||
? "bg-obsidian-100 border border-border-dark"
|
||||
: "bg-parchment-100 border border-border-light"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Ionicons
|
||||
name="search"
|
||||
size={16}
|
||||
color={dark ? "#706D67" : "#8A8278"}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<TextInput
|
||||
className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"
|
||||
}`}
|
||||
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChangeText={handleChange}
|
||||
returnKeyType="search"
|
||||
clearButtonMode="while-editing"
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity onPress={() => handleChange("")} hitSlop={8}>
|
||||
<Ionicons
|
||||
name="close-circle"
|
||||
size={16}
|
||||
color={dark ? "#706D67" : "#8A8278"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View, Text, Modal, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Subject } from "../services/api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
interface Props {
|
||||
subject: Subject | null; // null = closed
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function SubjectSheet({ subject, onClose }: Props) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { isSubscribed, subscribe, unsubscribe, user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (!subject) return null;
|
||||
|
||||
const subscribed = isSubscribed(subject.id);
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const toggle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (subscribed) await unsubscribe(subject.id);
|
||||
else await subscribe(subject.id);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!subject}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<TouchableOpacity
|
||||
className="flex-1 justify-end"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.45)" }}
|
||||
activeOpacity={1}
|
||||
onPress={onClose}
|
||||
>
|
||||
{/* Sheet — stop tap propagation */}
|
||||
<TouchableOpacity activeOpacity={1} onPress={() => { }}>
|
||||
<View
|
||||
className={`rounded-t-3xl px-6 pt-4 pb-10 ${dark ? "bg-obsidian-100" : "bg-surface-light"
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<View className={`w-10 h-1 rounded-full self-center mb-5 ${dark ? "bg-obsidian-50" : "bg-parchment-300"}`} />
|
||||
|
||||
{/* Group pill */}
|
||||
<View
|
||||
className="self-start px-2.5 py-1 rounded-full mb-3"
|
||||
style={{ backgroundColor: accent + "20" }}
|
||||
>
|
||||
</View>
|
||||
|
||||
{/* Subject name */}
|
||||
<Text className={`text-2xl font-display font-bold mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{subject.name}
|
||||
</Text>
|
||||
|
||||
<Text className={`text-sm font-sans mb-6 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{subscribed
|
||||
? "You're subscribed to this subject."
|
||||
: "Subscribe to see this subject in your feed."}
|
||||
</Text>
|
||||
|
||||
{/* Subscribe / Unsubscribe button */}
|
||||
{user ? (
|
||||
<TouchableOpacity
|
||||
onPress={toggle}
|
||||
disabled={loading}
|
||||
className="py-4 rounded-2xl items-center flex-row justify-center gap-2"
|
||||
style={{
|
||||
backgroundColor: subscribed
|
||||
? dark ? "#2C2A27" : "#F0EDE8"
|
||||
: accent,
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={subscribed ? accent : "#fff"} />
|
||||
) : (
|
||||
<>
|
||||
<Ionicons
|
||||
name={subscribed ? "bookmark" : "bookmark-outline"}
|
||||
size={17}
|
||||
color={subscribed ? accent : "#fff"}
|
||||
/>
|
||||
<Text
|
||||
className="text-sm font-sans font-semibold"
|
||||
style={{ color: subscribed ? accent : "#fff" }}
|
||||
>
|
||||
{subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Text className={`text-center text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Sign in to subscribe to subjects.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import {
|
||||
authApi,
|
||||
|
||||
LoginPayload, RegisterPayload, subscriptionsApi, UserUpdateDTO,
|
||||
} from "@/services/api";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
token: string;
|
||||
subscribedSubjectIds: string[];
|
||||
};
|
||||
|
||||
type AuthContextValue = {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (p: LoginPayload) => Promise<void>;
|
||||
register: (p: RegisterPayload) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
subscribe: (subjectId: string) => Promise<void>;
|
||||
unsubscribe: (subjectId: string) => Promise<void>;
|
||||
isSubscribed: (subjectId: string) => boolean;
|
||||
updateUser: (newEmail?: string, newPassword?: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
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);
|
||||
|
||||
// Gracefully fetch subscriptions — don't block login if this fails
|
||||
let subscribedSubjectIds: string[] = [];
|
||||
if (res.userId) {
|
||||
try {
|
||||
const dto = await authApi.getUser(res.userId, res.token);
|
||||
subscribedSubjectIds = dto.subjectSet?.map((s) => s.id) ?? [];
|
||||
} catch {
|
||||
// aaaa
|
||||
}
|
||||
}
|
||||
|
||||
await persist({
|
||||
id: res.userId ?? "",
|
||||
email: res.email,
|
||||
token: res.token,
|
||||
subscribedSubjectIds,
|
||||
});
|
||||
};
|
||||
|
||||
const register = async (payload: RegisterPayload) => {
|
||||
await authApi.register(payload);
|
||||
await login({ email: payload.email, password: payload.password });
|
||||
};
|
||||
|
||||
const updateUser = async (newEmail?: string, newPassword?: string) => {
|
||||
if (!user) return;
|
||||
const body: UserUpdateDTO = {
|
||||
email: user.email,
|
||||
newEmail: newEmail || undefined,
|
||||
password: newPassword || undefined,
|
||||
subjectSet: user.subscribedSubjectIds.map((id) => ({ id })),
|
||||
};
|
||||
const dto = await authApi.updateUser(body, user.token);
|
||||
const updated: User = {
|
||||
...user,
|
||||
email: newEmail ?? user.email,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s: any) => s.id) ?? user.subscribedSubjectIds,
|
||||
};
|
||||
await persist(updated);
|
||||
};
|
||||
|
||||
const logout = () => persist(null);
|
||||
|
||||
const subscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.subscribe(user.id, subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? [...user.subscribedSubjectIds, subjectId],
|
||||
};
|
||||
await persist(updated);
|
||||
};
|
||||
|
||||
const unsubscribe = async (subjectId: string) => {
|
||||
if (!user) return;
|
||||
const dto = await subscriptionsApi.unsubscribe(user.id, subjectId, user.token);
|
||||
const updated = {
|
||||
...user,
|
||||
subscribedSubjectIds: dto.subjectSet?.map((s) => s.id) ?? user.subscribedSubjectIds.filter((id) => id !== subjectId),
|
||||
};
|
||||
await persist(updated);
|
||||
};
|
||||
|
||||
const isSubscribed = (subjectId: string) =>
|
||||
user?.subscribedSubjectIds.includes(subjectId) ?? false;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, subscribe, unsubscribe, isSubscribed, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require('eslint-config-expo/flat');
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ['dist/*'],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -0,0 +1,124 @@
|
||||
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
import React from "react";
|
||||
import { useColorScheme } from "react-native";
|
||||
import { NavigationContainer, DefaultTheme, DarkTheme } from "@react-navigation/native";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
import SubscribedFeed from "@/screens/SubscribedFeed";
|
||||
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
|
||||
enableScreens();
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
|
||||
const LIGHT_TAB = {
|
||||
bg: "#FFFFFF",
|
||||
border: "#E8E2D5",
|
||||
active: "#C4622D",
|
||||
inactive: "#8A8278",
|
||||
label: "#1A1714",
|
||||
};
|
||||
const DARK_TAB = {
|
||||
bg: "#161513",
|
||||
border: "#2C2A27",
|
||||
active: "#E07B45",
|
||||
inactive: "#706D67",
|
||||
label: "#F0EDE8",
|
||||
};
|
||||
|
||||
|
||||
function ProfileTab() {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
return user ? <Profile /> : <AuthGate />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const scheme = useColorScheme();
|
||||
const dark = scheme === "dark";
|
||||
const tab = dark ? DARK_TAB : LIGHT_TAB;
|
||||
|
||||
const navTheme = dark
|
||||
? { ...DarkTheme, colors: { ...DarkTheme.colors, background: "#0E0D0C" } }
|
||||
: { ...DefaultTheme, colors: { ...DefaultTheme.colors, background: "#FDFAF5" } };
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer theme={navTheme}>
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
|
||||
tabBarStyle: {
|
||||
backgroundColor: tab.bg,
|
||||
borderTopColor: tab.border,
|
||||
borderTopWidth: 1,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 6,
|
||||
},
|
||||
|
||||
tabBarActiveTintColor: tab.active,
|
||||
tabBarInactiveTintColor: tab.inactive,
|
||||
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 10,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
|
||||
tabBarIcon: ({ focused, color, size }) => {
|
||||
const icons: Record<
|
||||
string,
|
||||
{ active: keyof typeof Ionicons.glyphMap; inactive: keyof typeof Ionicons.glyphMap }
|
||||
> = {
|
||||
"Subscribed": { active: "bookmark", inactive: "bookmark-outline" },
|
||||
"Discover": { active: "compass", inactive: "compass-outline" },
|
||||
"Profile": { active: "person-circle", inactive: "person-circle-outline" },
|
||||
};
|
||||
const set = icons[route.name];
|
||||
|
||||
return (
|
||||
<Ionicons
|
||||
name={focused ? set.active : set.inactive}
|
||||
size={focused ? size + 1 : size}
|
||||
color={color}
|
||||
/>
|
||||
);
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Subscribed"
|
||||
component={SubscribedFeed}
|
||||
options={{ title: "My Feed" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Discover"
|
||||
component={AllFeed}
|
||||
options={{ title: "Discover" }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Profile"
|
||||
component={ProfileTab}
|
||||
options={{ title: "Profile" }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
</NavigationContainer>
|
||||
</SafeAreaProvider>
|
||||
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
|
||||
const config = getDefaultConfig(__dirname)
|
||||
|
||||
module.exports = withNativeWind(config, { input: './globals.css' })
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "etfoglasi-frontend",
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"reset-project": "node ./scripts/reset-project.js",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"lint": "expo lint"
|
||||
},
|
||||
"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",
|
||||
"expo": "~54.0.20",
|
||||
"expo-constants": "~18.0.10",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.13",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
"expo-system-ui": "~6.0.8",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"nativewind": "^4.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-tailwindcss": "^1.1.11",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwindcss": "^3.4.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-config-expo": "~10.0.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import CollapsibleCategory from "../components/CollapsibleCategory";
|
||||
|
||||
|
||||
const GROUPS = [
|
||||
{ id: "1-year", label: "Prva godina", groupName: "Прва година", icon: "book-outline" as const, color: "#3B7DD8", darkColor: "#5E9EF4" },
|
||||
{ id: "2-year", label: "Druga godina", groupName: "Друга година", icon: "book-outline" as const, color: "#2E9E6B", darkColor: "#4EC992" },
|
||||
{ id: "3-year", label: "Treća godina", groupName: "Трећа година", icon: "book-outline" as const, color: "#9B4FB8", darkColor: "#C47CE0" },
|
||||
{ id: "4-year", label: "Četvrta godina", groupName: "Четврта година", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" },
|
||||
|
||||
{ id: "2-cycle", label: "Drugi ciklus", groupName: "Други циклус", icon: "book-outline" as const, color: "#3B7DD8", darkColor: "#5E9EF4" },
|
||||
{ id: "3-cycle", label: "Treći ciklus", groupName: "Трећи циклус", icon: "book-outline" as const, color: "#2E9E6B", darkColor: "#4EC992" },
|
||||
{ id: "postgraduate", label: "Postdiplomski studij", groupName: "Постдипломски студиј", icon: "book-outline" as const, color: "#9B4FB8", darkColor: "#C47CE0" },
|
||||
{ id: "defenses", label: "Odbrane završnih radova", groupName: "Одбрана завршних радова", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" },
|
||||
];
|
||||
|
||||
|
||||
export default function AllFeed() {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const visibleGroups = query.trim()
|
||||
? GROUPS.filter((g) => g.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: GROUPS;
|
||||
|
||||
const handleRefresh = () => {
|
||||
setRefreshing(true);
|
||||
setRefreshKey((k) => k + 1);
|
||||
setTimeout(() => setRefreshing(false), 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||
<View className="px-4 pt-2 pb-1 flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Discover
|
||||
</Text>
|
||||
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{GROUPS.length} groups
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Refresh button */}
|
||||
<TouchableOpacity
|
||||
onPress={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className={`w-9 h-9 rounded-xl items-center justify-center ${
|
||||
dark ? "bg-obsidian-50" : "bg-parchment-200"
|
||||
}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{refreshing
|
||||
? <ActivityIndicator size="small" color={accent} />
|
||||
: <Ionicons name="refresh-outline" size={18} color={accent} />
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<SearchBar placeholder="Search groups…" onSearch={setQuery} />
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{visibleGroups.length === 0 ? (
|
||||
<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}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
visibleGroups.map((group, index) => (
|
||||
<CollapsibleCategory
|
||||
key={group.id}
|
||||
{...group}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -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?.();
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
View, Text, TextInput, TouchableOpacity,
|
||||
ActivityIndicator, ScrollView, useColorScheme,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
export default function EditProfile({ onClose }: { onClose: () => void }) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
const { user, updateUser } = useAuth();
|
||||
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPass, setConfirmPass] = useState("");
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const inputClass = `rounded-xl px-4 py-3.5 text-sm font-sans border ${
|
||||
dark
|
||||
? "bg-obsidian-100 border-border-dark text-ink-dark"
|
||||
: "bg-parchment-100 border-border-light text-ink-light"
|
||||
}`;
|
||||
|
||||
const save = async () => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
|
||||
if (newPassword && newPassword !== confirmPass) {
|
||||
setError("Passwords don't match.");
|
||||
return;
|
||||
}
|
||||
if (!newEmail && !newPassword) {
|
||||
setError("Enter a new email or password to update.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateUser(
|
||||
newEmail || undefined,
|
||||
newPassword || undefined,
|
||||
);
|
||||
setSuccess("Profile updated successfully.");
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setConfirmPass("");
|
||||
} catch (e: any) {
|
||||
setError(e.message ?? "Update failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
<View className={`flex-row items-center px-4 py-4 border-b ${
|
||||
dark ? "border-border-dark" : "border-border-light"
|
||||
}`}>
|
||||
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Edit Profile
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||
>
|
||||
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Current email (read-only) */}
|
||||
<View className={`mb-5 px-4 py-3.5 rounded-xl border ${
|
||||
dark ? "bg-obsidian-50 border-border-dark" : "bg-parchment-200 border-border-light"
|
||||
}`}>
|
||||
<Text className={`text-xs font-sans font-semibold mb-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
CURRENT EMAIL
|
||||
</Text>
|
||||
<Text className={`text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{user?.email}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Change email
|
||||
TODO fix this show if email is taken and there is also a bug when trying to
|
||||
change email many times ill fix it later if i feel like it or someone else can
|
||||
*/}
|
||||
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
NEW EMAIL
|
||||
</Text>
|
||||
<TextInput
|
||||
className={`${inputClass} mb-5`}
|
||||
placeholder="Leave blank to keep current"
|
||||
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||
value={newEmail}
|
||||
onChangeText={setNewEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<View className={`h-px mb-5 ${dark ? "bg-border-dark" : "bg-border-light"}`} />
|
||||
|
||||
{/* New password */}
|
||||
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
NEW PASSWORD
|
||||
</Text>
|
||||
<View className="relative mb-3">
|
||||
<TextInput
|
||||
className={inputClass}
|
||||
placeholder="Leave blank to keep current"
|
||||
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry={!showPass}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPass((v) => !v)}
|
||||
className="absolute right-3 top-3.5"
|
||||
>
|
||||
<Ionicons
|
||||
name={showPass ? "eye-off-outline" : "eye-outline"}
|
||||
size={18}
|
||||
color={dark ? "#706D67" : "#8A8278"}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Confirm password */}
|
||||
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
CONFIRM NEW PASSWORD
|
||||
</Text>
|
||||
<TextInput
|
||||
className={`${inputClass} mb-5`}
|
||||
placeholder="Repeat new password"
|
||||
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
|
||||
value={confirmPass}
|
||||
onChangeText={setConfirmPass}
|
||||
secureTextEntry={!showPass}
|
||||
/>
|
||||
|
||||
{/* Error */}
|
||||
{error !== "" && (
|
||||
<View className="mb-4 px-3 py-2.5 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<Text className="text-xs font-sans text-red-500">{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{success !== "" && (
|
||||
<View className="mb-4 px-3 py-2.5 rounded-xl"
|
||||
style={{ backgroundColor: dark ? "#2E9E6B20" : "#2E9E6B15",
|
||||
borderWidth: 1, borderColor: "#2E9E6B30" }}>
|
||||
<Text className="text-xs font-sans" style={{ color: "#2E9E6B" }}>
|
||||
{success}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<TouchableOpacity
|
||||
onPress={save}
|
||||
disabled={loading}
|
||||
className="py-4 rounded-2xl items-center"
|
||||
style={{ backgroundColor: accent }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{loading
|
||||
? <ActivityIndicator color="#fff" />
|
||||
: <Text className="text-sm font-sans font-semibold text-white">Save changes</Text>
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import React from "react";
|
||||
import {
|
||||
View, Text, TouchableOpacity,
|
||||
Linking, ScrollView, useColorScheme,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
const CONTACT_EMAIL = "contact@ksan.dev";
|
||||
//TODO make github page because yea
|
||||
const GITHUB_URL = "https://git.ksan.dev/ksan";
|
||||
const APP_VERSION = "1.0.0";
|
||||
|
||||
export default function HelpSupport({ onClose }: { onClose: () => void }) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const contactLinks = [
|
||||
{
|
||||
icon: "mail-outline" as const,
|
||||
label: "Contact support",
|
||||
sub: CONTACT_EMAIL,
|
||||
color: dark ? "#5E9EF4" : "#3B7DD8",
|
||||
onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`),
|
||||
},
|
||||
{
|
||||
icon: "logo-github" as const,
|
||||
label: "Submit a fix on GitHub",
|
||||
sub: "Open a pull request or report a bug",
|
||||
color: dark ? "#4EC992" : "#2E9E6B",
|
||||
onPress: () => Linking.openURL(GITHUB_URL),
|
||||
},
|
||||
];
|
||||
|
||||
const aboutRows = [
|
||||
{ label: "Version", value: APP_VERSION },
|
||||
{ label: "Platform", value: "React Native / Expo" },
|
||||
{ label: "Source code", value: "GitHub", onPress: () => Linking.openURL(GITHUB_URL) },
|
||||
{ label: "Contact", value: CONTACT_EMAIL, onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`) },
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
<View className={`flex-row items-center px-4 py-4 border-b ${dark ? "border-border-dark" : "border-border-light"
|
||||
}`}>
|
||||
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Help & Support
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||
>
|
||||
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* ── Contact section ── */}
|
||||
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||||
}`}>
|
||||
Contact
|
||||
</Text>
|
||||
|
||||
<Text className={`px-1 text-sm font-sans leading-relaxed mb-4 ${dark ? "text-muted-dark" : "text-muted-light"
|
||||
}`}>
|
||||
Need help or found something broken? Reach out directly or open an issue on GitHub.
|
||||
</Text>
|
||||
|
||||
<View className={`rounded-2xl overflow-hidden border mb-6 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||
}`}>
|
||||
{contactLinks.map((link, index) => (
|
||||
<TouchableOpacity
|
||||
key={link.label}
|
||||
onPress={link.onPress}
|
||||
activeOpacity={0.7}
|
||||
className={`flex-row items-center px-4 py-4 ${index < contactLinks.length - 1
|
||||
? `border-b ${dark ? "border-border-dark" : "border-border-light"}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className="w-10 h-10 rounded-xl items-center justify-center mr-3"
|
||||
style={{ backgroundColor: link.color + "20" }}
|
||||
>
|
||||
<Ionicons name={link.icon} size={20} color={link.color} />
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className={`text-sm font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{link.label}
|
||||
</Text>
|
||||
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{link.sub}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="open-outline" size={15} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* About section */}
|
||||
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||||
}`}>
|
||||
About
|
||||
</Text>
|
||||
|
||||
{/* App identity card */}
|
||||
<View
|
||||
className={`items-center py-6 rounded-2xl border mb-4 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||
}`}
|
||||
>
|
||||
<View
|
||||
className="w-16 h-16 rounded-2xl items-center justify-center mb-3"
|
||||
style={{ backgroundColor: accent + "20" }}
|
||||
>
|
||||
<Text style={{ fontSize: 32 }}>📰</Text>
|
||||
</View>
|
||||
<Text className={`text-lg font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
ETF Oglasi
|
||||
</Text>
|
||||
<Text className={`text-xs font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Version {APP_VERSION}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* About rows */}
|
||||
<View className={`rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||
}`}>
|
||||
{aboutRows.map((row, index) => {
|
||||
const isLast = index === aboutRows.length - 1;
|
||||
const Inner = (
|
||||
<View className={`flex-row items-center px-4 py-3.5 ${!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
|
||||
}`}>
|
||||
<Text className={`flex-1 text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text
|
||||
className="text-sm font-sans font-semibold"
|
||||
style={{ color: row.onPress ? accent : dark ? "#F0EDE8" : "#1A1714" }}
|
||||
>
|
||||
{row.value}
|
||||
</Text>
|
||||
{row.onPress && (
|
||||
<Ionicons
|
||||
name="open-outline"
|
||||
size={13}
|
||||
color={accent}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
return row.onPress ? (
|
||||
<TouchableOpacity key={row.label} onPress={row.onPress} activeOpacity={0.7}>
|
||||
{Inner}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View key={row.label}>{Inner}</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* Footer note */}
|
||||
<Text className={`text-center text-xs font-sans mt-6 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Built with ♥ — contributions welcome on GitHub
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Subject, subjectsApi } from "../services/api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
export default function ManageSubscriptions({ onClose }: { onClose: () => void }) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user, isSubscribed, subscribe, unsubscribe } = useAuth();
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
|
||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
subjectsApi.getAll(user?.token)
|
||||
.then(setSubjects)
|
||||
.catch(() => setError("Couldn't load subjects."))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const toggle = async (subject: Subject) => {
|
||||
setToggling(subject.id);
|
||||
try {
|
||||
if (isSubscribed(subject.id)) await unsubscribe(subject.id);
|
||||
else await subscribe(subject.id);
|
||||
} finally {
|
||||
setToggling(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
<View className={`flex-row items-center px-4 py-4 border-b ${
|
||||
dark ? "border-border-dark" : "border-border-light"
|
||||
}`}>
|
||||
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Manage Subscriptions
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||
>
|
||||
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Body */}
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator color={accent} />
|
||||
</View>
|
||||
|
||||
) : error ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-3xl mb-3">⚠️</Text>
|
||||
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View className={`rounded-2xl overflow-hidden border ${
|
||||
dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
|
||||
}`}>
|
||||
{subjects.map((subject, index) => {
|
||||
const subscribed = isSubscribed(subject.id);
|
||||
const busy = toggling === subject.id;
|
||||
const isLast = index === subjects.length - 1;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={subject.id}
|
||||
className={`flex-row items-center px-4 py-3.5 ${
|
||||
!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
|
||||
}`}
|
||||
>
|
||||
{/* Subscribed indicator dot */}
|
||||
<View
|
||||
className="w-2 h-2 rounded-full mr-3"
|
||||
style={{
|
||||
backgroundColor: subscribed ? accent : "transparent",
|
||||
borderWidth: subscribed ? 0 : 1.5,
|
||||
borderColor: dark ? "#706D67" : "#8A8278",
|
||||
}}
|
||||
/>
|
||||
|
||||
<Text className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{subject.name}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => toggle(subject)}
|
||||
disabled={!!toggling}
|
||||
className="px-3 py-1.5 rounded-xl"
|
||||
style={{
|
||||
backgroundColor: subscribed
|
||||
? dark ? "#2C2A27" : "#F0EDE8"
|
||||
: accent + "20",
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{busy ? (
|
||||
<ActivityIndicator size="small" color={accent} style={{ width: 40 }} />
|
||||
) : (
|
||||
<Text className="text-xs font-sans font-semibold" style={{ color: accent }}>
|
||||
{subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
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";
|
||||
import ManageSubscriptions from "@/screens/ManageSubscriptions";
|
||||
import EditProfile from "@/screens/EditProfile";
|
||||
import HelpSupport from "@/screens/HelpSupport";
|
||||
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: any;
|
||||
label: string;
|
||||
value?: string;
|
||||
toggle?: boolean;
|
||||
toggleValue?: boolean;
|
||||
onToggle?: (v: boolean) => void;
|
||||
onPress?: () => void;
|
||||
accent?: string;
|
||||
};
|
||||
|
||||
function SettingRow({ icon, label, value, toggle, toggleValue, onToggle, onPress, accent }: SettingRowProps) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const iconColor = accent ?? (dark ? "#706D67" : "#8A8278");
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
className={`flex-row items-center px-4 py-3.5 border-b ${dark ? "border-border-dark" : "border-border-light"}`}
|
||||
>
|
||||
<View
|
||||
className="w-8 h-8 rounded-xl items-center justify-center mr-3"
|
||||
style={{ backgroundColor: iconColor + "18" }}
|
||||
>
|
||||
<Ionicons name={icon} size={16} color={iconColor} />
|
||||
</View>
|
||||
<Text className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{label}
|
||||
</Text>
|
||||
{toggle ? (
|
||||
<Switch
|
||||
value={toggleValue}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ true: dark ? "#E07B45" : "#C4622D", false: dark ? "#2C2A27" : "#E8E2D5" }}
|
||||
thumbColor="#FFFFFF"
|
||||
/>
|
||||
) : value ? (
|
||||
<Text className={`text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>{value}</Text>
|
||||
) : (
|
||||
<Ionicons name="chevron-forward" size={14} color={dark ? "#706D67" : "#8A8278"} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ title }: { title: string }) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
return (
|
||||
<Text className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function GuestProfile({ onSignIn }: { onSignIn: () => void }) {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollView contentContainerStyle={{ paddingBottom: 48 }} showsVerticalScrollIndicator={false}>
|
||||
{/* Avatar 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-obsidian-50" : "bg-parchment-200"}`}>
|
||||
<Ionicons name="person-outline" size={36} color={dark ? "#706D67" : "#8A8278"} />
|
||||
</View>
|
||||
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Guest
|
||||
</Text>
|
||||
<Text className={`text-sm font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Sign in to sync your reading
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Settings available to guests */}
|
||||
<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="General" />
|
||||
<SettingRow
|
||||
icon="help-circle-outline"
|
||||
label="Help & support"
|
||||
onPress={() => setShowHelp(true)}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="information-circle-outline"
|
||||
label="About"
|
||||
onPress={() => setShowHelp(true)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Sign in / Register CTA */}
|
||||
<TouchableOpacity
|
||||
onPress={onSignIn}
|
||||
className="mx-4 mt-4 py-4 rounded-2xl items-center"
|
||||
style={{ backgroundColor: accent }}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Ionicons name="log-in-outline" size={18} color="#fff" />
|
||||
<Text className="text-sm font-sans font-semibold text-white">
|
||||
Sign in / Register
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text className={`text-center text-xs font-sans mt-3 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
You can still browse and save locally without an account.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
|
||||
<Modal
|
||||
visible={showHelp}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowHelp(false)}
|
||||
>
|
||||
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedProfile({ onSignOut }: { onSignOut: () => void }) {
|
||||
const [showSubs, setShowSubs] = useState(false);
|
||||
const dark = useColorScheme() === "dark";
|
||||
const { user } = useAuth();
|
||||
const [notifications, setNotifications] = React.useState(true);
|
||||
const [digest, setDigest] = React.useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showHelp, setShowHelp] = 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">
|
||||
<View className="items-center">
|
||||
<Text className={`text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
{user?.subscribedSubjectIds?.length ?? 0}
|
||||
</Text>
|
||||
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Subscribed
|
||||
</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 (TODO)"
|
||||
toggle toggleValue={notifications} onToggle={setNotifications}
|
||||
accent={dark ? "#E07B45" : "#C4622D"}
|
||||
/>
|
||||
<SettingRow
|
||||
icon="mail-outline" label="Daily digest email (TODO)"
|
||||
toggle toggleValue={digest} onToggle={setDigest}
|
||||
accent={dark ? "#5E9EF4" : "#3B7DD8"}
|
||||
/>
|
||||
|
||||
<SectionLabel title="Subscriptions" />
|
||||
<SettingRow
|
||||
icon="bookmark-outline"
|
||||
label="Manage subscriptions"
|
||||
onPress={() => setShowSubs(true)}
|
||||
accent={dark ? "#4EC992" : "#2E9E6B"}
|
||||
/>
|
||||
|
||||
<SectionLabel title="Account" />
|
||||
<SettingRow icon="person-outline" label="Edit profile" onPress={() => setShowEdit(true)} />
|
||||
<SettingRow icon="help-circle-outline" label="Help & support" onPress={() => setShowHelp(true)} />
|
||||
</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>
|
||||
<Modal visible={showEdit} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowEdit(false)}>
|
||||
<EditProfile onClose={() => setShowEdit(false)} />
|
||||
</Modal>
|
||||
|
||||
<Modal visible={showHelp} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowHelp(false)}>
|
||||
<HelpSupport onClose={() => setShowHelp(false)} />
|
||||
</Modal>
|
||||
<Modal
|
||||
visible={showSubs}
|
||||
animationType="slide"
|
||||
presentationStyle="pageSheet"
|
||||
onRequestClose={() => setShowSubs(false)}
|
||||
>
|
||||
<ManageSubscriptions onClose={() => setShowSubs(false)} />
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
|
||||
{/* AuthGate auto-closes the modal on success because user becomes non-null */}
|
||||
<AuthGate onSuccess={() => setShowAuth(false)} />
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
View, Text, ScrollView, TouchableOpacity,
|
||||
ActivityIndicator, useColorScheme,
|
||||
} from "react-native";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import SearchBar from "../components/SearchBar";
|
||||
import ExpandableItem from "../components/ExpandableItem";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { Entry, entriesApi } from "../services/api";
|
||||
|
||||
type SortMode = "time" | "subject";
|
||||
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-GB", {
|
||||
day: "numeric", month: "short", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function groupBySubject(entries: Entry[]): { label: string; items: Entry[] }[] {
|
||||
const map = new Map<string, Entry[]>();
|
||||
entries.forEach((e) => {
|
||||
const key = e.subject?.name ?? e.groupName;
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(e);
|
||||
});
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
function groupByDate(entries: Entry[]): { label: string; items: Entry[] }[] {
|
||||
const map = new Map<string, Entry[]>();
|
||||
entries.forEach((e) => {
|
||||
const key = formatDate(e.timePublished);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(e);
|
||||
});
|
||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||
}
|
||||
|
||||
|
||||
function SectionHeader({ label, dark }: { label: string; dark: boolean }) {
|
||||
return (
|
||||
<Text className={`px-4 pt-5 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
|
||||
}`}>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function SortToggle({
|
||||
mode, onChange, dark, accent,
|
||||
}: {
|
||||
mode: SortMode;
|
||||
onChange: (m: SortMode) => void;
|
||||
dark: boolean;
|
||||
accent: string;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-row mx-4 mb-3 rounded-xl p-1 ${dark ? "bg-obsidian-100" : "bg-parchment-200"
|
||||
}`}
|
||||
>
|
||||
{(["time", "subject"] as SortMode[]).map((m) => {
|
||||
const active = mode === m;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={m}
|
||||
onPress={() => onChange(m)}
|
||||
className="flex-1 flex-row items-center justify-center gap-1.5 py-2 rounded-lg"
|
||||
style={{ backgroundColor: active ? accent : "transparent" }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name={m === "time" ? "time-outline" : "albums-outline"}
|
||||
size={14}
|
||||
color={active ? "#fff" : dark ? "#706D67" : "#8A8278"}
|
||||
/>
|
||||
<Text
|
||||
className="text-xs font-sans font-semibold"
|
||||
style={{ color: active ? "#fff" : dark ? "#706D67" : "#8A8278" }}
|
||||
>
|
||||
{m === "time" ? "By time" : "By subject"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function SubscribedFeed() {
|
||||
const dark = useColorScheme() === "dark";
|
||||
const accent = dark ? "#E07B45" : "#C4622D";
|
||||
const { user } = useAuth();
|
||||
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortMode, setSortMode] = useState<SortMode>("time");
|
||||
|
||||
const fetchFeed = useCallback(async () => {
|
||||
if (!user || user.subscribedSubjectIds.length === 0) { setEntries([]); return; }
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const pages = await Promise.all(
|
||||
user.subscribedSubjectIds.map((subjectId) =>
|
||||
entriesApi.getEntries({ subjectId, page: 0 })
|
||||
)
|
||||
);
|
||||
const merged = pages
|
||||
.flatMap((p) => p.content)
|
||||
.sort((a, b) =>
|
||||
new Date(b.timePublished).getTime() - new Date(a.timePublished).getTime()
|
||||
);
|
||||
setEntries(merged);
|
||||
} catch {
|
||||
setError("Couldn't load your feed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.subscribedSubjectIds]);
|
||||
|
||||
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||||
|
||||
// Filter then group
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return entries;
|
||||
const lower = query.toLowerCase();
|
||||
return entries.filter((e) =>
|
||||
e.title.toLowerCase().includes(lower) ||
|
||||
e.subject?.name?.toLowerCase().includes(lower) ||
|
||||
e.groupName?.toLowerCase().includes(lower)
|
||||
);
|
||||
}, [query, entries]);
|
||||
|
||||
const grouped = useMemo(() =>
|
||||
sortMode === "time"
|
||||
? groupByDate(filtered)
|
||||
: groupBySubject(filtered),
|
||||
[filtered, sortMode]);
|
||||
|
||||
if (!user) return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-4xl mb-3">📰</Text>
|
||||
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Sign in to see your feed
|
||||
</Text>
|
||||
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Subscribe to subjects and their latest entries will appear here.
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
if (!loading && user.subscribedSubjectIds.length === 0) return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
<View className={`border-b px-4 pt-2 pb-3 ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>Your Feed</Text>
|
||||
</View>
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-4xl mb-3">🔖</Text>
|
||||
<Text className={`text-base font-sans font-semibold text-center mb-1 ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
No subscriptions yet
|
||||
</Text>
|
||||
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
Go to Discover and tap a subject tag to subscribe.
|
||||
</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
|
||||
{/* Header */}
|
||||
<View className={`border-b ${dark ? "border-border-dark" : "border-border-light"}`}>
|
||||
<View className="px-4 pt-2 pb-1 flex-row items-center">
|
||||
<View className="flex-1">
|
||||
<Text className={`text-2xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
|
||||
Your Feed
|
||||
</Text>
|
||||
<Text className={`text-xs mt-0.5 font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{loading
|
||||
? "Loading…"
|
||||
: `${entries.length} entries · ${user.subscribedSubjectIds.length} subjects`}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={fetchFeed}
|
||||
disabled={loading}
|
||||
className={`w-9 h-9 rounded-xl items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{loading
|
||||
? <ActivityIndicator size="small" color={accent} />
|
||||
: <Ionicons name="refresh-outline" size={18} color={accent} />
|
||||
}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<SearchBar placeholder="Search your feed…" onSearch={setQuery} />
|
||||
</View>
|
||||
|
||||
{loading && entries.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator color={accent} />
|
||||
</View>
|
||||
|
||||
) : error ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className="text-3xl mb-3">⚠️</Text>
|
||||
<Text className={`text-sm font-sans text-center mb-4 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
|
||||
{error}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
onPress={fetchFeed}
|
||||
className="px-5 py-2.5 rounded-xl"
|
||||
style={{ backgroundColor: accent }}
|
||||
>
|
||||
<Text className="text-sm font-sans font-semibold text-white">Retry</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{/* Toggle */}
|
||||
<View className="pt-3">
|
||||
<SortToggle
|
||||
mode={sortMode}
|
||||
onChange={setSortMode}
|
||||
dark={dark}
|
||||
accent={accent}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<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 results for "{query}"
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
grouped.map((section) => (
|
||||
<View key={section.label}>
|
||||
<SectionHeader label={section.label} dark={dark} />
|
||||
{section.items.map((item) => (
|
||||
<ExpandableItem key={item.id} item={item} />
|
||||
))}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
|
||||
const BASE_URL = process.env.EXPO_PUBLIC_API_URL; // TODO change this
|
||||
|
||||
|
||||
function authHeader(token?: string) {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
token?: string,
|
||||
body?: object,
|
||||
params?: Record<string, any>,
|
||||
): Promise<T> {
|
||||
const url = new URL(`${BASE_URL}${path}`);
|
||||
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(), {
|
||||
method,
|
||||
// @ts-ignore
|
||||
headers: { "Content-Type": "application/json", ...authHeader(token) },
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function get<T>(path: string, token?: string, params?: Record<string, any>) {
|
||||
return request<T>("GET", path, token, undefined, params);
|
||||
}
|
||||
|
||||
export function post<T>(path: string, body: object, token?: string) {
|
||||
return request<T>("POST", path, token, body);
|
||||
}
|
||||
|
||||
export function put<T>(
|
||||
path: string,
|
||||
body?: object,
|
||||
token?: string
|
||||
) {
|
||||
return request<T>("PUT", path, token, body);
|
||||
}
|
||||
|
||||
export function del<T>(path: string, token?: string) {
|
||||
return request<T>("DELETE", path, token);
|
||||
}
|
||||
|
||||
export type Subject = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type JwtResponse = {
|
||||
token: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type UserDTO = {
|
||||
id: string;
|
||||
email: string;
|
||||
subjectSet: Subject[];
|
||||
|
||||
};
|
||||
|
||||
|
||||
export type UserUpdateDTO = {
|
||||
email: string;
|
||||
newEmail?: string;
|
||||
password?: string;
|
||||
subjectSet: { id: string }[]; // pass existing subscriptions so they aren't wiped
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type Entry = {
|
||||
id: string;
|
||||
title: string;
|
||||
timePublished: string;
|
||||
groupName: string;
|
||||
infoEntry: string;
|
||||
paragraph: string;
|
||||
filepath?: string;
|
||||
subject: Subject;
|
||||
};
|
||||
|
||||
export type SpringPage<T> = {
|
||||
content: T[];
|
||||
totalPages: number;
|
||||
last: boolean;
|
||||
number: number;
|
||||
};
|
||||
|
||||
|
||||
export type LoginPayload = { email: string; password: string };
|
||||
export type RegisterPayload = { email: string; password: string; name?: string };
|
||||
|
||||
export const authApi = {
|
||||
login: (p: LoginPayload) => post<JwtResponse>("/login", p),
|
||||
register: (p: RegisterPayload) => post<UserDTO>("/register", p),
|
||||
getUser: (userId: string, token: string) => get<UserDTO>(`/users/${userId}`, token),
|
||||
updateUser: (body: UserUpdateDTO, token: string) => request<UserDTO>("PUT", "/users", token, body),
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const subjectsApi = {
|
||||
getAll: (token?: string) => get<Subject[]>("/subjects", token),
|
||||
};
|
||||
|
||||
export const subscriptionsApi = {
|
||||
subscribe: (userId: string, subjectId: string, token: string) =>
|
||||
put<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
{}, // body placeholder
|
||||
token
|
||||
),
|
||||
|
||||
unsubscribe: (userId: string, subjectId: string, token: string) =>
|
||||
del<UserDTO>(
|
||||
`/users/${userId}/subjects/${subjectId}`,
|
||||
token
|
||||
),
|
||||
};
|
||||
export const entriesApi = {
|
||||
getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) =>
|
||||
get<SpringPage<Entry>>("/entries", undefined, params),
|
||||
getEntry: (id: string) => get<Entry>(`/entries/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
"./screens/**/*.{js,jsx,ts,tsx}",
|
||||
"./components/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
|
||||
presets: [require("nativewind/preset")],
|
||||
|
||||
darkMode: "media", // follows system preference
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// ── Brand accent ──────────────────────────────
|
||||
accent: {
|
||||
DEFAULT: "#C4622D",
|
||||
dark: "#E07B45",
|
||||
muted: "#E8A882",
|
||||
},
|
||||
|
||||
// ── Light theme surfaces ───────────────────────
|
||||
parchment: {
|
||||
50: "#FDFAF5",
|
||||
100: "#F7F3EB",
|
||||
200: "#EDE7D9",
|
||||
300: "#DDD5C4",
|
||||
400: "#C9BEA9",
|
||||
},
|
||||
|
||||
// ── Dark theme surfaces ────────────────────────
|
||||
obsidian: {
|
||||
50: "#2A2825",
|
||||
100: "#1F1E1B",
|
||||
200: "#161513",
|
||||
300: "#0E0D0C",
|
||||
400: "#080807",
|
||||
},
|
||||
|
||||
// ── Semantic aliases (used in components) ──────
|
||||
surface: {
|
||||
light: "#FFFFFF",
|
||||
dark: "#1C1B19",
|
||||
},
|
||||
border: {
|
||||
light: "#E8E2D5",
|
||||
dark: "#2C2A27",
|
||||
},
|
||||
ink: {
|
||||
light: "#1A1714",
|
||||
dark: "#F0EDE8",
|
||||
},
|
||||
muted: {
|
||||
light: "#8A8278",
|
||||
dark: "#706D67",
|
||||
},
|
||||
},
|
||||
|
||||
fontFamily: {
|
||||
// Use with expo-font or react-native-google-fonts
|
||||
display: ["Playfair Display", "serif"],
|
||||
sans: ["DM Sans", "sans-serif"],
|
||||
mono: ["DM Mono", "monospace"],
|
||||
},
|
||||
|
||||
borderRadius: {
|
||||
"4xl": "2rem",
|
||||
},
|
||||
|
||||
spacing: {
|
||||
"18": "4.5rem",
|
||||
"22": "5.5rem",
|
||||
},
|
||||
|
||||
boxShadow: {
|
||||
card: "0 1px 4px rgba(0,0,0,0.08), 0 4px 16px rgba(0,0,0,0.06)",
|
||||
"card-dark": "0 1px 4px rgba(0,0,0,0.4), 0 4px 16px rgba(0,0,0,0.3)",
|
||||
tab: "0 -1px 0 rgba(0,0,0,0.06)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||