refactor(rn): extract tab bar as a component
Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
834c93fc00
commit
9c3400a5d5
|
|
@ -0,0 +1,206 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import type { FC } from "react"
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react"
|
||||
import type {
|
||||
Animated as AnimatedNative,
|
||||
StyleProp,
|
||||
TouchableOpacityProps,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
|
||||
|
||||
import { accentColor } from "@/src/theme/colors"
|
||||
|
||||
import type { Tab } from "./types"
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: Tab[]
|
||||
|
||||
tabbarClassName?: string
|
||||
tabbarStyle?: StyleProp<ViewStyle>
|
||||
|
||||
TabItem?: FC<{ isSelected: boolean; tab: Tab } & Pick<TouchableOpacityProps, "onLayout">>
|
||||
|
||||
onTabItemPress?: (index: number) => void
|
||||
currentTab?: number
|
||||
|
||||
tabScrollContainerAnimatedX?: AnimatedNative.Value
|
||||
}
|
||||
|
||||
const springConfig = {
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
}
|
||||
|
||||
export const TabBar = forwardRef<ScrollView, TabBarProps>(
|
||||
(
|
||||
{
|
||||
tabs,
|
||||
TabItem = Pressable,
|
||||
tabbarClassName,
|
||||
tabbarStyle,
|
||||
|
||||
onTabItemPress,
|
||||
currentTab: tab,
|
||||
tabScrollContainerAnimatedX: pagerOffsetX,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [currentTab, setCurrentTab] = useState(tab || 0)
|
||||
const [tabWidths, setTabWidths] = useState<number[]>([])
|
||||
const [tabPositions, setTabPositions] = useState<number[]>([])
|
||||
const indicatorPosition = useSharedValue(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof tab === "number") {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
const sharedPagerOffsetX = useSharedValue(0)
|
||||
const [tabBarWidth, setTabBarWidth] = useState(0)
|
||||
useEffect(() => {
|
||||
if (pagerOffsetX) {
|
||||
return
|
||||
}
|
||||
sharedPagerOffsetX.value = withSpring(currentTab * tabBarWidth, springConfig)
|
||||
}, [currentTab, pagerOffsetX, sharedPagerOffsetX, tabBarWidth])
|
||||
useEffect(() => {
|
||||
if (!pagerOffsetX) return
|
||||
const id = pagerOffsetX.addListener(({ value }) => {
|
||||
sharedPagerOffsetX.value = value
|
||||
})
|
||||
return () => {
|
||||
pagerOffsetX.removeListener(id)
|
||||
}
|
||||
}, [pagerOffsetX, sharedPagerOffsetX])
|
||||
const tabRef = useRef<ScrollView>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pagerOffsetX) return
|
||||
const listener = pagerOffsetX.addListener(({ value }) => {
|
||||
// Calculate which tab should be active based on scroll position
|
||||
const tabIndex = Math.round(value / tabBarWidth)
|
||||
if (tabIndex !== currentTab) {
|
||||
setCurrentTab(tabIndex)
|
||||
onTabItemPress?.(tabIndex)
|
||||
}
|
||||
})
|
||||
|
||||
return () => pagerOffsetX.removeListener(listener)
|
||||
}, [currentTab, onTabItemPress, pagerOffsetX, tabBarWidth])
|
||||
|
||||
useImperativeHandle(ref, () => tabRef.current!)
|
||||
useEffect(() => {
|
||||
if (tabWidths.length > 0) {
|
||||
indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig)
|
||||
|
||||
if (tabRef.current) {
|
||||
const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0
|
||||
|
||||
const isCurrentTabVisible =
|
||||
sharedPagerOffsetX.value < tabPositions[currentTab] &&
|
||||
sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab]
|
||||
|
||||
if (!isCurrentTabVisible) {
|
||||
tabRef.current.scrollTo({ x, y: 0, animated: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths])
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
const scrollProgress = sharedPagerOffsetX.value / tabBarWidth
|
||||
|
||||
const currentIndex = Math.floor(scrollProgress)
|
||||
const nextIndex = Math.min(currentIndex + 1, tabs.length - 1)
|
||||
const progress = scrollProgress - currentIndex
|
||||
|
||||
// Interpolate between current and next tab positions
|
||||
const xPosition =
|
||||
tabPositions[currentIndex] +
|
||||
(tabPositions[nextIndex] - tabPositions[currentIndex]) * progress
|
||||
|
||||
// Interpolate between current and next tab widths
|
||||
const width =
|
||||
tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress
|
||||
|
||||
return {
|
||||
transform: [{ translateX: xPosition }],
|
||||
width,
|
||||
backgroundColor: tabs[currentTab].activeColor || accentColor,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
onLayout={(event) => {
|
||||
setTabBarWidth(event.nativeEvent.layout.width)
|
||||
}}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className={cn(
|
||||
"border-tertiary-system-background relative shrink-0 grow-0",
|
||||
tabbarClassName,
|
||||
)}
|
||||
horizontal
|
||||
ref={tabRef}
|
||||
contentContainerStyle={styles.tabScroller}
|
||||
style={[styles.root, tabbarStyle]}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
onPress={() => {
|
||||
setCurrentTab(index)
|
||||
onTabItemPress?.(index)
|
||||
}}
|
||||
key={tab.value}
|
||||
isSelected={index === currentTab}
|
||||
onLayout={(event) => {
|
||||
const { width, x } = event.nativeEvent.layout
|
||||
setTabWidths((prev) => {
|
||||
const newWidths = [...prev]
|
||||
newWidths[index] = width
|
||||
return newWidths
|
||||
})
|
||||
setTabPositions((prev) => {
|
||||
const newPositions = [...prev]
|
||||
newPositions[index] = x
|
||||
return newPositions
|
||||
})
|
||||
}}
|
||||
tab={tab}
|
||||
>
|
||||
<TabItemInner tab={tab} isSelected={index === currentTab} />
|
||||
</TabItem>
|
||||
))}
|
||||
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
</ScrollView>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabScroller: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
root: { paddingHorizontal: 6 },
|
||||
|
||||
indicator: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
},
|
||||
})
|
||||
|
||||
const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => {
|
||||
return (
|
||||
<View className="p-2">
|
||||
<Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +1,18 @@
|
|||
import { cn } from "@follow/utils"
|
||||
import type { FC } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import type { StyleProp, TouchableOpacityProps, ViewStyle } from "react-native"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { ScrollView, StyleProp, TouchableOpacityProps, ViewStyle } from "react-native"
|
||||
import {
|
||||
Animated as RnAnimated,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
useAnimatedValue,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native"
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated"
|
||||
import type { ViewProps } from "react-native-svg/lib/typescript/fabric/utils"
|
||||
|
||||
import { accentColor } from "@/src/theme/colors"
|
||||
|
||||
import { AnimatedScrollView } from "../../common/AnimatedComponents"
|
||||
import { TabBar } from "./TabBar"
|
||||
|
||||
type Tab = {
|
||||
name: string
|
||||
|
|
@ -45,11 +40,6 @@ interface TabViewProps {
|
|||
lazyOnce?: boolean
|
||||
}
|
||||
|
||||
const springConfig = {
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
}
|
||||
|
||||
export const TabView: FC<TabViewProps> = ({
|
||||
tabs,
|
||||
Tab = View,
|
||||
|
|
@ -67,80 +57,12 @@ export const TabView: FC<TabViewProps> = ({
|
|||
lazyOnce,
|
||||
lazyTab,
|
||||
}) => {
|
||||
const tabRef = useRef<ScrollView>(null)
|
||||
|
||||
const [tabWidths, setTabWidths] = useState<number[]>([])
|
||||
const [tabPositions, setTabPositions] = useState<number[]>([])
|
||||
|
||||
const [currentTab, setCurrentTab] = useState(initialTab ?? 0)
|
||||
|
||||
const pagerOffsetX = useAnimatedValue(0)
|
||||
const sharedPagerOffsetX = useSharedValue(0)
|
||||
useEffect(() => {
|
||||
const id = pagerOffsetX.addListener(({ value }) => {
|
||||
sharedPagerOffsetX.value = value
|
||||
})
|
||||
return () => {
|
||||
pagerOffsetX.removeListener(id)
|
||||
}
|
||||
}, [pagerOffsetX, sharedPagerOffsetX])
|
||||
|
||||
const indicatorPosition = useSharedValue(0)
|
||||
const { width: windowWidth } = useWindowDimensions()
|
||||
|
||||
useEffect(() => {
|
||||
if (tabWidths.length > 0) {
|
||||
indicatorPosition.value = withSpring(tabPositions[currentTab] || 0, springConfig)
|
||||
|
||||
if (tabRef.current) {
|
||||
const x = currentTab > 0 ? tabPositions[currentTab - 1] + tabWidths[currentTab - 1] : 0
|
||||
|
||||
const isCurrentTabVisible =
|
||||
sharedPagerOffsetX.value < tabPositions[currentTab] &&
|
||||
sharedPagerOffsetX.value + tabWidths[currentTab] > tabPositions[currentTab]
|
||||
|
||||
if (!isCurrentTabVisible) {
|
||||
tabRef.current.scrollTo({ x, y: 0, animated: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [currentTab, indicatorPosition, sharedPagerOffsetX.value, tabPositions, tabWidths])
|
||||
|
||||
const indicatorStyle = useAnimatedStyle(() => {
|
||||
const scrollProgress = sharedPagerOffsetX.value / windowWidth
|
||||
|
||||
const currentIndex = Math.floor(scrollProgress)
|
||||
const nextIndex = Math.min(currentIndex + 1, tabs.length - 1)
|
||||
const progress = scrollProgress - currentIndex
|
||||
|
||||
// Interpolate between current and next tab positions
|
||||
const xPosition =
|
||||
tabPositions[currentIndex] + (tabPositions[nextIndex] - tabPositions[currentIndex]) * progress
|
||||
|
||||
// Interpolate between current and next tab widths
|
||||
const width =
|
||||
tabWidths[currentIndex] + (tabWidths[nextIndex] - tabWidths[currentIndex]) * progress
|
||||
|
||||
return {
|
||||
transform: [{ translateX: xPosition }],
|
||||
width,
|
||||
backgroundColor: tabs[currentTab].activeColor || accentColor,
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const listener = pagerOffsetX.addListener(({ value }) => {
|
||||
// Calculate which tab should be active based on scroll position
|
||||
const tabIndex = Math.round(value / windowWidth)
|
||||
if (tabIndex !== currentTab) {
|
||||
setCurrentTab(tabIndex)
|
||||
onTabChange?.(tabIndex)
|
||||
}
|
||||
})
|
||||
|
||||
return () => pagerOffsetX.removeListener(listener)
|
||||
}, [tabWidths, tabPositions, currentTab, pagerOffsetX, windowWidth, onTabChange])
|
||||
|
||||
const [lazyTabSet, setLazyTabSet] = useState(() => new Set<number>())
|
||||
|
||||
const shouldRenderCurrentTab = (index: number) => {
|
||||
|
|
@ -162,47 +84,22 @@ export const TabView: FC<TabViewProps> = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<ScrollView
|
||||
showsHorizontalScrollIndicator={false}
|
||||
className={cn(
|
||||
"border-tertiary-system-background relative shrink-0 grow-0",
|
||||
tabbarClassName,
|
||||
<TabBar
|
||||
onTabItemPress={useCallback(
|
||||
(index: number) => {
|
||||
contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true })
|
||||
setCurrentTab(index)
|
||||
onTabChange?.(index)
|
||||
},
|
||||
[onTabChange, windowWidth],
|
||||
)}
|
||||
horizontal
|
||||
ref={tabRef}
|
||||
contentContainerStyle={styles.tabScroller}
|
||||
style={[styles.root, tabbarStyle]}
|
||||
>
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
onPress={() => {
|
||||
// setCurrentTab(index)
|
||||
contentScrollerRef.current?.scrollTo({ x: index * windowWidth, y: 0, animated: true })
|
||||
onTabChange?.(index)
|
||||
}}
|
||||
key={tab.value}
|
||||
isSelected={index === currentTab}
|
||||
onLayout={(event) => {
|
||||
const { width, x } = event.nativeEvent.layout
|
||||
setTabWidths((prev) => {
|
||||
const newWidths = [...prev]
|
||||
newWidths[index] = width
|
||||
return newWidths
|
||||
})
|
||||
setTabPositions((prev) => {
|
||||
const newPositions = [...prev]
|
||||
newPositions[index] = x
|
||||
return newPositions
|
||||
})
|
||||
}}
|
||||
tab={tab}
|
||||
>
|
||||
<TabItemInner tab={tab} isSelected={index === currentTab} />
|
||||
</TabItem>
|
||||
))}
|
||||
|
||||
<Animated.View style={[styles.indicator, indicatorStyle]} />
|
||||
</ScrollView>
|
||||
tabs={tabs}
|
||||
currentTab={currentTab}
|
||||
tabbarClassName={tabbarClassName}
|
||||
tabbarStyle={tabbarStyle}
|
||||
TabItem={TabItem}
|
||||
tabScrollContainerAnimatedX={pagerOffsetX}
|
||||
/>
|
||||
|
||||
<AnimatedScrollView
|
||||
onScroll={RnAnimated.event([{ nativeEvent: { contentOffset: { x: pagerOffsetX } } }], {
|
||||
|
|
@ -227,27 +124,3 @@ export const TabView: FC<TabViewProps> = ({
|
|||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TabItemInner = ({ tab, isSelected }: { tab: Tab; isSelected: boolean }) => {
|
||||
return (
|
||||
<View className="p-2">
|
||||
<Text style={{ color: isSelected ? accentColor : "gray" }}>{tab.name}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabScroller: {
|
||||
alignItems: "center",
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
root: { paddingHorizontal: 6 },
|
||||
indicator: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
height: 2,
|
||||
borderRadius: 1,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
export type Tab = {
|
||||
name: string
|
||||
activeColor?: string
|
||||
value: string
|
||||
}
|
||||
|
|
@ -16,7 +16,7 @@ import { TabView } from "@/src/components/ui/tabview"
|
|||
import { apiClient } from "@/src/lib/api-fetch"
|
||||
|
||||
import { RSSHubCategoryCopyMap } from "./copy"
|
||||
import { RecommendationListItem } from "./recommendation-item"
|
||||
import { RecommendationListItem } from "./RecommendationListItem"
|
||||
|
||||
export const Recommendations = () => {
|
||||
const headerHeight = useHeaderHeight()
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { useAtom } from "jotai"
|
||||
import { View } from "react-native"
|
||||
|
||||
import { TabBar } from "@/src/components/ui/tabview/TabBar"
|
||||
import type { Tab } from "@/src/components/ui/tabview/types"
|
||||
|
||||
import { SearchType } from "./constants"
|
||||
import { useDiscoverPageContext } from "./ctx"
|
||||
|
||||
const Tabs: Tab[] = [
|
||||
{ name: "All", value: SearchType.AGGREGATE },
|
||||
{ name: "RSS", value: SearchType.RSS },
|
||||
{ name: "RSSHub", value: SearchType.RSSHUB },
|
||||
{ name: "User", value: SearchType.USER },
|
||||
]
|
||||
export const SearchTabBar = () => {
|
||||
const { searchTypeAtom } = useDiscoverPageContext()
|
||||
const [searchType, setSearchType] = useAtom(searchTypeAtom)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<TabBar
|
||||
tabs={Tabs}
|
||||
currentTab={Tabs.findIndex((tab) => tab.value === searchType)}
|
||||
onTabItemPress={(index) => {
|
||||
setSearchType(Tabs[index].value as SearchType)
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export enum SearchType {
|
||||
AGGREGATE = "aggregate",
|
||||
RSS = "rss",
|
||||
RSSHUB = "rsshub",
|
||||
USER = "user",
|
||||
}
|
||||
|
|
@ -2,9 +2,13 @@ import type { PrimitiveAtom } from "jotai"
|
|||
import { atom } from "jotai"
|
||||
import { createContext, useContext, useState } from "react"
|
||||
|
||||
import { SearchType } from "./constants"
|
||||
|
||||
interface DiscoverPageContextType {
|
||||
searchFocusedAtom: PrimitiveAtom<boolean>
|
||||
searchValueAtom: PrimitiveAtom<string>
|
||||
|
||||
searchTypeAtom: PrimitiveAtom<SearchType>
|
||||
}
|
||||
export const DiscoverPageContext = createContext<DiscoverPageContextType>(null!)
|
||||
|
||||
|
|
@ -12,10 +16,11 @@ export const DiscoverPageProvider = ({ children }: { children: React.ReactNode }
|
|||
const [atomRefs] = useState((): DiscoverPageContextType => {
|
||||
const searchFocusedAtom = atom(true)
|
||||
const searchValueAtom = atom("")
|
||||
|
||||
const searchTypeAtom = atom(SearchType.AGGREGATE)
|
||||
return {
|
||||
searchFocusedAtom,
|
||||
searchValueAtom,
|
||||
searchTypeAtom,
|
||||
}
|
||||
})
|
||||
return <DiscoverPageContext.Provider value={atomRefs}>{children}</DiscoverPageContext.Provider>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { getDefaultHeaderHeight } from "@react-navigation/elements"
|
||||
import { useTheme } from "@react-navigation/native"
|
||||
import { router } from "expo-router"
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { useEffect, useRef } from "react"
|
||||
|
|
@ -21,6 +20,7 @@ import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
|
|||
import { accentColor, useColor } from "@/src/theme/colors"
|
||||
|
||||
import { useDiscoverPageContext } from "./ctx"
|
||||
import { SearchTabBar } from "./SearchTabBar"
|
||||
|
||||
export const SearchHeader = () => {
|
||||
const frame = useSafeAreaFrame()
|
||||
|
|
@ -28,11 +28,12 @@ export const SearchHeader = () => {
|
|||
const headerHeight = getDefaultHeaderHeight(frame, false, insets.top)
|
||||
|
||||
return (
|
||||
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
|
||||
<View style={{ minHeight: headerHeight, paddingTop: insets.top }} className="relative">
|
||||
<BlurEffect />
|
||||
<View style={styles.header}>
|
||||
<ComposeSearchBar />
|
||||
</View>
|
||||
<SearchTabBar />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -105,7 +106,6 @@ const ComposeSearchBar = () => {
|
|||
}
|
||||
|
||||
const SearchInput = () => {
|
||||
const { colors } = useTheme()
|
||||
const { searchFocusedAtom, searchValueAtom } = useDiscoverPageContext()
|
||||
const [isFocused, setIsFocused] = useAtom(searchFocusedAtom)
|
||||
const placeholderTextColor = useColor("placeholderText")
|
||||
|
|
@ -173,7 +173,7 @@ const SearchInput = () => {
|
|||
}, [isFocused])
|
||||
|
||||
return (
|
||||
<View style={{ backgroundColor: colors.card, ...styles.searchbar }}>
|
||||
<View style={styles.searchbar} className="dark:bg-gray-6 bg-gray-5">
|
||||
{focusOrHasValue && (
|
||||
<Animated.View
|
||||
style={{
|
||||
|
|
@ -221,7 +221,6 @@ const SearchInput = () => {
|
|||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flex: 1,
|
||||
|
||||
alignItems: "center",
|
||||
marginTop: -3,
|
||||
flexDirection: "row",
|
||||
|
|
@ -229,15 +228,15 @@ const styles = StyleSheet.create({
|
|||
marginHorizontal: 16,
|
||||
position: "relative",
|
||||
},
|
||||
|
||||
searchbar: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
borderRadius: 50,
|
||||
height: "100%",
|
||||
height: 32,
|
||||
position: "relative",
|
||||
},
|
||||
searchInput: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Stack } from "expo-router"
|
||||
|
||||
import { Recommendations } from "@/src/modules/discover/recommendations"
|
||||
import { Recommendations } from "@/src/modules/discover/Recommendations"
|
||||
import { DiscoverHeader } from "@/src/modules/discover/search"
|
||||
|
||||
export default function Discover() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue