project setup

This commit is contained in:
Sone
2025-11-02 10:08:56 +01:00
commit 568838003e
174 changed files with 27100 additions and 0 deletions
@@ -0,0 +1,178 @@
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import { Pressable, Text, View } from "react-native";
import { useAppSelector } from "@/store/hooks";
import { selectIsMobile } from "@/store/screen/screenSizeSlice";
import { IoniconsName } from "@/types/index";
import { useRoleAccess } from "@/hooks/useRoleAccess";
const HIDDEN_PATHS = ["/accounts/admins", "/accounts/todos"];
interface TabItem {
label: string;
route: string;
icon?: IoniconsName;
}
interface TabGroup {
basePath: string;
tabs: TabItem[];
}
export const ContentTabs = () => {
const pathname = usePathname();
const isMobile = useAppSelector(selectIsMobile);
const { isManager } = useRoleAccess();
// Hide top tabs when user lacks permission and is on restricted Accounts pages
if (!isManager && HIDDEN_PATHS.includes(pathname)) {
return null;
}
const tabGroups: TabGroup[] = [
{
basePath: "/",
tabs: [
{
label: "Dashboard",
route: "/user-dashboard/index",
icon: "home",
},
],
},
{
basePath: "/accounts",
tabs: [
{
label: "Users",
route: "/accounts/users",
icon: "person-outline",
},
...(isManager
? [
{
label: "Admins",
route: "/accounts/admins",
icon: "settings" as IoniconsName,
},
{
label: "Todos",
route: "/accounts/todos",
icon: "list" as IoniconsName,
},
]
: []),
],
},
{
basePath: "/logs",
tabs: [{ label: "System", route: "/logs/system", icon: "server" }],
},
{
basePath: "/reports",
tabs: [{ label: "Inbox", route: "/reports/inbox", icon: "mail" }],
},
{
basePath: "/data-protection",
tabs: [
{ label: "General", route: "/data-protection/general", icon: "cog" },
],
},
{
basePath: "/settings",
tabs: [{ label: "General", route: "/settings/general", icon: "cog" }],
},
];
let currentSection = "";
if (
pathname.includes("/dashboard") ||
pathname === "/(tabs)" ||
pathname === "/"
) {
currentSection = "/";
} else if (pathname.includes("/accounts")) {
currentSection = "/accounts";
} else if (pathname.includes("/settings")) {
currentSection = "/settings";
} else if (pathname.includes("/logs")) {
currentSection = "/logs";
} else if (pathname.includes("/reports")) {
currentSection = "/reports";
} else if (pathname.includes("/data-protection")) {
currentSection = "/data-protection";
}
const activeGroup = tabGroups.find(
(group) => group.basePath === currentSection
);
if (!activeGroup || activeGroup.tabs.length === 0) {
return null;
}
const isTabActive = (tabRoute: string) => {
if (tabRoute === "/user-dashboard/index") {
if (pathname === "/(tabs)" || pathname === "/") {
return true;
}
return pathname.includes("/user-dashboard");
}
const routeSegments = tabRoute.split("/");
const lastSegment = routeSegments[routeSegments.length - 1];
return pathname.includes(lastSegment);
};
return (
<View
className={`bg-primaryPurpleLight ${isMobile ? "px-1" : "px-2"} ${
isMobile ? "pt-2 pb-0" : "pt-3"
}`}
>
<View className={`flex-row ${isMobile ? "justify-start gap-3" : ""}`}>
{activeGroup.tabs.map((tab, index) => {
const active = isTabActive(tab.route);
return (
<Pressable
key={index}
onPress={() => router.push(tab.route as any)}
className={`relative mx-1 border-b-2 transition-colors duration-200
${active ? "border-white" : "border-transparent"}
${isMobile ? "px-1" : "px-4"}
`}
style={{ paddingBottom: isMobile ? 6 : 4 }}
>
{isMobile ? (
<View className="items-center">
<Ionicons
name={tab.icon as IoniconsName}
size={20}
color="white"
style={{ opacity: active ? 1 : 0.7 }}
/>
<Text
className="text-white text-[10px] mt-0.5"
style={{ opacity: active ? 1 : 0.7 }}
>
{tab.label}
</Text>
</View>
) : (
<Text
className={`text-base text-white transition-colors duration-200
${active ? "font-medium" : "font-normal"}
`}
>
{tab.label}
</Text>
)}
</Pressable>
);
})}
</View>
</View>
);
};
@@ -0,0 +1,112 @@
import { View, Animated } from "react-native";
import { useState, useMemo, useEffect, useRef } from "react";
import { debounce } from "lodash";
import { router, useLocalSearchParams, usePathname } from "expo-router";
import { SearchBar } from "@/components/ui/SearchBar";
import { SearchDropdown } from "@/components/ui/SearchDropdown";
import { SizeEnum } from "@/constants/sizeEnum";
import { UserButton } from "@/components/auth/UserButton";
import { SearchResult } from "@/store/api/searchApi";
export const HeaderControls = () => {
const [searchQuery, setSearchQuery] = useState("");
const params = useLocalSearchParams<{ q?: string }>();
const pathname = usePathname();
const searchWidth = useRef(new Animated.Value(250)).current;
const isOnSearchPage =
typeof pathname === "string" && pathname.includes("/search");
const debouncedSearch = useMemo(
() =>
debounce((query: string) => {
if (!query.trim()) return;
}, 300),
[]
);
useEffect(() => {
debouncedSearch(searchQuery);
// Animate width based on whether user is typing
// Use spring animation for smoother feel
Animated.spring(searchWidth, {
toValue: searchQuery.trim() ? 500 : 250,
useNativeDriver: false,
tension: 100,
friction: 10,
}).start();
return debouncedSearch.cancel;
}, [searchQuery, debouncedSearch, searchWidth]);
useEffect(() => {
if (!isOnSearchPage) return;
const q = (params.q ?? "").toString();
setSearchQuery(q);
}, [isOnSearchPage, params.q]);
useEffect(() => {
if (!isOnSearchPage) return;
const current = (params.q ?? "").toString();
if (searchQuery.trim() !== "" && searchQuery !== current) {
router.setParams({ q: searchQuery });
}
}, [isOnSearchPage, searchQuery, params.q]);
const handleResultSelect = (result: SearchResult) => {
const q = result.title || "";
const encodedQ = encodeURIComponent(q);
// Immediately clear search and collapse animation to prevent lag
setSearchQuery("");
Animated.timing(searchWidth, {
toValue: 250,
duration: 200,
useNativeDriver: false,
}).start();
// Navigate after starting animation
switch (result.type) {
case "admin":
router.push(`/(tabs)/accounts/admins?q=${encodedQ}`);
break;
case "user":
router.push(`/(tabs)/accounts/users?q=${encodedQ}`);
break;
case "todo":
router.push(`/(tabs)/accounts/todos?q=${encodedQ}`);
break;
default: {
router.push(`/(tabs)/search?q=${encodedQ}`);
break;
}
}
};
const handleViewAllResults = (query: string) => {
router.push(`/(tabs)/search?q=${encodeURIComponent(query)}`);
};
return (
<View className="flex-row items-center justify-end px-4 absolute right-0 top-[6px] h-8">
<Animated.View
className="relative"
style={{ zIndex: 1000, width: searchWidth }}
id="search-container"
>
<SearchBar
searchQuery={searchQuery}
onChange={setSearchQuery}
placeholderText="Search"
size={SizeEnum.Small}
/>
<SearchDropdown
searchQuery={searchQuery}
onResultSelect={handleResultSelect}
onViewAllResults={handleViewAllResults}
/>
</Animated.View>
<UserButton />
</View>
);
};
+307
View File
@@ -0,0 +1,307 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { usePathname } from "expo-router";
import { useEffect, useRef, useState } from "react";
import { Animated, Platform, View } from "react-native";
import { useAppSelector, useAppDispatch } from "@/store/hooks";
import {
selectIsMobile,
setSidebarCollapsed,
} from "@/store/screen/screenSizeSlice";
import { SignOutButton } from "@/components/auth/SignOutButton";
import { useAuthContext } from "@/auth/AuthContext";
import { SidebarToggle } from "@/components/features/navigation/SidebarToggle";
import { SidebarItem } from "@/components/features/navigation/SidebarItem";
const SIDEBAR_STATE_KEY = "sidebarCollapsedState";
const saveCollapsedState = async (isCollapsed: boolean) => {
try {
if (Platform.OS === "web") {
localStorage.setItem(SIDEBAR_STATE_KEY, JSON.stringify(isCollapsed));
} else {
await AsyncStorage.setItem(
SIDEBAR_STATE_KEY,
JSON.stringify(isCollapsed)
);
}
} catch (error) {
console.error("Error saving sidebar state:", error);
}
};
const loadCollapsedState = async (): Promise<boolean | null> => {
try {
let value;
if (Platform.OS === "web") {
value = localStorage.getItem(SIDEBAR_STATE_KEY);
} else {
value = await AsyncStorage.getItem(SIDEBAR_STATE_KEY);
}
return value ? JSON.parse(value) : null;
} catch (error) {
console.error("Error loading sidebar state:", error);
return null;
}
};
export const Sidebar = ({
mobileHidden = false,
onRequestMobileToggle,
}: {
mobileHidden?: boolean;
onRequestMobileToggle?: () => void;
}) => {
const { isAuthenticated } = useAuthContext();
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
const [labelsVisible, setLabelsVisible] = useState(true);
const animatedWidth = useRef(new Animated.Value(200)).current;
const textOpacity = useRef(new Animated.Value(1)).current;
const iconTranslateX = useRef(new Animated.Value(0)).current;
const paddingLeft = useRef(new Animated.Value(16)).current; // 0.5rem = 8px
const isMobile = useAppSelector(selectIsMobile);
const dispatch = useAppDispatch();
useEffect(() => {
const loadSavedState = async () => {
if (isMobile) {
setCollapsed(true);
setLabelsVisible(false);
textOpacity.setValue(0);
iconTranslateX.setValue(12);
paddingLeft.setValue(8);
Animated.timing(animatedWidth, {
toValue: mobileHidden ? 0 : 80,
duration: 250,
useNativeDriver: false,
}).start();
return;
}
const savedState = await loadCollapsedState();
if (savedState !== null) {
setCollapsed(savedState);
dispatch(setSidebarCollapsed(savedState));
setLabelsVisible(!savedState);
textOpacity.setValue(savedState ? 0 : 1);
iconTranslateX.setValue(savedState ? 12 : 0);
paddingLeft.setValue(8);
Animated.timing(animatedWidth, {
toValue: savedState ? 80 : 200,
duration: 300,
useNativeDriver: false,
}).start();
}
};
loadSavedState();
}, [
animatedWidth,
textOpacity,
iconTranslateX,
paddingLeft,
isMobile,
mobileHidden,
dispatch,
]);
const isActive = (path: string) => {
if (path === "/user-dashboard" || path === "/dashboard") {
return (
pathname.includes("/user-dashboard") ||
pathname === "/(tabs)" ||
pathname === "/"
);
}
if (path === "/accounts") {
return pathname.includes("/accounts");
}
if (path === "/settings") {
return (
pathname.startsWith("/settings") ||
pathname.startsWith("/(tabs)/settings")
);
}
if (path === "/data-protection") {
return (
pathname.startsWith("/data-protection") ||
pathname.startsWith("/(tabs)/data-protection")
);
}
return pathname.includes(path);
};
const toggleSidebar = () => {
if (isMobile) {
onRequestMobileToggle?.();
return;
}
const newCollapsedState = !collapsed;
if (!newCollapsedState) {
setLabelsVisible(true);
}
Animated.parallel([
Animated.timing(animatedWidth, {
toValue: newCollapsedState ? 80 : 200,
duration: 300,
useNativeDriver: false,
}),
Animated.timing(textOpacity, {
toValue: newCollapsedState ? 0 : 1,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(iconTranslateX, {
toValue: newCollapsedState ? 12 : 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(paddingLeft, {
toValue: 8,
duration: 300,
useNativeDriver: false,
}),
]).start(() => {
setLabelsVisible(!newCollapsedState);
});
if (newCollapsedState) {
setLabelsVisible(true);
}
setCollapsed(newCollapsedState);
dispatch(setSidebarCollapsed(newCollapsedState));
saveCollapsedState(newCollapsedState);
};
return (
<Animated.View
className="border-r px-2"
style={{
width: animatedWidth,
backgroundColor: "#49385D",
borderRightColor: "#49385D",
borderRightWidth: undefined,
paddingHorizontal: undefined,
flex: isMobile ? undefined : 1,
height: "100%",
overflow: "hidden",
}}
>
<View className="flex-1 flex-col">
{!(isMobile && mobileHidden) && (
<View
className="border-b-2 border-white"
style={{
paddingBottom: isMobile ? 6 : 0,
paddingTop: isMobile ? 6 : 0,
}}
>
<SidebarToggle
onPress={toggleSidebar}
textOpacity={textOpacity}
iconTranslateX={iconTranslateX}
showLabel={labelsVisible}
className="h-[40px] w-full justify-start"
/>
</View>
)}
{!(isMobile && mobileHidden) && (
<View>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/dashboard/user-dashboard"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/dashboard/user-dashboard"
icon="home-outline"
label="Home"
isFirst
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/accounts/users"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/accounts"
icon="person-outline"
label="Accounts"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/logs/system"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/logs"
icon="checkbox-outline"
label="Logs"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/reports/inbox"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/reports"
icon="copy-outline"
label="Reports"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/data-protection/general"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/data-protection"
icon="lock-closed-outline"
label="Data Protection"
/>
<SidebarItem
paddingLeft={paddingLeft}
routerPushValue={"/(tabs)/settings/general"}
isMobile={isMobile}
isActive={isActive}
iconTranslateX={iconTranslateX}
textOpacity={textOpacity}
showLabel={labelsVisible}
path="/settings"
icon="settings-outline"
label="Settings"
/>
</View>
)}
{!(isMobile && mobileHidden) && (
<View className="mb-4 mt-auto px-2">
{isAuthenticated && (
<SignOutButton
textOpacity={textOpacity}
iconTranslateX={iconTranslateX}
paddingLeft={paddingLeft}
/>
)}
</View>
)}
</View>
</Animated.View>
);
};
@@ -0,0 +1,81 @@
import { Animated, Pressable, Text } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { IoniconsName } from "@/types/index";
interface SidebarItemProps {
paddingLeft: Animated.Value;
routerPushValue: any;
isMobile: boolean;
isActive: (path: string) => boolean;
iconTranslateX: Animated.Value;
textOpacity: Animated.Value;
path: string;
icon: IoniconsName;
label: string;
isFirst?: boolean;
showLabel?: boolean;
}
export const SidebarItem = ({
paddingLeft,
routerPushValue,
isMobile,
isActive,
iconTranslateX,
textOpacity,
path,
icon,
label,
isFirst,
showLabel = true,
}: SidebarItemProps) => {
return (
<Animated.View
style={{
paddingLeft,
paddingRight: 8,
}}
>
<Pressable
onPress={() => router.push(routerPushValue)}
className={`flex-row items-center py-3 rounded-lg w-full ${
isFirst && !isMobile ? "mt-4" : isMobile ? "mt-1" : ""
} ${showLabel ? "justify-start" : "justify-center"}`}
style={{
backgroundColor: isActive(path) ? "#5A4570" : "transparent",
}}
>
<Animated.View
style={{
alignItems: "center",
...(showLabel
? { paddingLeft: 8, transform: [{ translateX: iconTranslateX }] }
: {}),
}}
>
<Ionicons name={icon as IoniconsName} size={24} color="white" />
</Animated.View>
{showLabel && (
<Animated.View
style={{
opacity: textOpacity,
marginLeft: 12,
overflow: "hidden",
}}
>
<Text
className={`${
isActive(path) ? "text-white font-medium" : "text-gray-300"
}`}
numberOfLines={1}
>
{label}
</Text>
</Animated.View>
)}
</Pressable>
</Animated.View>
);
};
@@ -0,0 +1,60 @@
import { Pressable, Text, Animated } from "react-native";
import { Ionicons } from "@expo/vector-icons";
interface SidebarToggleProps {
textOpacity?: Animated.Value;
iconTranslateX?: Animated.Value;
onPress: () => void;
showLabel?: boolean;
size?: number;
color?: string;
className?: string;
}
export const SidebarToggle = ({
textOpacity,
iconTranslateX,
onPress,
showLabel = false,
size = 24,
color = "white",
className = "",
}: SidebarToggleProps) => {
return (
<Animated.View
style={{
paddingRight: showLabel ? 4 : 8,
paddingLeft: showLabel ? 16 : 8,
}}
>
<Pressable
onPress={onPress}
className={`flex-row items-center rounded-lg ${className}`}
>
<Animated.View
style={{
alignItems: "center",
justifyContent: "center",
...(showLabel && iconTranslateX !== undefined
? { transform: [{ translateX: iconTranslateX }] }
: { width: "100%" }),
}}
>
<Ionicons name="menu" size={size} color={color} />
</Animated.View>
{showLabel && (
<Animated.View
style={{
...(textOpacity !== undefined ? { opacity: textOpacity } : {}),
overflow: "hidden",
}}
>
<Text className="text-white text-lg ml-2" numberOfLines={1}>
Options
</Text>
</Animated.View>
)}
</Pressable>
</Animated.View>
);
};