moved files to monorepo and updated controllers to start with /api

This commit is contained in:
2026-05-31 21:04:32 +02:00
parent e4cb598193
commit d6657d83dd
118 changed files with 19894 additions and 0 deletions
+100
View File
@@ -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 &#34;{query}&#34;
</Text>
</View>
) : (
visibleGroups.map((group, index) => (
<CollapsibleCategory
key={group.id}
{...group}
refreshKey={refreshKey}
/>
))
)}
</ScrollView>
</SafeAreaView>
);
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState } from "react";
import {
View, Text, TextInput, TouchableOpacity,
ActivityIndicator, KeyboardAvoidingView,
Platform, ScrollView, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "../context/AuthContext";
type Mode = "login" | "register";
export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) {
const dark = useColorScheme() === "dark";
const { login, register } = useAuth();
const [mode, setMode] = useState<Mode>("login");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showPass, setShowPass] = useState(false);
const accent = dark ? "#E07B45" : "#C4622D";
const submit = async () => {
setError("");
if (!email || !password) { setError("Please fill in all fields."); return; }
setLoading(true);
try {
if (mode === "login") await login({ email, password });
else await register({ email, password, name });
onSuccess?.();
} 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>
);
}
+185
View File
@@ -0,0 +1,185 @@
import React, { useState } from "react";
import {
View, Text, TextInput, TouchableOpacity,
ActivityIndicator, ScrollView, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "@/context/AuthContext";
export default function EditProfile({ onClose }: { onClose: () => void }) {
const dark = useColorScheme() === "dark";
const accent = dark ? "#E07B45" : "#C4622D";
const { user, updateUser } = useAuth();
const [newEmail, setNewEmail] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPass, setConfirmPass] = useState("");
const [showPass, setShowPass] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const inputClass = `rounded-xl px-4 py-3.5 text-sm font-sans border ${
dark
? "bg-obsidian-100 border-border-dark text-ink-dark"
: "bg-parchment-100 border-border-light text-ink-light"
}`;
const save = async () => {
setError("");
setSuccess("");
if (newPassword && newPassword !== confirmPass) {
setError("Passwords don't match.");
return;
}
if (!newEmail && !newPassword) {
setError("Enter a new email or password to update.");
return;
}
setLoading(true);
try {
await updateUser(
newEmail || undefined,
newPassword || undefined,
);
setSuccess("Profile updated successfully.");
setNewEmail("");
setNewPassword("");
setConfirmPass("");
} catch (e: any) {
setError(e.message ?? "Update failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */}
<View className={`flex-row items-center px-4 py-4 border-b ${
dark ? "border-border-dark" : "border-border-light"
}`}>
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
Edit Profile
</Text>
<TouchableOpacity
onPress={onClose}
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
>
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
keyboardShouldPersistTaps="handled"
>
{/* Current email (read-only) */}
<View className={`mb-5 px-4 py-3.5 rounded-xl border ${
dark ? "bg-obsidian-50 border-border-dark" : "bg-parchment-200 border-border-light"
}`}>
<Text className={`text-xs font-sans font-semibold mb-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
CURRENT EMAIL
</Text>
<Text className={`text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{user?.email}
</Text>
</View>
{/* Change email
TODO fix this show if email is taken and there is also a bug when trying to
change email many times ill fix it later if i feel like it or someone else can
*/}
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
NEW EMAIL
</Text>
<TextInput
className={`${inputClass} mb-5`}
placeholder="Leave blank to keep current"
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
value={newEmail}
onChangeText={setNewEmail}
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
{/* Divider */}
<View className={`h-px mb-5 ${dark ? "bg-border-dark" : "bg-border-light"}`} />
{/* New password */}
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
NEW PASSWORD
</Text>
<View className="relative mb-3">
<TextInput
className={inputClass}
placeholder="Leave blank to keep current"
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry={!showPass}
/>
<TouchableOpacity
onPress={() => setShowPass((v) => !v)}
className="absolute right-3 top-3.5"
>
<Ionicons
name={showPass ? "eye-off-outline" : "eye-outline"}
size={18}
color={dark ? "#706D67" : "#8A8278"}
/>
</TouchableOpacity>
</View>
{/* Confirm password */}
<Text className={`text-xs font-sans font-semibold mb-1.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
CONFIRM NEW PASSWORD
</Text>
<TextInput
className={`${inputClass} mb-5`}
placeholder="Repeat new password"
placeholderTextColor={dark ? "#706D67" : "#8A8278"}
value={confirmPass}
onChangeText={setConfirmPass}
secureTextEntry={!showPass}
/>
{/* Error */}
{error !== "" && (
<View className="mb-4 px-3 py-2.5 rounded-xl bg-red-500/10 border border-red-500/20">
<Text className="text-xs font-sans text-red-500">{error}</Text>
</View>
)}
{/* Success */}
{success !== "" && (
<View className="mb-4 px-3 py-2.5 rounded-xl"
style={{ backgroundColor: dark ? "#2E9E6B20" : "#2E9E6B15",
borderWidth: 1, borderColor: "#2E9E6B30" }}>
<Text className="text-xs font-sans" style={{ color: "#2E9E6B" }}>
{success}
</Text>
</View>
)}
{/* Save button */}
<TouchableOpacity
onPress={save}
disabled={loading}
className="py-4 rounded-2xl items-center"
style={{ backgroundColor: accent }}
activeOpacity={0.8}
>
{loading
? <ActivityIndicator color="#fff" />
: <Text className="text-sm font-sans font-semibold text-white">Save changes</Text>
}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
+174
View File
@@ -0,0 +1,174 @@
import React from "react";
import {
View, Text, TouchableOpacity,
Linking, ScrollView, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
const CONTACT_EMAIL = "contact@ksan.dev";
//TODO make github page because yea
const GITHUB_URL = "https://git.ksan.dev/ksan";
const APP_VERSION = "1.0.0";
export default function HelpSupport({ onClose }: { onClose: () => void }) {
const dark = useColorScheme() === "dark";
const accent = dark ? "#E07B45" : "#C4622D";
const contactLinks = [
{
icon: "mail-outline" as const,
label: "Contact support",
sub: CONTACT_EMAIL,
color: dark ? "#5E9EF4" : "#3B7DD8",
onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`),
},
{
icon: "logo-github" as const,
label: "Submit a fix on GitHub",
sub: "Open a pull request or report a bug",
color: dark ? "#4EC992" : "#2E9E6B",
onPress: () => Linking.openURL(GITHUB_URL),
},
];
const aboutRows = [
{ label: "Version", value: APP_VERSION },
{ label: "Platform", value: "React Native / Expo" },
{ label: "Source code", value: "GitHub", onPress: () => Linking.openURL(GITHUB_URL) },
{ label: "Contact", value: CONTACT_EMAIL, onPress: () => Linking.openURL(`mailto:${CONTACT_EMAIL}`) },
];
return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */}
<View className={`flex-row items-center px-4 py-4 border-b ${dark ? "border-border-dark" : "border-border-light"
}`}>
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
Help & Support
</Text>
<TouchableOpacity
onPress={onClose}
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
>
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
</TouchableOpacity>
</View>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
showsVerticalScrollIndicator={false}
>
{/* ── Contact section ── */}
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
}`}>
Contact
</Text>
<Text className={`px-1 text-sm font-sans leading-relaxed mb-4 ${dark ? "text-muted-dark" : "text-muted-light"
}`}>
Need help or found something broken? Reach out directly or open an issue on GitHub.
</Text>
<View className={`rounded-2xl overflow-hidden border mb-6 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
}`}>
{contactLinks.map((link, index) => (
<TouchableOpacity
key={link.label}
onPress={link.onPress}
activeOpacity={0.7}
className={`flex-row items-center px-4 py-4 ${index < contactLinks.length - 1
? `border-b ${dark ? "border-border-dark" : "border-border-light"}`
: ""
}`}
>
<View
className="w-10 h-10 rounded-xl items-center justify-center mr-3"
style={{ backgroundColor: link.color + "20" }}
>
<Ionicons name={link.icon} size={20} color={link.color} />
</View>
<View className="flex-1">
<Text className={`text-sm font-sans font-semibold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{link.label}
</Text>
<Text className={`text-xs font-sans mt-0.5 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{link.sub}
</Text>
</View>
<Ionicons name="open-outline" size={15} color={dark ? "#706D67" : "#8A8278"} />
</TouchableOpacity>
))}
</View>
{/* About section */}
<Text className={`px-1 pb-2 text-xs font-sans font-bold tracking-widest uppercase ${dark ? "text-muted-dark" : "text-muted-light"
}`}>
About
</Text>
{/* App identity card */}
<View
className={`items-center py-6 rounded-2xl border mb-4 ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
}`}
>
<View
className="w-16 h-16 rounded-2xl items-center justify-center mb-3"
style={{ backgroundColor: accent + "20" }}
>
<Text style={{ fontSize: 32 }}>📰</Text>
</View>
<Text className={`text-lg font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
ETF Oglasi
</Text>
<Text className={`text-xs font-sans mt-1 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Version {APP_VERSION}
</Text>
</View>
{/* About rows */}
<View className={`rounded-2xl overflow-hidden border ${dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
}`}>
{aboutRows.map((row, index) => {
const isLast = index === aboutRows.length - 1;
const Inner = (
<View className={`flex-row items-center px-4 py-3.5 ${!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
}`}>
<Text className={`flex-1 text-sm font-sans ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{row.label}
</Text>
<Text
className="text-sm font-sans font-semibold"
style={{ color: row.onPress ? accent : dark ? "#F0EDE8" : "#1A1714" }}
>
{row.value}
</Text>
{row.onPress && (
<Ionicons
name="open-outline"
size={13}
color={accent}
style={{ marginLeft: 4 }}
/>
)}
</View>
);
return row.onPress ? (
<TouchableOpacity key={row.label} onPress={row.onPress} activeOpacity={0.7}>
{Inner}
</TouchableOpacity>
) : (
<View key={row.label}>{Inner}</View>
);
})}
</View>
{/* Footer note */}
<Text className={`text-center text-xs font-sans mt-6 ${dark ? "text-muted-dark" : "text-muted-light"}`}>
Built with contributions welcome on GitHub
</Text>
</ScrollView>
</SafeAreaView>
);
}
+130
View File
@@ -0,0 +1,130 @@
import React, { useEffect, useState } from "react";
import {
View, Text, ScrollView, TouchableOpacity,
ActivityIndicator, useColorScheme,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { Subject, subjectsApi } from "../services/api";
import { useAuth } from "../context/AuthContext";
export default function ManageSubscriptions({ onClose }: { onClose: () => void }) {
const dark = useColorScheme() === "dark";
const { user, isSubscribed, subscribe, unsubscribe } = useAuth();
const accent = dark ? "#E07B45" : "#C4622D";
const [subjects, setSubjects] = useState<Subject[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [toggling, setToggling] = useState<string | null>(null);
useEffect(() => {
subjectsApi.getAll(user?.token)
.then(setSubjects)
.catch(() => setError("Couldn't load subjects."))
.finally(() => setLoading(false));
}, []);
const toggle = async (subject: Subject) => {
setToggling(subject.id);
try {
if (isSubscribed(subject.id)) await unsubscribe(subject.id);
else await subscribe(subject.id);
} finally {
setToggling(null);
}
};
return (
<SafeAreaView className={`flex-1 ${dark ? "bg-obsidian-200" : "bg-parchment-50"}`}>
{/* Header */}
<View className={`flex-row items-center px-4 py-4 border-b ${
dark ? "border-border-dark" : "border-border-light"
}`}>
<Text className={`flex-1 text-xl font-display font-bold ${dark ? "text-ink-dark" : "text-ink-light"}`}>
Manage Subscriptions
</Text>
<TouchableOpacity
onPress={onClose}
className={`w-8 h-8 rounded-full items-center justify-center ${dark ? "bg-obsidian-50" : "bg-parchment-200"}`}
>
<Ionicons name="close" size={18} color={dark ? "#706D67" : "#8A8278"} />
</TouchableOpacity>
</View>
{/* Body */}
{loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color={accent} />
</View>
) : error ? (
<View className="flex-1 items-center justify-center px-8">
<Text className="text-3xl mb-3"></Text>
<Text className={`text-sm font-sans text-center ${dark ? "text-muted-dark" : "text-muted-light"}`}>
{error}
</Text>
</View>
) : (
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
<View className={`rounded-2xl overflow-hidden border ${
dark ? "bg-obsidian-100 border-border-dark" : "bg-surface-light border-border-light"
}`}>
{subjects.map((subject, index) => {
const subscribed = isSubscribed(subject.id);
const busy = toggling === subject.id;
const isLast = index === subjects.length - 1;
return (
<View
key={subject.id}
className={`flex-row items-center px-4 py-3.5 ${
!isLast ? `border-b ${dark ? "border-border-dark" : "border-border-light"}` : ""
}`}
>
{/* Subscribed indicator dot */}
<View
className="w-2 h-2 rounded-full mr-3"
style={{
backgroundColor: subscribed ? accent : "transparent",
borderWidth: subscribed ? 0 : 1.5,
borderColor: dark ? "#706D67" : "#8A8278",
}}
/>
<Text className={`flex-1 text-sm font-sans ${dark ? "text-ink-dark" : "text-ink-light"}`}>
{subject.name}
</Text>
<TouchableOpacity
onPress={() => toggle(subject)}
disabled={!!toggling}
className="px-3 py-1.5 rounded-xl"
style={{
backgroundColor: subscribed
? dark ? "#2C2A27" : "#F0EDE8"
: accent + "20",
}}
activeOpacity={0.7}
>
{busy ? (
<ActivityIndicator size="small" color={accent} style={{ width: 40 }} />
) : (
<Text className="text-xs font-sans font-semibold" style={{ color: accent }}>
{subscribed ? "Unsubscribe" : "Subscribe"}
</Text>
)}
</TouchableOpacity>
</View>
);
})}
</View>
</ScrollView>
)}
</SafeAreaView>
);
}
+293
View File
@@ -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>
);
}
+269
View File
@@ -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>
);
}