project setup
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { Redirect, Stack } from "expo-router";
|
||||
import { View } from "react-native";
|
||||
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
|
||||
const AuthRoutesLayout = () => {
|
||||
const { isAuthenticated, bootstrapping } = useAuthContext();
|
||||
|
||||
if (bootstrapping) {
|
||||
return (
|
||||
<View className="flex-1 p-5 bg-primaryPurpleLight">
|
||||
<View
|
||||
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href={"/"} />;
|
||||
}
|
||||
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
};
|
||||
|
||||
export default AuthRoutesLayout;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { AuthLayout } from "@/components/auth/AuthLayout";
|
||||
import { AuthHeader } from "@/components/auth/AuthHeader";
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { AuthFooter } from "@/components/auth/AuthFooter";
|
||||
import { FormInput } from "@/components/auth/FormInput";
|
||||
import { getApiUrl } from "@/store/api/baseApi";
|
||||
import { AuthButton } from "@/components/auth/AuthButton";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.email("Enter a valid email"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const ForgotPasswordScreen = () => {
|
||||
const router = useRouter();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: "onSubmit",
|
||||
reValidateMode: "onChange",
|
||||
shouldUnregister: false,
|
||||
defaultValues: { email: "" },
|
||||
});
|
||||
const email = watch("email");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${getApiUrl()?.replace(/\/$/, "")}/auth/password/forgot`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: values.email }),
|
||||
}
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || "Failed to request reset");
|
||||
}
|
||||
|
||||
setMessage("If an account exists for this email, a reset link was sent");
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setError("Failed to request reset");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader title="Project Athena" />
|
||||
<AuthCard title="Forgot Password" subtitle="We'll email you a reset link">
|
||||
{!!error && (
|
||||
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
|
||||
)}
|
||||
{!!message && (
|
||||
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
|
||||
)}
|
||||
<FormInput
|
||||
label={errors.email?.message || "Email"}
|
||||
value={email}
|
||||
onChangeText={(t) => setValue("email", t, { shouldValidate: true })}
|
||||
icon="email"
|
||||
disabled={submitting}
|
||||
onSubmitEditing={handleSubmit(onSubmit)}
|
||||
returnKeyType="send"
|
||||
submitBehavior="blurAndSubmit"
|
||||
/>
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<AuthButton
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Sending..." : "Send Reset Link"}
|
||||
</AuthButton>
|
||||
</View>
|
||||
</AuthCard>
|
||||
<AuthFooter
|
||||
question="Remembered your password?"
|
||||
actionText="Sign in"
|
||||
onPress={() => router.push("/sign-in")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordScreen;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useRouter, useLocalSearchParams } from "expo-router";
|
||||
import { useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { AuthLayout } from "@/components/auth/AuthLayout";
|
||||
import { AuthHeader } from "@/components/auth/AuthHeader";
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { AuthFooter } from "@/components/auth/AuthFooter";
|
||||
import { FormInput } from "@/components/auth/FormInput";
|
||||
import { PasswordInput } from "@/components/auth/PasswordInput";
|
||||
import { AuthButton } from "@/components/auth/AuthButton";
|
||||
import { getApiUrl } from "@/store/api/baseApi";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((d) => d.password === d.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match",
|
||||
});
|
||||
|
||||
type FormVals = z.infer<typeof schema>;
|
||||
|
||||
const ResetPasswordScreen = () => {
|
||||
const router = useRouter();
|
||||
const { token: tokenParam } = useLocalSearchParams<{ token?: string }>();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [tokenOverride, setTokenOverride] = useState("");
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<FormVals>({
|
||||
resolver: zodResolver(schema),
|
||||
mode: "onSubmit",
|
||||
reValidateMode: "onChange",
|
||||
shouldUnregister: false,
|
||||
defaultValues: { password: "", confirmPassword: "" },
|
||||
});
|
||||
|
||||
const password = watch("password");
|
||||
const confirmPassword = watch("confirmPassword");
|
||||
const tokenFromLink =
|
||||
typeof tokenParam === "string" && tokenParam.length > 0 ? tokenParam : "";
|
||||
const token = tokenFromLink || tokenOverride;
|
||||
|
||||
const onSubmit = async (vals: FormVals) => {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
if (!token) {
|
||||
throw new Error("Reset link token is required");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${getApiUrl()?.replace(/\/$/, "")}/auth/password/reset`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, password: vals.password }),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || "Failed to reset password");
|
||||
}
|
||||
setMessage("Password has been reset. Redirecting to sign in...");
|
||||
setTimeout(() => router.replace("/sign-in"), 1500);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset password");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader title="Project Athena" />
|
||||
<AuthCard
|
||||
title="Reset Password"
|
||||
subtitle={
|
||||
tokenFromLink
|
||||
? "Set a new password"
|
||||
: "Paste the reset link token and set a new password"
|
||||
}
|
||||
>
|
||||
{!!error && (
|
||||
<Text style={{ color: "#F83434", marginBottom: 8 }}>{error}</Text>
|
||||
)}
|
||||
{!!message && (
|
||||
<Text style={{ color: "#22C927", marginBottom: 8 }}>{message}</Text>
|
||||
)}
|
||||
|
||||
{!tokenFromLink && (
|
||||
<FormInput
|
||||
label={"Reset token"}
|
||||
value={tokenOverride}
|
||||
onChangeText={setTokenOverride}
|
||||
icon="key"
|
||||
disabled={submitting}
|
||||
onSubmitEditing={handleSubmit(onSubmit)}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
label={errors.password?.message || "New Password"}
|
||||
value={password}
|
||||
onChangeText={(t) =>
|
||||
setValue("password", t, { shouldValidate: true })
|
||||
}
|
||||
disabled={submitting}
|
||||
onSubmitEditing={handleSubmit(onSubmit)}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={errors.confirmPassword?.message || "Confirm Password"}
|
||||
value={confirmPassword}
|
||||
onChangeText={(t) =>
|
||||
setValue("confirmPassword", t, { shouldValidate: true })
|
||||
}
|
||||
disabled={submitting}
|
||||
onSubmitEditing={handleSubmit(onSubmit)}
|
||||
returnKeyType="send"
|
||||
submitBehavior="blurAndSubmit"
|
||||
/>
|
||||
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<AuthButton
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Resetting password..." : "Reset Password"}
|
||||
</AuthButton>
|
||||
</View>
|
||||
</AuthCard>
|
||||
<AuthFooter
|
||||
question="Remembered your password?"
|
||||
actionText="Sign in"
|
||||
onPress={() => router.push("/sign-in")}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetPasswordScreen;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useRouter } from "expo-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
import { AuthLayout } from "@/components/auth/AuthLayout";
|
||||
import { AuthHeader } from "@/components/auth/AuthHeader";
|
||||
import { AuthCard } from "@/components/auth/AuthCard";
|
||||
import { SignInForm } from "@/components/auth/SignInForm";
|
||||
import { ForgotPasswordLink } from "@/components/auth/ForgotPasswordLink";
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
|
||||
const useWarmUpBrowser = () => {
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "web") {
|
||||
WebBrowser.warmUpAsync();
|
||||
return () => {
|
||||
WebBrowser.coolDownAsync();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const SignIn = () => {
|
||||
const router = useRouter();
|
||||
const { login, isAuthenticated, loading } = useAuthContext();
|
||||
|
||||
useWarmUpBrowser();
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const successMessage = "";
|
||||
const isResettingPassword = false;
|
||||
|
||||
const onForgotPasswordPress = async () => {
|
||||
router.push("/forgot-password");
|
||||
};
|
||||
|
||||
const onSignInSubmit = async (vals: {
|
||||
emailAddress: string;
|
||||
password: string;
|
||||
}) => {
|
||||
setErrorMessage("");
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await login(vals.emailAddress, vals.password);
|
||||
router.replace("/");
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || "Invalid credentials";
|
||||
setErrorMessage(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && isAuthenticated) {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, isAuthenticated, router]);
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthHeader title="Project Athena" />
|
||||
|
||||
<AuthCard title="Welcome Back" subtitle="Sign in to continue">
|
||||
<SignInForm
|
||||
errorMessage={errorMessage}
|
||||
successMessage={successMessage}
|
||||
isLoading={isLoading}
|
||||
onSignInSubmit={onSignInSubmit}
|
||||
/>
|
||||
|
||||
<ForgotPasswordLink
|
||||
onPress={onForgotPasswordPress}
|
||||
isResetting={isResettingPassword}
|
||||
disabled={isResettingPassword || isLoading}
|
||||
/>
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Redirect, Tabs, usePathname, router } from "expo-router";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useAuthContext } from "@/auth/AuthContext";
|
||||
import { ContentTabs } from "@/components/features/navigation/ContentTabs";
|
||||
import { Sidebar } from "@/components/features/navigation/Sidebar";
|
||||
import { SidebarToggle } from "@/components/features/navigation/SidebarToggle";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
import { HeaderControls } from "@/components/features/navigation/HeaderControls";
|
||||
import { LoadingSpinner } from "@/components/ui/LoadingSpinner";
|
||||
import { UserButton } from "@/components/auth/UserButton";
|
||||
|
||||
const TabsLayout = () => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
const { isAuthenticated, bootstrapping } = useAuthContext();
|
||||
const pathname = usePathname();
|
||||
const [mobileHidden, setMobileHidden] = useState(true);
|
||||
|
||||
if (bootstrapping) {
|
||||
return (
|
||||
<View className="flex-1 bg-primaryPurpleLight justify-center items-center">
|
||||
<LoadingSpinner />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
if (pathname !== "/sign-in") {
|
||||
return <Redirect href="/sign-in" />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1 flex-row">
|
||||
<View
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
mobileHidden={isMobile ? mobileHidden : false}
|
||||
onRequestMobileToggle={() => setMobileHidden((v) => !v)}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1 bg-primaryPurpleLight">
|
||||
<View className="relative">
|
||||
<View style={{ paddingLeft: isMobile && mobileHidden ? 80 : 0 }}>
|
||||
<ContentTabs />
|
||||
</View>
|
||||
|
||||
{isMobile && mobileHidden && (
|
||||
<View
|
||||
className="absolute z-50"
|
||||
style={{
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: 56,
|
||||
width: 80,
|
||||
backgroundColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
borderBottomWidth: 0,
|
||||
paddingLeft: 4,
|
||||
paddingRight: 4,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<SidebarToggle
|
||||
onPress={() => setMobileHidden(false)}
|
||||
showLabel={!mobileHidden}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<View className="absolute flex flex-row items-center justify-center z-50 right-4 top-2 space-x-3">
|
||||
<Pressable className="p-1" onPress={() => router.push("/search")}>
|
||||
<Ionicons name="search" size={20} color="white" />
|
||||
</Pressable>
|
||||
<UserButton />
|
||||
</View>
|
||||
) : (
|
||||
<HeaderControls />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: { display: "none" },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen name="index" />
|
||||
<Tabs.Screen name="accounts/index" />
|
||||
<Tabs.Screen name="logs/index" />
|
||||
<Tabs.Screen name="reports/index" />
|
||||
<Tabs.Screen name="data-protection/index" />
|
||||
<Tabs.Screen name="settings/index" />
|
||||
<Tabs.Screen name="search" />
|
||||
</Tabs>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default TabsLayout;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AdminDetailsPage } from "@/components/features/admins/AdminDetailsPage";
|
||||
|
||||
const AdminDetails = () => {
|
||||
return <AdminDetailsPage />;
|
||||
};
|
||||
|
||||
export default AdminDetails;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AdminsPage } from "@/components/features/admins/AdminsPage";
|
||||
import { useRoleAccess } from "@/hooks/useRoleAccess";
|
||||
import { AccessDenied } from "@/components/features/auth/AccessDenied";
|
||||
|
||||
const Admins = () => {
|
||||
const { isManager } = useRoleAccess();
|
||||
if (!isManager) return <AccessDenied />;
|
||||
return <AdminsPage />;
|
||||
};
|
||||
|
||||
export default Admins;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const Accounts = () => {
|
||||
return <Redirect href="/accounts/users" />;
|
||||
};
|
||||
|
||||
export default Accounts;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { TodosPage } from "@/components/features/todos/TodosPage";
|
||||
import { useRoleAccess } from "@/hooks/useRoleAccess";
|
||||
import { AccessDenied } from "@/components/features/auth/AccessDenied";
|
||||
|
||||
const Todos = () => {
|
||||
const { isManager } = useRoleAccess();
|
||||
if (!isManager) return <AccessDenied />;
|
||||
return <TodosPage />;
|
||||
};
|
||||
|
||||
export default Todos;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UserDetailsPage } from "@/components/features/users/UserDetailsPage";
|
||||
|
||||
const UserDetails = () => {
|
||||
return <UserDetailsPage />;
|
||||
};
|
||||
|
||||
export default UserDetails;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UsersPage } from "@/components/features/users/UsersPage";
|
||||
|
||||
const Users = () => {
|
||||
return <UsersPage />;
|
||||
};
|
||||
|
||||
export default Users;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const Dashboard = () => {
|
||||
return <Redirect href="/dashboard/user-dashboard" />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,7 @@
|
||||
import UserDashboardPage from "@/components/features/dashboard/UserDashboardPage";
|
||||
|
||||
const UserDashboard = () => {
|
||||
return <UserDashboardPage />;
|
||||
};
|
||||
|
||||
export default UserDashboard;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
const General = () => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 p-1 ${isMobile ? "p-1" : "p-5"} bg-primaryPurpleLight`}
|
||||
>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
General Security
|
||||
</Text>
|
||||
<Text className="text-base text-gray-600">
|
||||
This is the General Security tab content. It would typically show
|
||||
application errors, exceptions, and critical issues that need attention.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default General;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const DataProtection = () => {
|
||||
return <Redirect href="/data-protection/general" />;
|
||||
};
|
||||
|
||||
export default DataProtection;
|
||||
@@ -0,0 +1,3 @@
|
||||
import Dashboard from "./dashboard";
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const Logs = () => {
|
||||
return <Redirect href="/logs/system" />;
|
||||
};
|
||||
|
||||
export default Logs;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { LogsPage } from "@/components/features/logs/LogsPage";
|
||||
|
||||
const SystemLogs = () => {
|
||||
return <LogsPage />;
|
||||
};
|
||||
|
||||
export default SystemLogs;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
const Inbox = () => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 p-1 ${isMobile ? "p-1" : "p-5"} bg-primaryPurpleLight`}
|
||||
>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
Inbox
|
||||
</Text>
|
||||
<Text className="text-base text-gray-600">
|
||||
This is the Inbox tab content. It would typically show application
|
||||
errors, exceptions, and critical issues that need attention.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default Inbox;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const Reports = () => {
|
||||
return <Redirect href="/reports/inbox" />;
|
||||
};
|
||||
|
||||
export default Reports;
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { View, Text, FlatList } from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { ActivityIndicator } from "react-native-paper";
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
import { SearchResult, useGlobalSearchQuery } from "@/store/api/searchApi";
|
||||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { SizeEnum } from "@/constants/sizeEnum";
|
||||
import { ResultRow } from "@/components/features/search/ResultRow";
|
||||
import { normalizeEnumLike } from "@/utils/formattingUtils";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const SearchPage = () => {
|
||||
const params = useLocalSearchParams<{ q?: string }>();
|
||||
const [q, setQ] = useState(params.q ?? "");
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
useEffect(() => {
|
||||
setQ(params.q ?? "");
|
||||
}, [params.q]);
|
||||
|
||||
const debouncedQ = useDebounce(q, 300);
|
||||
|
||||
const [limit] = useState(100);
|
||||
const { data, isFetching, isLoading, isError } = useGlobalSearchQuery(
|
||||
{ q: debouncedQ, page: 1, limit },
|
||||
{
|
||||
skip: !debouncedQ || debouncedQ.trim().length < 2,
|
||||
refetchOnMountOrArgChange: true,
|
||||
refetchOnFocus: true,
|
||||
}
|
||||
);
|
||||
|
||||
const results = useMemo(() => data?.results ?? [], [data?.results]);
|
||||
|
||||
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>({});
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedIds((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}, []);
|
||||
const isExpanded = useCallback(
|
||||
(id: string) => !!expandedIds[id],
|
||||
[expandedIds]
|
||||
);
|
||||
|
||||
// Reset expansions when new query results arrive
|
||||
useEffect(() => {
|
||||
if (data?.results) setExpandedIds({});
|
||||
}, [debouncedQ, data?.results]);
|
||||
|
||||
// Update route param only after debounce
|
||||
useEffect(() => {
|
||||
router.setParams({ q: debouncedQ || "" });
|
||||
}, [debouncedQ]);
|
||||
|
||||
// Preprocess heavy formatting outside render
|
||||
const formattedResults = useMemo(
|
||||
() =>
|
||||
results.map((item) => ({
|
||||
...item,
|
||||
subtitle: item.subtitle ? normalizeEnumLike(item.subtitle) : null,
|
||||
description: item.description
|
||||
? normalizeEnumLike(item.description)
|
||||
: null,
|
||||
createdAtFormatted: item.createdAt
|
||||
? new Date(item.createdAt).toLocaleString()
|
||||
: null,
|
||||
})),
|
||||
[results]
|
||||
);
|
||||
|
||||
const renderResult = useCallback(
|
||||
({ item }: { item: SearchResult }) => (
|
||||
<ResultRow
|
||||
item={item}
|
||||
expanded={isExpanded(item.id)}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
),
|
||||
[isExpanded, toggleExpanded]
|
||||
);
|
||||
|
||||
return (
|
||||
<View className="flex-1 p-4">
|
||||
<View>
|
||||
{isMobile && (
|
||||
<Button
|
||||
onPress={() => router.push("/dashboard/user-dashboard")}
|
||||
disabled={isFetching}
|
||||
className="mb-2"
|
||||
>
|
||||
Go to User Dashboard
|
||||
</Button>
|
||||
)}
|
||||
<SearchBar
|
||||
searchQuery={q}
|
||||
onChange={setQ}
|
||||
placeholderText="Search"
|
||||
size={SizeEnum.Regular}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!q || q.trim().length < 2 ? (
|
||||
<View className="mt-6">
|
||||
<Text className="text-gray-600">Start typing to search.</Text>
|
||||
</View>
|
||||
) : isLoading && results.length === 0 ? (
|
||||
<View className="mt-6">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
) : isError && results.length === 0 ? (
|
||||
<View className="mt-6">
|
||||
<Text className="text-red-500">Search failed. Please try again.</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View className="flex-1">
|
||||
<Text className="text-gray-700 mb-2 mt-2">
|
||||
Results: {results.length}
|
||||
</Text>
|
||||
<FlatList
|
||||
data={formattedResults as SearchResult[]}
|
||||
keyExtractor={(item) => `${item.type}-${item.id}`}
|
||||
renderItem={renderResult}
|
||||
contentContainerStyle={{ paddingBottom: 16 }}
|
||||
ItemSeparatorComponent={() => <View style={{ height: 12 }} />}
|
||||
/>
|
||||
{isFetching && (
|
||||
<View className="my-3">
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { useAppSelector } from "@/store/hooks";
|
||||
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
|
||||
|
||||
const GeneralSettings = () => {
|
||||
const isMobile = useAppSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 p-1 ${isMobile ? "p-1" : "p-5"} bg-primaryPurpleLight`}
|
||||
>
|
||||
<Text
|
||||
className={`text-2xl font-bold ${
|
||||
isMobile ? "mb-1" : "mb-4"
|
||||
} text-white`}
|
||||
>
|
||||
General Settings
|
||||
</Text>
|
||||
<Text className="text-base text-gray-600">
|
||||
This is the General Settings tab content. It would typically show
|
||||
application preferences, language settings, and other general
|
||||
configuration options.
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettings;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Redirect } from "expo-router";
|
||||
|
||||
const Settings = () => {
|
||||
return <Redirect href="/settings/general" />;
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
|
||||
import { Provider as ReduxProvider } from "react-redux";
|
||||
import { Provider as PaperProvider, MD3LightTheme } from "react-native-paper";
|
||||
|
||||
import { AuthProvider } from "@/auth/AuthContext";
|
||||
import { ScreenSizeListenerWrapper } from "@/hooks/useScreenSizeListener";
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { store } from "store";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
const theme = {
|
||||
...MD3LightTheme,
|
||||
colors: {
|
||||
...MD3LightTheme.colors,
|
||||
},
|
||||
};
|
||||
|
||||
const RootLayout = () => {
|
||||
useDocumentTitle();
|
||||
|
||||
return (
|
||||
<ReduxProvider store={store}>
|
||||
<AuthProvider>
|
||||
<PaperProvider theme={theme}>
|
||||
<SafeAreaProvider>
|
||||
<ScreenSizeListenerWrapper />
|
||||
<SafeAreaView className="flex-1">
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</SafeAreaView>
|
||||
</SafeAreaProvider>
|
||||
</PaperProvider>
|
||||
</AuthProvider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Reference in New Issue
Block a user