Add:"
- app/(application)/admin/

## NO Issue
This commit is contained in:
2025-08-05 17:40:46 +08:00
parent 48c34aa26c
commit c4c16f19c1
17 changed files with 857 additions and 73 deletions

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useRef } from "react";
import {
Animated,
Dimensions,
InteractionManager,
PanResponder,
StyleSheet
} from "react-native";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { SafeAreaView } from "react-native-safe-area-context";
// Lebar drawer (bisa di-pass sebagai prop)
const DRAWER_WIDTH = Dimensions.get("window").width * 0.8; // 80% lebar layar
interface DrawerAdminProps {
children?: React.ReactNode;
width?: number; // lebar drawer
isVisible: boolean;
onClose: () => void; // ganti nama dari closeDrawer agar lebih jelas
}
export default function DrawerAdmin({
children,
width = DRAWER_WIDTH,
isVisible,
onClose,
}: DrawerAdminProps) {
const drawerAnim = useRef(new Animated.Value(-width)).current; // mulai dari kiri (tersembunyi)
// Efek untuk handle animasi saat isVisible berubah
useEffect(() => {
if (isVisible) {
Animated.timing(drawerAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}).start();
} else {
Animated.timing(drawerAnim, {
toValue: -width,
duration: 300,
useNativeDriver: true,
}).start();
}
}, [isVisible, width, onClose, drawerAnim]);
const panResponder = useRef(
PanResponder.create({
onMoveShouldSetPanResponder: (_, gestureState) => {
return Math.abs(gestureState.dx) > 10; // deteksi gesek horizontal
},
onPanResponderMove: (_, gestureState) => {
let newAnim = gestureState.dx; // geser ke kanan = dx positif → drawerAnim negatif
newAnim = Math.max(-width, Math.min(0, newAnim)); // batas antara -width dan 0
drawerAnim.setValue(newAnim);
},
onPanResponderRelease: (_, gestureState) => {
if (gestureState.dx > 100) {
// gesek kencang ke kiri → tutup
InteractionManager.runAfterInteractions(() => {
onClose();
});
} else {
// kembali ke posisi terbuka penuh
Animated.spring(drawerAnim, {
toValue: 0,
useNativeDriver: true,
}).start();
}
},
})
).current;
if (!isVisible) return null;
return (
<>
{/* Overlay Gelap */}
<Animated.View
style={[
styles.overlay,
{
opacity: drawerAnim.interpolate({
inputRange: [-width, 0],
outputRange: [0, 0.6],
extrapolate: "clamp",
}),
},
]}
onTouchStart={() => {
InteractionManager.runAfterInteractions(() => {
onClose();
});
}}
/>
{/* Left Drawer */}
<Animated.View
style={[
styles.drawer,
{
width,
transform: [{ translateX: drawerAnim }],
},
]}
{...panResponder.panHandlers}
>
{/* Handle Bar (opsional) */}
{/* <View style={styles.handleBar} /> */}
<SafeAreaView>{children}</SafeAreaView>
</Animated.View>
</>
);
}
const styles = StyleSheet.create({
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "black",
zIndex: 998,
},
drawer: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
backgroundColor: AccentColor.darkblue,
// borderRadius: 20, // opsional
shadowColor: "#000",
shadowOffset: { width: 2, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 5,
elevation: 5,
zIndex: 999,
padding: 20,
},
handleBar: {
width: 10,
height: 5,
backgroundColor: MainColor.yellow,
borderRadius: 5,
alignSelf: "flex-start",
marginBottom: 20,
},
});

View File

@@ -0,0 +1,251 @@
import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
import React, { useRef, useState } from "react";
import {
Animated,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import TextCustom from "../Text/TextCustom";
interface NavbarItem {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
}[];
initiallyOpened?: boolean;
}
interface NavbarMenuProps {
items: NavbarItem[];
onClose?: () => void;
}
export default function NavbarMenu({ items, onClose }: NavbarMenuProps) {
const pathname = usePathname();
const [activeLink, setActiveLink] = useState<string | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]); // Untuk kontrol dropdown
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label) ? prev.filter((key) => key !== label) : [label]
);
};
return (
<View style={styles.container}>
{items.map((item) => (
<MenuItem
key={item.label}
item={item}
onClose={onClose}
activeLink={activeLink}
setActiveLink={setActiveLink}
isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)}
/>
))}
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
onClose,
activeLink,
setActiveLink,
isOpen,
toggleOpen,
}: {
item: NavbarItem;
onClose?: () => void;
activeLink: string | null;
setActiveLink: (link: string | null) => void;
isOpen: boolean;
toggleOpen: () => void;
}) {
const isActive = activeLink === item.link;
const animatedHeight = useRef(new Animated.Value(0)).current;
// Animasi saat isOpen berubah
React.useEffect(() => {
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 40 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links]);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity style={styles.parentItem} onPress={toggleOpen}>
<Ionicons
name={item.icon}
size={20}
color={MainColor.white}
style={styles.icon}
/>
<Text style={styles.parentText}>{item.label}</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={MainColor.white}
/>
</TouchableOpacity>
{/* Submenu (Animated) */}
<Animated.View
style={[
styles.submenu,
// {
// backgroundColor: "red",
// },
{
height: animatedHeight,
opacity: animatedHeight.interpolate({
inputRange: [0, item.links.length * 40],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
]}
>
{item.links.map((subItem, index) => {
const isSubActive = activeLink === subItem.link;
return (
<TouchableOpacity
key={index}
style={[styles.subItem, isSubActive && styles.subItemActive]}
onPress={() => {
setActiveLink(subItem.link);
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="caret-forward-sharp"
size={16}
color={isSubActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
isSubActive && { color: MainColor.yellow },
]}
>
{subItem.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</View>
);
}
// Menu tanpa submenu
return (
<TouchableOpacity
style={[styles.singleItem, isActive && styles.singleItemActive]}
onPress={() => {
setActiveLink(item.link || null);
onClose?.();
router.push(item.link as any);
}}
>
<Ionicons
name={item.icon}
size={20}
color={isActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.singleText,
{ color: isActive ? MainColor.yellow : MainColor.white },
]}
>
{item.label}
</Text>
</TouchableOpacity>
);
}
// Styles
const styles = StyleSheet.create({
container: {
marginTop: 20,
},
parentItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
justifyContent: "space-between",
},
parentText: {
flex: 1,
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
singleItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
},
singleItemActive: {
backgroundColor: AccentColor.blue,
},
singleText: {
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
icon: {
width: 24,
textAlign: "center",
paddingRight: 10,
},
submenu: {
overflow: "hidden",
marginLeft: 30,
marginTop: 5,
},
subItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 6,
marginBottom: 4,
},
subItemActive: {
backgroundColor: AccentColor.blue,
},
subText: {
color: MainColor.white,
fontSize: 14,
},
});

View File

@@ -0,0 +1,279 @@
import React, { useRef, useState, useEffect } from "react";
import {
Animated,
Dimensions,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { AccentColor, MainColor } from "@/constants/color-palet";
import { Ionicons } from "@expo/vector-icons";
import { router, usePathname } from "expo-router";
// Lebar sidebar
const SIDEBAR_WIDTH = Dimensions.get("window").width * 0.8;
interface SidebarItem {
label: string;
icon?: keyof typeof Ionicons.glyphMap;
color?: string;
link?: string;
links?: {
label: string;
link: string;
}[];
initiallyOpened?: boolean;
}
interface SidebarMenuProps {
items: SidebarItem[];
onClose?: () => void;
}
export default function SidebarMenu({ items, onClose }: SidebarMenuProps) {
const pathname = usePathname();
const [activeLink, setActiveLink] = useState<string | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]); // Untuk kontrol dropdown
// Normalisasi path: hapus trailing slash
const normalizePath = (path: string) => path.replace(/\/+$/, "");
const normalizedPathname = pathname ? normalizePath(pathname) : "";
// Set activeLink saat pathname berubah
useEffect(() => {
if (normalizedPathname) {
setActiveLink(normalizedPathname);
}
}, [normalizedPathname]);
// Auto-buka dropdown jika submenu aktif
useEffect(() => {
const activeItem = items.find((item) =>
item.links?.some((sub) => sub.link === normalizedPathname)
);
if (activeItem && !openKeys.includes(activeItem.label)) {
setOpenKeys([activeItem.label]);
}
}, [normalizedPathname, items, openKeys]);
// Toggle dropdown
const toggleOpen = (label: string) => {
setOpenKeys((prev) =>
prev.includes(label) ? prev.filter((key) => key !== label) : [label]
);
};
return (
<View style={styles.container}>
{items.map((item) => (
<MenuItem
key={item.label}
item={item}
onClose={onClose}
activeLink={activeLink}
setActiveLink={setActiveLink}
isOpen={openKeys.includes(item.label)}
toggleOpen={() => toggleOpen(item.label)}
/>
))}
</View>
);
}
// Komponen Item Menu
function MenuItem({
item,
onClose,
activeLink,
setActiveLink,
isOpen,
toggleOpen,
}: {
item: SidebarItem;
onClose?: () => void;
activeLink: string | null;
setActiveLink: (link: string | null) => void;
isOpen: boolean;
toggleOpen: () => void;
}) {
const animatedHeight = useRef(new Animated.Value(0)).current;
// Animasi saat isOpen berubah
React.useEffect(() => {
Animated.timing(animatedHeight, {
toValue: isOpen ? (item.links ? item.links.length * 40 : 0) : 0,
duration: 200,
useNativeDriver: false,
}).start();
}, [isOpen, item.links]);
// Cek apakah menu ini aktif
const isActive = activeLink === item.link;
// Cek apakah ada submenu aktif
const hasActiveSubItem = item.links?.some((sub) => sub.link === activeLink);
// Jika ada submenu
if (item.links && item.links.length > 0) {
return (
<View>
{/* Parent Item */}
<TouchableOpacity style={styles.parentItem} onPress={toggleOpen}>
<Ionicons
name={item.icon}
size={20}
color={MainColor.white}
style={styles.icon}
/>
<Text style={styles.parentText}>{item.label}</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={16}
color={MainColor.white}
/>
</TouchableOpacity>
{/* Submenu (Animated) */}
<Animated.View
style={[
styles.submenu,
{
height: animatedHeight,
opacity: animatedHeight.interpolate({
inputRange: [0, item.links.length * 40],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
]}
>
{item.links.map((subItem, index) => {
const isSubActive = activeLink === subItem.link;
return (
<TouchableOpacity
key={index}
style={[styles.subItem, isSubActive && styles.subItemActive]}
onPress={() => {
setActiveLink(subItem.link);
onClose?.();
router.push(subItem.link as any);
}}
>
<Ionicons
name="caret-forward-sharp"
size={16}
color={isSubActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.subText,
isSubActive && { color: MainColor.yellow },
]}
>
{subItem.label}
</Text>
</TouchableOpacity>
);
})}
</Animated.View>
</View>
);
}
// Menu tanpa submenu
return (
<TouchableOpacity
style={[styles.singleItem, isActive && styles.singleItemActive]}
onPress={() => {
setActiveLink(item.link || null);
onClose?.();
router.push(item.link as any);
}}
>
<Ionicons
name={item.icon}
size={20}
color={isActive ? MainColor.yellow : MainColor.white}
style={styles.icon}
/>
<Text
style={[
styles.singleText,
{ color: isActive ? MainColor.yellow : MainColor.white },
]}
>
{item.label}
</Text>
</TouchableOpacity>
);
}
// Styles
const styles = StyleSheet.create({
container: {
marginTop: 20,
},
parentItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
justifyContent: "space-between",
},
parentText: {
flex: 1,
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
singleItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 12,
paddingHorizontal: 10,
backgroundColor: AccentColor.darkblue,
borderRadius: 8,
marginBottom: 5,
},
singleItemActive: {
backgroundColor: AccentColor.blue,
},
singleText: {
fontSize: 16,
fontWeight: "500",
marginLeft: 10,
color: MainColor.white,
},
icon: {
width: 24,
textAlign: "center",
paddingRight: 10,
},
submenu: {
overflow: "hidden",
marginLeft: 30,
marginTop: 5,
},
subItem: {
flexDirection: "row",
alignItems: "center",
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 6,
marginBottom: 4,
},
subItemActive: {
backgroundColor: AccentColor.blue,
},
subText: {
color: MainColor.white,
fontSize: 14,
},
});