diff --git a/components/CollapsibleCategory.tsx b/components/CollapsibleCategory.tsx index 7204045..9dacc7e 100644 --- a/components/CollapsibleCategory.tsx +++ b/components/CollapsibleCategory.tsx @@ -4,46 +4,43 @@ import { ActivityIndicator, LayoutAnimation, useColorScheme, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { Entry, entriesApi } from "../services/api"; +import { Entry, entriesApi } from "@/services/api"; import ExpandableItem from "./ExpandableItem"; -// ─── Props ───────────────────────────────────────────────────────────────── -// items removed — now fetched from API using groupName - export interface CollapsibleCategoryProps { - id: string; - label: string; - icon: keyof typeof Ionicons.glyphMap; - color: string; - darkColor: string; - groupName: string; // passed to /entries?groupName= - defaultOpen?: boolean; + id: string; + label: string; + icon: keyof typeof Ionicons.glyphMap; + color: string; + darkColor: string; + groupName: string; + refreshKey?: number; } -// ─── Component ───────────────────────────────────────────────────────────── - export default function CollapsibleCategory({ - label, - icon, - color, - darkColor, - groupName, - defaultOpen = false, - }: CollapsibleCategoryProps) { - const dark = useColorScheme() === "dark"; + label, + icon, + color, + darkColor, + groupName, + refreshKey = 0, +}: CollapsibleCategoryProps) { + const dark = useColorScheme() === "dark"; const accentColor = dark ? darkColor : color; - const [open, setOpen] = useState(defaultOpen); - const [items, setItems] = useState([]); - const [page, setPage] = useState(0); + const [open, setOpen] = useState(); + const [items, setItems] = useState([]); + const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + const [error, setError] = useState(""); - const initialised = useRef(false); + // Stable fetch — won't re-create when loading changes + const loadingRef = useRef(false); const fetchPage = useCallback(async (pageNum: number) => { - if (loading) return; + if (loadingRef.current) return; + loadingRef.current = true; setLoading(true); setError(""); try { @@ -56,17 +53,19 @@ export default function CollapsibleCategory({ } catch { setError("Couldn't load entries. Tap to retry."); } finally { + loadingRef.current = false; setLoading(false); } - }, [groupName, loading]); + }, [groupName]); - // Fetch first page the first time the accordion opens + // Fetch page 0 on mount AND whenever refreshKey changes useEffect(() => { - if (open && !initialised.current) { - initialised.current = true; - fetchPage(0); - } - }, [open]); + setItems([]); + setPage(0); + setHasMore(true); + setError(""); + fetchPage(0); + }, [refreshKey]); // refreshKey bump = full reset const toggle = () => { LayoutAnimation.configureNext({ @@ -74,22 +73,20 @@ export default function CollapsibleCategory({ create: { type: "easeInEaseOut", property: "opacity" }, update: { type: "spring", springDamping: 0.85 }, }); + // @ts-ignore bla bla bla setOpen((v) => !v); }; return ( - {/* ── Header — untouched from your original ── */} + {/* ── Header ── */} - + {label} - {/* Count badge — shows loaded count, spins while first load */} + {/* Count badge — spinner while loading first page */} ) : ( - + {items.length} )} @@ -130,10 +120,10 @@ export default function CollapsibleCategory({ /> - {/* ── Body ── */} + {/* Body */} {open && ( - {/* Error */} + {/* Error with retry */} {error !== "" && ( fetchPage(page)} @@ -145,7 +135,6 @@ export default function CollapsibleCategory({ )} - {/* Items — same ExpandableItem you already have */} {items.map((item) => ( ))} @@ -155,11 +144,10 @@ export default function CollapsibleCategory({ fetchPage(page + 1)} disabled={loading} - className={`mx-4 mt-1 mb-1 py-3 rounded-2xl border items-center ${ - dark + 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 ? ( @@ -167,10 +155,7 @@ export default function CollapsibleCategory({ ) : ( - + Load more @@ -178,11 +163,8 @@ export default function CollapsibleCategory({ )} - {/* End of list */} {!hasMore && items.length > 0 && ( - + All {items.length} entries loaded )} @@ -190,4 +172,4 @@ export default function CollapsibleCategory({ )} ); -} \ No newline at end of file +} diff --git a/components/ExpandableItem.tsx b/components/ExpandableItem.tsx index 792de10..c381b54 100644 --- a/components/ExpandableItem.tsx +++ b/components/ExpandableItem.tsx @@ -5,7 +5,9 @@ import { UIManager, View, useColorScheme, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; -import { Entry } from "../services/api"; +import { Entry, Subject } from "@/services/api"; +import { useAuth } from "@/context/AuthContext"; +import SubjectSheet from "@/components/SubjectSheet"; if (Platform.OS === "android") { UIManager.setLayoutAnimationEnabledExperimental?.(true); @@ -26,6 +28,11 @@ function fileName(path: string): string { } export default function ExpandableItem({ item }: { item: Entry }) { + + + const { user } = useAuth(); + const [sheetSubject, setSheetSubject] = useState(null); + const [expanded, setExpanded] = useState(false); const rotateAnim = useRef(new Animated.Value(0)).current; const dark = useColorScheme() === "dark"; @@ -45,135 +52,156 @@ export default function ExpandableItem({ item }: { item: Entry }) { }; const chevronRotation = rotateAnim.interpolate({ - inputRange: [0, 1], + inputRange: [0, 1], outputRange: ["0deg", "180deg"], }); return ( - - {/* ── Collapsed header ── */} - - + <> + + {/* Collapsed header */} + + - {/* Subject tag + date */} - - - - {item.subject?.name ?? item.groupName} + {/* Tag + date row */} + + + {/* Subject tag */} + true}> + {user ? ( + setSheetSubject(item.subject)} + className={`px-2 py-0.5 rounded-full ${dark ? "bg-accent-dark/20" : "bg-accent/10"}`} + activeOpacity={0.7} + > + + {item.subject?.name ?? item.groupName} + + + ) : ( + + + {item.subject?.name ?? item.groupName} + + + )} + + + {/* Group pill */} + + + {item.groupName} + + + + + {formatDate(item.timePublished)} - - - {item.groupName} - - - - {formatDate(item.timePublished)} - - - - {/* Title */} - - {item.title} - - - {/* info_entry — always visible as subtitle */} - {item.infoEntry ? ( + {/* Title */} - {item.infoEntry} + {item.title} - ) : null} - - - - - - - {/* ── Expanded body ── */} - {expanded && ( - - - {/* paragraph */} - {item.paragraph ? ( - - - Content + {/* info_entry — always visible as subtitle */} + {item.infoEntry ? ( + + {item.infoEntry} - - {item.paragraph} - - - ) : null} - - {/* Meta row: subject + group + date */} - - - - + ) : null} - {/* filepath — clickable attachment */} - {item.filepath ? ( - Linking.openURL(item.filepath!)} - activeOpacity={0.75} - className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${ - dark - ? "bg-obsidian-50 border-border-dark" - : "bg-parchment-100 border-border-light" - }`} - // stop the expand/collapse toggle firing - onStartShouldSetResponder={() => true} - > - - - - - - {fileName(item.filepath)} - - - Tap to open attachment - - - - - ) : null} + + + - )} - + + {/* Expanded body */} + {expanded && ( + + + {/* paragraph */} + {item.paragraph ? ( + + + Content + + + {item.paragraph} + + + ) : null} + + {/* Meta row: subject + group + date */} + + + + + + + {/* filepath — clickable attachment */} + {item.filepath ? ( + Linking.openURL(item.filepath!)} + activeOpacity={0.75} + className={`mx-4 mb-4 flex-row items-center gap-3 px-4 py-3 rounded-xl border ${dark + ? "bg-obsidian-50 border-border-dark" + : "bg-parchment-100 border-border-light" + }`} + + > + + + + + + {fileName(item.filepath)} + + + Tap to open attachment + + + + + ) : null} + + )} + setSheetSubject(null)} + /> + + ); } @@ -181,7 +209,7 @@ export default function ExpandableItem({ item }: { item: Entry }) { function Row({ label, value, dark, last = false }: { label: string; value?: string; - dark: boolean; + dark: boolean; last?: boolean; }) { if (!value) return null; @@ -195,4 +223,4 @@ function Row({ label, value, dark, last = false }: { ); -} \ No newline at end of file +} diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx index 24ed13b..ae32cb2 100644 --- a/components/SearchBar.tsx +++ b/components/SearchBar.tsx @@ -12,10 +12,11 @@ interface SearchBarProps { onSearch?: (text: string) => void; } +//TODO make cirilic==latinic when searching export default function SearchBar({ - placeholder = "Search…", - onSearch, - }: SearchBarProps) { + placeholder = "Search…", + onSearch, +}: SearchBarProps) { const [query, setQuery] = useState(""); const scheme = useColorScheme(); const dark = scheme === "dark"; @@ -30,9 +31,9 @@ export default function SearchBar({ 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" - } + ? "bg-obsidian-100 border border-border-dark" + : "bg-parchment-100 border border-border-light" + } `} > ); -} \ No newline at end of file +} diff --git a/components/SubjectSheet.tsx b/components/SubjectSheet.tsx new file mode 100644 index 0000000..aa572ee --- /dev/null +++ b/components/SubjectSheet.tsx @@ -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 ( + + {/* Backdrop */} + + {/* Sheet — stop tap propagation */} + { }}> + + {/* Drag handle */} + + + {/* Group pill */} + + + + {/* Subject name */} + + {subject.name} + + + + {subscribed + ? "You're subscribed to this subject." + : "Subscribe to see this subject in your feed."} + + + {/* Subscribe / Unsubscribe button */} + {user ? ( + + {loading ? ( + + ) : ( + <> + + + {subscribed ? "Unsubscribe" : "Subscribe"} + + + )} + + ) : ( + + Sign in to subscribe to subjects. + + )} + + + + + ); +} diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index 2449079..7420cb5 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -1,24 +1,36 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { authApi, LoginPayload, RegisterPayload, JwtResponse } from "../services/api"; +import { + authApi, -type User = { email: string; token: string }; + 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; + login: (p: LoginPayload) => Promise; register: (p: RegisterPayload) => Promise; - logout: () => Promise; + logout: () => Promise; + subscribe: (subjectId: string) => Promise; + unsubscribe: (subjectId: string) => Promise; + isSubscribed: (subjectId: string) => boolean; + updateUser: (newEmail?: string, newPassword?: string) => Promise; }; const AuthContext = createContext(null); export function AuthProvider({ children }: { children: React.ReactNode }) { - const [user, setUser] = useState(null); + const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - // Restore session on mount useEffect(() => { AsyncStorage.getItem("auth_user") .then((raw) => { if (raw) setUser(JSON.parse(raw)); }) @@ -27,25 +39,81 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const persist = async (u: User | null) => { if (u) await AsyncStorage.setItem("auth_user", JSON.stringify(u)); - else await AsyncStorage.removeItem("auth_user"); + else await AsyncStorage.removeItem("auth_user"); setUser(u); }; const login = async (payload: LoginPayload) => { const res = await authApi.login(payload); - await persist({ email: res.email, token: res.token }); + + // 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); - // Auto-login after register 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 ( - + {children} ); @@ -55,4 +123,4 @@ export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth must be used inside AuthProvider"); return ctx; -} \ No newline at end of file +} diff --git a/data/mockData.ts b/data/mockData.ts deleted file mode 100644 index 360681b..0000000 --- a/data/mockData.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { FeedItem } from "@/components/ExpandableItem"; -import { Category } from "@/components/CollapsibleCategory"; - - - -// random generated data to check the UI before I connect to the server and fetch data - -// ── 1st Year Subjects ─────────────────────────────────────────────────────── - -const YEAR_1_SUBJECTS = [ - { - id: "y1-maths", - title: "Mathematics I", - author: "Prof. John Doe", - timestamp: "2 hours ago", - tag: "Mathematics", - paragraphs: [ - "Introduction to Calculus, Linear Algebra, and Differential Equations.", - "Topics include Limits, Continuity, and Integration techniques." - ] - }, - { - id: "y1-physics", - title: "Physics I", - author: "Prof. Jane Smith", - timestamp: "5 hours ago", - tag: "Physics", - paragraphs: [ - "Fundamentals of Mechanics, Thermodynamics, and Waves.", - "Topics include Newton’s Laws, Energy, and Thermodynamics." - ] - }, -]; - -// ── 2nd Year Subjects ─────────────────────────────────────────────────────── - -const YEAR_2_SUBJECTS = [ - { - id: "y2-maths", - title: "Mathematics II", - author: "Prof. Alan Walker", - timestamp: "1 day ago", - tag: "Mathematics", - paragraphs: [ - "Advanced Calculus, Linear Algebra, and Complex Analysis.", - "Topics include Eigenvalues, Eigenvectors, and Fourier Series." - ] - }, - { - id: "y2-electronics", - title: "Basic Electronics", - author: "Prof. Emma Stone", - timestamp: "Yesterday", - tag: "Electronics", - paragraphs: [ - "Introduction to basic electrical circuits, components, and semiconductor devices.", - "Topics include Resistors, Capacitors, Diodes, and Transistors." - ] - }, -]; - -// ── 3rd Year Subjects ─────────────────────────────────────────────────────── - -const YEAR_3_SUBJECTS = [ - { - id: "y3-maths", - title: "Mathematics III", - author: "Prof. Robert Brown", - timestamp: "3 days ago", - tag: "Mathematics", - paragraphs: [ - "Partial Differential Equations, Laplace Transforms, and Numerical Methods.", - "Topics include PDEs, Laplace Transforms, and Optimization Techniques." - ] - }, - { - id: "y3-physics", - title: "Physics III", - author: "Prof. Alice White", - timestamp: "Yesterday", - tag: "Physics", - paragraphs: [ - "Quantum Mechanics, Relativity, and Solid-State Physics.", - "Topics include Schrödinger’s Equation, Quantum States, and Relativity." - ] - }, -]; - -// ── 4th Year Subjects ─────────────────────────────────────────────────────── - -const YEAR_4_SUBJECTS = [ - { - id: "y4-electronics", - title: "Electronics Engineering Design", - author: "Prof. Kate Johnson", - timestamp: "1 week ago", - tag: "Electronics", - paragraphs: [ - "Project-based learning and design of electronic systems.", - "Topics include Embedded Systems, PCB Design, and Signal Processing." - ] - }, - { - id: "y4-physics", - title: "Physics IV", - author: "Prof. Michael Harris", - timestamp: "5 days ago", - tag: "Physics", - paragraphs: [ - "Advanced topics in Particle Physics, Astrophysics, and Nuclear Physics.", - "Topics include Quantum Field Theory, Astrophysics, and Particle Accelerators." - ] - }, -]; - -// ── Subscribed Feed ────────────────────────────────────────────────────────── - -export const SUBSCRIBED_ITEMS: FeedItem[] = [ - { - id: "s1", - title: "Mathematics I", - author: "Prof. John Doe", - timestamp: "2 hours ago", - tag: "Mathematics", - paragraphs: YEAR_1_SUBJECTS[0].paragraphs, - }, - { - id: "s2", - title: "Physics I", - author: "Prof. Jane Smith", - timestamp: "5 hours ago", - tag: "Physics", - paragraphs: YEAR_1_SUBJECTS[1].paragraphs, - }, - { - id: "s3", - title: "Mathematics II", - author: "Prof. Alan Walker", - timestamp: "1 day ago", - tag: "Mathematics", - paragraphs: YEAR_2_SUBJECTS[0].paragraphs, - }, - { - id: "s4", - title: "Basic Electronics", - author: "Prof. Emma Stone", - timestamp: "Yesterday", - tag: "Electronics", - paragraphs: YEAR_2_SUBJECTS[1].paragraphs, - }, - { - id: "s5", - title: "Mathematics III", - author: "Prof. Robert Brown", - timestamp: "3 days ago", - tag: "Mathematics", - paragraphs: YEAR_3_SUBJECTS[0].paragraphs, - }, -]; - -// ── All Feed: Years & Subjects ────────────────────────────────────────────── - -export const ALL_CATEGORIES: Category[] = [ - { - id: "cat-year-1", - label: "1st Year", - icon: "book-outline", - color: "#3B7DD8", - darkColor: "#5E9EF4", - items: YEAR_1_SUBJECTS, - }, - { - id: "cat-year-2", - label: "2nd Year", - icon: "book-outline", - color: "#2E9E6B", - darkColor: "#4EC992", - items: YEAR_2_SUBJECTS, - }, - { - id: "cat-year-3", - label: "3rd Year", - icon: "book-outline", - color: "#9B4FB8", - darkColor: "#C47CE0", - items: YEAR_3_SUBJECTS, - }, - { - id: "cat-year-4", - label: "4th Year", - icon: "book-outline", - color: "#C4622D", - darkColor: "#E07B45", - items: YEAR_4_SUBJECTS, - }, -]; \ No newline at end of file diff --git a/screens/AllFeed.tsx b/screens/AllFeed.tsx index b2491d9..65b86ab 100644 --- a/screens/AllFeed.tsx +++ b/screens/AllFeed.tsx @@ -1,114 +1,102 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useState } from "react"; import { - View, Text, ScrollView, - ActivityIndicator, useColorScheme, TouchableOpacity, + 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, { CollapsibleCategoryProps } from "../components/CollapsibleCategory"; -import { entriesApi } from "../services/api"; +import CollapsibleCategory from "../components/CollapsibleCategory"; -// Cycles through as many groups as the API returns -const PALETTE: Pick[] = [ - { icon: "book-outline", color: "#3B7DD8", darkColor: "#5E9EF4" }, - { icon: "book-outline", color: "#2E9E6B", darkColor: "#4EC992" }, - { icon: "book-outline", color: "#9B4FB8", darkColor: "#C47CE0" }, - { icon: "book-outline", color: "#C4622D", darkColor: "#E07B45" }, - { icon: "book-outline", color: "#B8834F", darkColor: "#D4A574" }, - { icon: "book-outline", color: "#2E7D9E", darkColor: "#4EA8C9" }, + +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" }, + // Tbh need to fix something on the server side for some reason its grouping defence as third ciclus but ill fix it later if i feel like it for now idc + //{ id: "defenses", label: "Odbrane zavrsšnih radova", groupName: "Одбране завршних радова", icon: "book-outline" as const, color: "#C4622D", darkColor: "#E07B45" }, + { 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 [groups, setGroups] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(""); + const [refreshKey, setRefreshKey] = useState(0); + const [refreshing, setRefreshing] = useState(false); - const loadGroups = () => { - setLoading(true); - setError(""); - entriesApi.getGroups() - .then(setGroups) - .catch(() => setError("Couldn't load groups.")) - .finally(() => setLoading(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); }; - useEffect(() => { loadGroups(); }, []); - - const visibleGroups = useMemo(() => { - if (!query.trim()) return groups; - const lower = query.toLowerCase(); - return groups.filter((g) => g.toLowerCase().includes(lower)); - }, [query, groups]); - return ( {/* Header */} - - - Discover - - - {loading ? "Loading…" : `${groups.length} groups`} - - - - + + + + Discover + + + {GROUPS.length} groups + + - {/* States */} - {loading ? ( - - - - - ) : error ? ( - - ⚠️ - - {error} - + {/* Refresh button */} - Retry + {refreshing + ? + : + } - ) : ( - - {visibleGroups.length === 0 ? ( - - 🔍 - - No groups match "{query}" - - - Try a different search term. - - - ) : ( - visibleGroups.map((groupName, index) => ( - - )) - )} - - )} + + + + + {visibleGroups.length === 0 ? ( + + 🔍 + + No groups match "{query}" + + + ) : ( + visibleGroups.map((group, index) => ( + + )) + )} + ); } \ No newline at end of file diff --git a/screens/AuthGate.tsx b/screens/AuthGate.tsx index 7fb4720..83c6337 100644 --- a/screens/AuthGate.tsx +++ b/screens/AuthGate.tsx @@ -10,15 +10,16 @@ import { useAuth } from "../context/AuthContext"; type Mode = "login" | "register"; -export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { const dark = useColorScheme() === "dark"; +export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { + const dark = useColorScheme() === "dark"; const { login, register } = useAuth(); - const [mode, setMode] = useState("login"); - const [email, setEmail] = useState(""); + const [mode, setMode] = useState("login"); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [name, setName] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); const [showPass, setShowPass] = useState(false); const accent = dark ? "#E07B45" : "#C4622D"; @@ -31,7 +32,7 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { try { if (mode === "login") await login({ email, password }); else await register({ email, password, name }); - onSuccess?.(); // ← add this + onSuccess?.(); } catch (e: any) { setError(e.message ?? "Something went wrong."); } finally { @@ -42,8 +43,8 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { 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"} + ? "bg-obsidian-100 border-border-dark text-ink-dark" + : "bg-parchment-100 border-border-light text-ink-light"} `; return ( @@ -80,11 +81,10 @@ export default function AuthGate({ onSuccess }: { onSuccess?: () => void }) { {/* Card */} void }) { ); -} \ No newline at end of file +} diff --git a/screens/EditProfile.tsx b/screens/EditProfile.tsx new file mode 100644 index 0000000..596c5d7 --- /dev/null +++ b/screens/EditProfile.tsx @@ -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 ( + + {/* Header */} + + + Edit Profile + + + + + + + + {/* Current email (read-only) */} + + + CURRENT EMAIL + + + {user?.email} + + + + {/* 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 + */} + + NEW EMAIL + + + + {/* Divider */} + + + {/* New password */} + + NEW PASSWORD + + + + setShowPass((v) => !v)} + className="absolute right-3 top-3.5" + > + + + + + {/* Confirm password */} + + CONFIRM NEW PASSWORD + + + + {/* Error */} + {error !== "" && ( + + {error} + + )} + + {/* Success */} + {success !== "" && ( + + + {success} + + + )} + + {/* Save button */} + + {loading + ? + : Save changes + } + + + + ); +} \ No newline at end of file diff --git a/screens/HelpSupport.tsx b/screens/HelpSupport.tsx new file mode 100644 index 0000000..6b73ba8 --- /dev/null +++ b/screens/HelpSupport.tsx @@ -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 ( + + {/* Header */} + + + Help & Support + + + + + + + + {/* ── Contact section ── */} + + Contact + + + + Need help or found something broken? Reach out directly or open an issue on GitHub. + + + + {contactLinks.map((link, index) => ( + + + + + + + {link.label} + + + {link.sub} + + + + + ))} + + + {/* About section */} + + About + + + {/* App identity card */} + + + 📰 + + + ETF Oglasi + + + Version {APP_VERSION} + + + + {/* About rows */} + + {aboutRows.map((row, index) => { + const isLast = index === aboutRows.length - 1; + const Inner = ( + + + {row.label} + + + {row.value} + + {row.onPress && ( + + )} + + ); + + return row.onPress ? ( + + {Inner} + + ) : ( + {Inner} + ); + })} + + + {/* Footer note */} + + Built with ♥ — contributions welcome on GitHub + + + + ); +} diff --git a/screens/ManageSubscriptions.tsx b/screens/ManageSubscriptions.tsx new file mode 100644 index 0000000..aba748f --- /dev/null +++ b/screens/ManageSubscriptions.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [toggling, setToggling] = useState(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 ( + + {/* Header */} + + + Manage Subscriptions + + + + + + + {/* Body */} + {loading ? ( + + + + + ) : error ? ( + + ⚠️ + + {error} + + + + ) : ( + + + {subjects.map((subject, index) => { + const subscribed = isSubscribed(subject.id); + const busy = toggling === subject.id; + const isLast = index === subjects.length - 1; + + return ( + + {/* Subscribed indicator dot */} + + + + {subject.name} + + + toggle(subject)} + disabled={!!toggling} + className="px-3 py-1.5 rounded-xl" + style={{ + backgroundColor: subscribed + ? dark ? "#2C2A27" : "#F0EDE8" + : accent + "20", + }} + activeOpacity={0.7} + > + {busy ? ( + + ) : ( + + {subscribed ? "Unsubscribe" : "Subscribe"} + + )} + + + ); + })} + + + )} + + ); +} \ No newline at end of file diff --git a/screens/Profile.tsx b/screens/Profile.tsx index 5a21708..9af57df 100644 --- a/screens/Profile.tsx +++ b/screens/Profile.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import { View, Text, ScrollView, TouchableOpacity, Switch, Modal, useColorScheme, @@ -7,8 +7,10 @@ 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"; -// ─── Sub-components ──────────────────────────────────────────────────────── type SettingRowProps = { icon: any; @@ -65,173 +67,209 @@ function SectionLabel({ title }: { title: string }) { ); } -// ─── Guest profile (logged out) ──────────────────────────────────────────── function GuestProfile({ onSignIn }: { onSignIn: () => void }) { - const dark = useColorScheme() === "dark"; + const dark = useColorScheme() === "dark"; const accent = dark ? "#E07B45" : "#C4622D"; + const [showHelp, setShowHelp] = useState(false); return ( - - {/* Avatar block */} - - {/* Anonymous avatar */} + <> + + {/* Avatar block */} - - - - Guest - - - Sign in to sync your reading - - - - {/* Settings available to guests */} - - - {}} /> - {}} /> - - - {/* Sign in / Register CTA */} - - - - - Sign in / Register + + + + + Guest + + + Sign in to sync your reading - - - You can still browse and save locally without an account. - - + {/* Settings available to guests */} + + + setShowHelp(true)} + /> + setShowHelp(true)} + /> + + + {/* Sign in / Register CTA */} + + + + + Sign in / Register + + + + + + You can still browse and save locally without an account. + + + + setShowHelp(false)} + > + setShowHelp(false)} /> + + ); } -// ─── Authenticated profile (logged in) ──────────────────────────────────── - 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 [digest, setDigest] = React.useState(false); + const [showEdit, setShowEdit] = useState(false); + const [showHelp, setShowHelp] = useState(false); return ( - - {/* Avatar + name block */} - - - 📰 - - - {user?.email?.split("@")[0] ?? "Reader"} - - - {user?.email} - + <> + + {/* Avatar + name block */} + + + 📰 + + + {user?.email?.split("@")[0] ?? "Reader"} + + + {user?.email} + - {/* Stats row */} - - {[ - { label: "Subscribed", value: "5" }, - { label: "Read", value: "128" }, - { label: "Saved", value: "34" }, - ].map((stat) => ( - + {/* Stats row */} + + - {stat.value} + {user?.subscribedSubjectIds?.length ?? 0} - {stat.label} + Subscribed - ))} + - - {/* Settings sections */} - - - - + {/* Settings sections */} + + + + - - {}} accent={dark ? "#4EC992" : "#2E9E6B"} - /> + + setShowSubs(true)} + accent={dark ? "#4EC992" : "#2E9E6B"} + /> - - {}} /> - {}} /> - - - {/* Sign out */} - - - - Sign out + + setShowEdit(true)} /> + setShowHelp(true)} /> - - + + {/* Sign out */} + + + + Sign out + + + + + setShowEdit(false)}> + setShowEdit(false)} /> + + + setShowHelp(false)}> + setShowHelp(false)} /> + + setShowSubs(false)} + > + setShowSubs(false)} /> + + + + ); + + } -// ─── Root export ─────────────────────────────────────────────────────────── export default function Profile() { - const dark = useColorScheme() === "dark"; + const dark = useColorScheme() === "dark"; const { user, logout } = useAuth(); const [showAuth, setShowAuth] = React.useState(false); return ( {user - ? + ? + + : setShowAuth(true)} /> } @@ -252,4 +290,4 @@ export default function Profile() { ); -} \ No newline at end of file +} diff --git a/screens/SubscribedFeed.tsx b/screens/SubscribedFeed.tsx index c70c05e..b12aa6d 100644 --- a/screens/SubscribedFeed.tsx +++ b/screens/SubscribedFeed.tsx @@ -1,92 +1,269 @@ -import React, { useState, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { - View, - Text, - ScrollView, - useColorScheme, + 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 { SUBSCRIBED_ITEMS } from "@/data/mockData"; +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(); + 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(); + 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 ( + + {label} + + ); +} + + +function SortToggle({ + mode, onChange, dark, accent, +}: { + mode: SortMode; + onChange: (m: SortMode) => void; + dark: boolean; + accent: string; +}) { + return ( + + {(["time", "subject"] as SortMode[]).map((m) => { + const active = mode === m; + return ( + 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} + > + + + {m === "time" ? "By time" : "By subject"} + + + ); + })} + + ); +} + export default function SubscribedFeed() { + const dark = useColorScheme() === "dark"; + const accent = dark ? "#E07B45" : "#C4622D"; + const { user } = useAuth(); + + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); const [query, setQuery] = useState(""); - const scheme = useColorScheme(); - const dark = scheme === "dark"; + const [sortMode, setSortMode] = useState("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 SUBSCRIBED_ITEMS; + if (!query.trim()) return entries; const lower = query.toLowerCase(); - return SUBSCRIBED_ITEMS.filter( - (item) => - item.title.toLowerCase().includes(lower) || - item.author.toLowerCase().includes(lower) || - item.tag.toLowerCase().includes(lower) + return entries.filter((e) => + e.title.toLowerCase().includes(lower) || + e.subject?.name?.toLowerCase().includes(lower) || + e.groupName?.toLowerCase().includes(lower) ); - }, [query]); + }, [query, entries]); - return ( - + const grouped = useMemo(() => + sortMode === "time" + ? groupByDate(filtered) + : groupBySubject(filtered), + [filtered, sortMode]); - - - - Your Feed - - - {SUBSCRIBED_ITEMS.length} subscriptions · updated just now - - - - + if (!user) return ( + + + Your Feed + + + 📰 + + Sign in to see your feed + + + Subscribe to subjects and their latest entries will appear here. + - - {/* Scrollable list */} - - {filtered.length === 0 ? ( - - 🔍 - - No results for "{query}" - - - Try a different title, author, or topic tag. - - - ) : ( - filtered.map((item) => ) - )} - ); -} \ No newline at end of file + + if (!loading && user.subscribedSubjectIds.length === 0) return ( + + + Your Feed + + + 🔖 + + No subscriptions yet + + + Go to Discover and tap a subject tag to subscribe. + + + + ); + + return ( + + {/* Header */} + + + + + Your Feed + + + {loading + ? "Loading…" + : `${entries.length} entries · ${user.subscribedSubjectIds.length} subjects`} + + + + {loading + ? + : + } + + + + + + {loading && entries.length === 0 ? ( + + + + + ) : error ? ( + + ⚠️ + + {error} + + + Retry + + + + ) : ( + + {/* Toggle */} + + + + + {filtered.length === 0 ? ( + + 🔍 + + No results for "{query}" + + + ) : ( + grouped.map((section) => ( + + + {section.items.map((item) => ( + + ))} + + )) + )} + + )} + + ); +} diff --git a/services/api.tsx b/services/api.tsx index ec1aae5..bf51a50 100644 --- a/services/api.tsx +++ b/services/api.tsx @@ -1,70 +1,135 @@ -const BASE_URL = "http://192.168.100.6:8080"; // TODO change this +import React from "react"; -async function post(path: string, body: object): Promise { - const res = await fetch(`${BASE_URL}${path}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`)); - return res.json(); +const BASE_URL = "http://192.168.100.163:8080"; // TODO change this + + +function authHeader(token?: string) { + return token ? { Authorization: `Bearer ${token}` } : {}; } -async function get(path: string, params?: Record): Promise { +async function request( + method: string, + path: string, + token?: string, + body?: object, + params?: Record, +): Promise { const url = new URL(`${BASE_URL}${path}`); if (params) { Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); }); } - const res = await fetch(url.toString()); + 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 type LoginPayload = { email: string; password: string }; -export type RegisterPayload = { email: string; password: string;}; -export type JwtResponse = { token: string; email: string; message: string }; -export type UserDTO = { id: number; email: string; name?: string }; +export function get(path: string, token?: string, params?: Record) { + return request("GET", path, token, undefined, params); +} + +export function post(path: string, body: object, token?: string) { + return request("POST", path, token, body); +} + +export function put( + path: string, + body?: object, + token?: string +) { + return request("PUT", path, token, body); +} + +export function del(path: string, token?: string) { + return request("DELETE", path, token); +} export type Subject = { - id: string; + 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; + id: string; + title: string; timePublished: string; - groupName: string; - infoEntry: string; - paragraph: string; - filepath?: string; - subject: Subject; + groupName: string; + infoEntry: string; + paragraph: string; + filepath?: string; + subject: Subject; }; export type SpringPage = { - content: T[]; - totalPages: number; - totalElements: number; - last: boolean; - number: number; + 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("/login", p), + login: (p: LoginPayload) => post("/login", p), register: (p: RegisterPayload) => post("/register", p), + getUser: (userId: string, token: string) => get(`/users/${userId}`, token), + updateUser: (body: UserUpdateDTO, token: string) => request("PUT", "/users", token, body), }; + +export const subjectsApi = { + getAll: (token?: string) => get("/subjects", token), +}; + +export const subscriptionsApi = { + subscribe: (userId: string, subjectId: string, token: string) => + put( + `/users/${userId}/subjects/${subjectId}`, + {}, // body placeholder + token + ), + + unsubscribe: (userId: string, subjectId: string, token: string) => + del( + `/users/${userId}/subjects/${subjectId}`, + token + ), +}; export const entriesApi = { - getGroups: () => - get("/groups"), - getEntries: (params: { subjectId?: string; groupName?: string; page?: number }) => - get>("/entries", params), - - getEntry: (id: string) => - get(`/entries/${id}`), -}; \ No newline at end of file + get>("/entries", undefined, params), + getEntry: (id: string) => get(`/entries/${id}`), +};