feat: rsshub form layer
Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
fd373fb527
commit
6f9fb5ba92
|
|
@ -22,8 +22,10 @@
|
|||
"@follow/models": "workspace:*",
|
||||
"@follow/shared": "workspace:*",
|
||||
"@follow/utils": "workspace:*",
|
||||
"@gorhom/portal": "1.0.14",
|
||||
"@hookform/resolvers": "3.9.1",
|
||||
"@react-native-cookies/cookies": "^6.2.1",
|
||||
"@react-native-picker/picker": "2.9.0",
|
||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
"@shopify/flash-list": "1.7.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
import { cn } from "@follow/utils/src/utils"
|
||||
import { useRef } from "react"
|
||||
import { Pressable } from "react-native"
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withTiming,
|
||||
} from "react-native-reanimated"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { CheckFilledIcon } from "@/src/icons/check_filled"
|
||||
import { Copy2CuteReIcon } from "@/src/icons/copy_2_cute_re"
|
||||
|
||||
type Size = "sm" | "md"
|
||||
interface CopyButtonProps {
|
||||
onCopy: () => void
|
||||
className?: string
|
||||
size?: Size
|
||||
}
|
||||
|
||||
const sizeClassNames = {
|
||||
sm: "size-8",
|
||||
md: "size-10",
|
||||
}
|
||||
|
||||
const sizeIconSize = {
|
||||
sm: 18,
|
||||
md: 20,
|
||||
}
|
||||
|
||||
export const CopyButton = ({ onCopy, className, size = "sm" }: CopyButtonProps) => {
|
||||
const initialIconScale = useSharedValue(1)
|
||||
const pressedIconScale = useSharedValue(0)
|
||||
const initialStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: initialIconScale.value }],
|
||||
}))
|
||||
|
||||
const pressedStyle = useAnimatedStyle(() => ({
|
||||
position: "absolute",
|
||||
transform: [{ scale: pressedIconScale.value }],
|
||||
}))
|
||||
|
||||
const animatedProgressingRef = useRef(false)
|
||||
const handlePress = useEventCallback(() => {
|
||||
onCopy()
|
||||
if (animatedProgressingRef.current) return
|
||||
animatedProgressingRef.current = true
|
||||
initialIconScale.value = withTiming(0, { duration: 100 }, () => {
|
||||
pressedIconScale.value = withTiming(1, { duration: 100 }, () => {
|
||||
pressedIconScale.value = withDelay(
|
||||
1000,
|
||||
withTiming(0, { duration: 100 }, () => {
|
||||
initialIconScale.value = withTiming(1, { duration: 100 }, () => {
|
||||
animatedProgressingRef.current = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
return (
|
||||
<Pressable
|
||||
className={cn(
|
||||
"bg-red items-center justify-center rounded-lg",
|
||||
sizeClassNames[size],
|
||||
className,
|
||||
)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<Animated.View style={initialStyle}>
|
||||
<Copy2CuteReIcon color="#fff" height={sizeIconSize[size]} width={sizeIconSize[size]} />
|
||||
</Animated.View>
|
||||
<Animated.View style={pressedStyle}>
|
||||
<CheckFilledIcon color="#fff" height={sizeIconSize[size]} width={sizeIconSize[size]} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
|
@ -11,6 +11,6 @@ const node = (
|
|||
}}
|
||||
/>
|
||||
)
|
||||
export const HeaderBlur = () => {
|
||||
export const BlurEffect = () => {
|
||||
return node
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { cn } from "@follow/utils/src/utils"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import type { StyleProp, ViewStyle } from "react-native"
|
||||
import { Text, View } from "react-native"
|
||||
|
||||
export const FormLabel: FC<
|
||||
PropsWithChildren<{
|
||||
label: string
|
||||
optional?: boolean
|
||||
className?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}>
|
||||
> = ({ label, optional, className, style }) => {
|
||||
return (
|
||||
<View className={cn("flex-row", className)} style={style}>
|
||||
<Text className="text-label text-lg font-medium capitalize">{label}</Text>
|
||||
{!optional && <Text className="text-red ml-1 align-sub">*</Text>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/* eslint-disable @eslint-react/no-array-index-key */
|
||||
import { cn } from "@follow/utils"
|
||||
import { Portal } from "@gorhom/portal"
|
||||
import { Picker } from "@react-native-picker/picker"
|
||||
import { useMemo, useState } from "react"
|
||||
import type { StyleProp, ViewStyle } from "react-native"
|
||||
import { Pressable, Text, View } from "react-native"
|
||||
import Animated, { SlideOutDown } from "react-native-reanimated"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { MingcuteDownLineIcon } from "@/src/icons/mingcute_down_line"
|
||||
import { useColor } from "@/src/theme/colors"
|
||||
|
||||
import { BlurEffect } from "../../common/HeaderBlur"
|
||||
|
||||
interface SelectProps<T> {
|
||||
options: { label: string; value: T }[]
|
||||
|
||||
value: T
|
||||
onValueChange: (value: T) => void
|
||||
|
||||
wrapperClassName?: string
|
||||
wrapperStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
export function Select<T>({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
wrapperClassName,
|
||||
wrapperStyle,
|
||||
}: SelectProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const [currentValue, setCurrentValue] = useState(() => {
|
||||
if (!value) {
|
||||
return options[0].value
|
||||
}
|
||||
return value
|
||||
})
|
||||
|
||||
const valueToLabelMap = useMemo(() => {
|
||||
return options.reduce((acc, option) => {
|
||||
acc.set(option.value, option.label)
|
||||
return acc
|
||||
}, new Map<T, string>())
|
||||
}, [options])
|
||||
|
||||
const handleChangeValue = useEventCallback((value: T) => {
|
||||
setCurrentValue(value)
|
||||
onValueChange(value)
|
||||
})
|
||||
|
||||
const systemFill = useColor("systemFill")
|
||||
return (
|
||||
<>
|
||||
{/* Trigger */}
|
||||
<Pressable onPress={() => setIsOpen(!isOpen)}>
|
||||
<View
|
||||
className={cn(
|
||||
"border-system-fill/80 bg-system-fill/30 h-12 flex-row items-center rounded-lg border pl-4 pr-2",
|
||||
wrapperClassName,
|
||||
)}
|
||||
style={wrapperStyle}
|
||||
>
|
||||
<Text className="text-text">{valueToLabelMap.get(currentValue)}</Text>
|
||||
<View className="ml-auto shrink-0">
|
||||
<MingcuteDownLineIcon color={systemFill} />
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
{/* Picker */}
|
||||
{isOpen && (
|
||||
<Portal>
|
||||
<Pressable
|
||||
onPress={() => setIsOpen(false)}
|
||||
className="absolute inset-0 flex flex-row items-end"
|
||||
>
|
||||
<Animated.View className="relative flex-1" exiting={SlideOutDown}>
|
||||
<BlurEffect />
|
||||
<Pressable onPress={(e) => e.stopPropagation()}>
|
||||
<Picker selectedValue={currentValue} onValueChange={handleChangeValue}>
|
||||
{options.map((option, index) => (
|
||||
<Picker.Item key={index} label={option.label} value={option.value} />
|
||||
))}
|
||||
</Picker>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</Portal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { cn } from "@follow/utils/src/utils"
|
||||
import type { FC } from "react"
|
||||
import type { StyleProp, TextInputProps, ViewStyle } from "react-native"
|
||||
import { StyleSheet, TextInput, View } from "react-native"
|
||||
|
||||
interface TextFieldProps {
|
||||
wrapperClassName?: string
|
||||
wrapperStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
export const TextField: FC<TextInputProps & TextFieldProps> = ({
|
||||
className,
|
||||
style,
|
||||
wrapperClassName,
|
||||
wrapperStyle,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"bg-system-fill/40 relative h-12 flex-row items-center rounded-lg px-4",
|
||||
wrapperClassName,
|
||||
)}
|
||||
style={wrapperStyle}
|
||||
>
|
||||
<TextInput
|
||||
className={cn("text-text placeholder:text-placeholder-text", className)}
|
||||
style={StyleSheet.flatten([styles.textField, style])}
|
||||
{...rest}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
textField: {
|
||||
fontSize: 16,
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import * as React from "react"
|
||||
import Svg, { G, Path } from "react-native-svg"
|
||||
|
||||
interface MingcuteDownLineIconProps {
|
||||
width?: number
|
||||
height?: number
|
||||
color?: string
|
||||
}
|
||||
|
||||
export const MingcuteDownLineIcon = ({
|
||||
width = 24,
|
||||
height = 24,
|
||||
color = "#10161F",
|
||||
}: MingcuteDownLineIconProps) => {
|
||||
return (
|
||||
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
|
||||
<G fill="none" fillRule="evenodd">
|
||||
<Path d="M24 0v24H0V0zM12.593 23.258l-.011.002-.071.035-.02.004-.014-.004-.071-.035q-.016-.005-.024.005l-.004.01-.017.428.005.02.01.013.104.074.015.004.012-.004.104-.074.012-.016.004-.017-.017-.427q-.004-.016-.017-.018m.265-.113-.013.002-.185.093-.01.01-.003.011.018.43.005.012.008.007.201.093q.019.005.029-.008l.004-.014-.034-.614q-.005-.019-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014-.034.614q.001.018.017.024l.015-.002.201-.093.01-.008.004-.011.017-.43-.003-.012-.01-.01z" />
|
||||
<Path
|
||||
fill={color}
|
||||
d="M12.707 15.707a1 1 0 0 1-1.414 0L5.636 10.05A1 1 0 1 1 7.05 8.636l4.95 4.95 4.95-4.95a1 1 0 0 1 1.414 1.414z"
|
||||
/>
|
||||
</G>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,7 +13,8 @@ import { RSSHubCategoryCopyMap } from "./copy"
|
|||
|
||||
export const RecommendationListItem: FC<{
|
||||
data: RSSHubRouteDeclaration
|
||||
}> = memo(({ data }) => {
|
||||
routePrefix: string
|
||||
}> = memo(({ data, routePrefix }) => {
|
||||
const { maintainers, categories } = useMemo(() => {
|
||||
const maintainers = new Set<string>()
|
||||
const categories = new Set<string>()
|
||||
|
|
@ -90,23 +91,26 @@ export const RecommendationListItem: FC<{
|
|||
|
||||
<Grid columns={2} gap={8} className="mt-2">
|
||||
{Object.keys(data.routes).map((route) => (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: "/rsshub-form",
|
||||
params: {
|
||||
url: data.url,
|
||||
route,
|
||||
},
|
||||
})
|
||||
}}
|
||||
key={route}
|
||||
className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2"
|
||||
>
|
||||
<Text ellipsizeMode="middle" numberOfLines={1} className="whitespace-pre">
|
||||
{data.routes[route].name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View className="relative" key={route}>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
router.push({
|
||||
pathname: "/rsshub-form",
|
||||
params: {
|
||||
routePrefix,
|
||||
route: JSON.stringify(data.routes[route]),
|
||||
name: data.name,
|
||||
},
|
||||
})
|
||||
}}
|
||||
className="bg-gray-5 h-10 flex-row items-center justify-center overflow-hidden rounded px-2"
|
||||
/>
|
||||
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
|
||||
<Text ellipsizeMode="middle" numberOfLines={1} className="text-text whitespace-pre">
|
||||
{data.routes[route].name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</Grid>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useHeaderHeight } from "@react-navigation/elements"
|
|||
import { FlashList } from "@shopify/flash-list"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { FC } from "react"
|
||||
import { useCallback, useMemo, useRef, useState } from "react"
|
||||
import { useCallback, useMemo, useRef } from "react"
|
||||
import { Text, TouchableOpacity, View } from "react-native"
|
||||
import type { PanGestureHandlerGestureEvent } from "react-native-gesture-handler"
|
||||
import { PanGestureHandler } from "react-native-gesture-handler"
|
||||
|
|
@ -183,7 +183,7 @@ const ItemRenderer = ({
|
|||
// Render item
|
||||
return (
|
||||
<View className="mr-4">
|
||||
<RecommendationListItem data={item.data} />
|
||||
<RecommendationListItem data={item.data} routePrefix={item.key} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -193,8 +193,6 @@ const NavigationSidebar: FC<{
|
|||
alphabetGroups: (string | { key: string; data: RSSHubRouteDeclaration })[]
|
||||
listRef: React.RefObject<FlashList<string | { key: string; data: RSSHubRouteDeclaration }>>
|
||||
}> = ({ alphabetGroups, listRef }) => {
|
||||
const [activeLetter, setActiveLetter] = useState<string>("")
|
||||
|
||||
const scrollToLetter = useCallback(
|
||||
(letter: string, animated = true) => {
|
||||
const index = alphabetGroups.findIndex((group) => {
|
||||
|
|
@ -237,10 +235,8 @@ const NavigationSidebar: FC<{
|
|||
const firstChar = letter[0].toUpperCase()
|
||||
const firstCharIsAlphabet = /[A-Z]/.test(firstChar)
|
||||
if (firstCharIsAlphabet) {
|
||||
setActiveLetter(letter)
|
||||
scrollToLetter(letter, false)
|
||||
} else {
|
||||
setActiveLetter("#")
|
||||
scrollToLetter("#", false)
|
||||
}
|
||||
},
|
||||
|
|
@ -256,15 +252,10 @@ const NavigationSidebar: FC<{
|
|||
hitSlop={5}
|
||||
key={title}
|
||||
onPress={() => {
|
||||
setActiveLetter(title)
|
||||
scrollToLetter(title)
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm ${activeLetter !== title ? "text-secondary-text/60" : "text-accent"}`}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text className="text-secondary-text/60 text-sm">{title}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "react-native"
|
||||
import { useSafeAreaFrame, useSafeAreaInsets } from "react-native-safe-area-context"
|
||||
|
||||
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
|
||||
import { BlurEffect } from "@/src/components/common/HeaderBlur"
|
||||
import { Search2CuteReIcon } from "@/src/icons/search_2_cute_re"
|
||||
import { accentColor, useColor } from "@/src/theme/colors"
|
||||
|
||||
|
|
@ -29,7 +29,7 @@ export const SearchHeader = () => {
|
|||
|
||||
return (
|
||||
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
|
||||
<HeaderBlur />
|
||||
<BlurEffect />
|
||||
<View style={styles.header}>
|
||||
<ComposeSearchBar />
|
||||
</View>
|
||||
|
|
@ -44,7 +44,7 @@ export const DiscoverHeader = () => {
|
|||
|
||||
return (
|
||||
<View style={{ height: headerHeight, paddingTop: insets.top }} className="relative">
|
||||
<HeaderBlur />
|
||||
<BlurEffect />
|
||||
<View style={styles.header}>
|
||||
<PlaceholerSearchBar />
|
||||
</View>
|
||||
|
|
@ -53,11 +53,11 @@ export const DiscoverHeader = () => {
|
|||
}
|
||||
|
||||
const PlaceholerSearchBar = () => {
|
||||
const { colors } = useTheme()
|
||||
const placeholderTextColor = useColor("placeholderText")
|
||||
return (
|
||||
<Pressable
|
||||
style={{ backgroundColor: colors.card, ...styles.searchbar }}
|
||||
style={styles.searchbar}
|
||||
className="dark:bg-gray-6 bg-gray-5"
|
||||
onPress={() => {
|
||||
router.push("/search")
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import { cn, regexpPathToPath } from "@follow/utils"
|
||||
import type { FC } from "react"
|
||||
import { useMemo } from "react"
|
||||
import type { UseFormReturn } from "react-hook-form"
|
||||
import { Clipboard, Text, View } from "react-native"
|
||||
|
||||
import { CopyButton } from "@/src/components/common/CopyButton"
|
||||
|
||||
export const PreviewUrl: FC<{
|
||||
watch: UseFormReturn<any>["watch"]
|
||||
path: string
|
||||
routePrefix: string
|
||||
className?: string
|
||||
}> = ({ watch, path, routePrefix, className }) => {
|
||||
const data = watch()
|
||||
|
||||
const fullPath = useMemo(() => {
|
||||
try {
|
||||
return regexpPathToPath(path, data)
|
||||
} catch (err: unknown) {
|
||||
console.info((err as Error).message)
|
||||
return path
|
||||
}
|
||||
}, [path, data])
|
||||
|
||||
const renderedPath = `rsshub://${routePrefix}${fullPath}`
|
||||
return (
|
||||
<View className={cn("relative min-w-0", className)}>
|
||||
<Text className="text-text/80 w-full whitespace-pre-line break-words">{renderedPath}</Text>
|
||||
<CopyButton
|
||||
onCopy={() => {
|
||||
Clipboard.setString(renderedPath)
|
||||
}}
|
||||
className="absolute right-0 top-0"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { jotaiStore } from "@follow/utils"
|
||||
import { PortalProvider } from "@gorhom/portal"
|
||||
import { ThemeProvider } from "@react-navigation/native"
|
||||
import { QueryClientProvider } from "@tanstack/react-query"
|
||||
import { useDrizzleStudio } from "expo-drizzle-studio-plugin"
|
||||
|
|
@ -28,7 +29,9 @@ export const RootProviders = ({ children }: { children: ReactNode }) => {
|
|||
<View style={[styles.flex, currentThemeColors]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<GestureHandlerRootView>{children}</GestureHandlerRootView>
|
||||
<GestureHandlerRootView>
|
||||
<PortalProvider>{children}</PortalProvider>
|
||||
</GestureHandlerRootView>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,162 @@
|
|||
import { Stack } from "expo-router"
|
||||
import type { RSSHubParameter, RSSHubParameterObject, RSSHubRoute } from "@follow/models/src/rsshub"
|
||||
import { parseFullPathParams, parseRegexpPathParams } from "@follow/utils"
|
||||
import { PortalProvider } from "@gorhom/portal"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { router, Stack, useLocalSearchParams } from "expo-router"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import type { UseFormReturn } from "react-hook-form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { View } from "react-native"
|
||||
import { KeyboardAwareScrollView } from "react-native-keyboard-controller"
|
||||
import { z } from "zod"
|
||||
|
||||
import { ModalHeaderCloseButton } from "@/src/components/common/ModalSharedComponents"
|
||||
import { FormLabel } from "@/src/components/ui/form/Label"
|
||||
import { Select } from "@/src/components/ui/form/Select"
|
||||
import { TextField } from "@/src/components/ui/form/TextField"
|
||||
import { PreviewUrl } from "@/src/modules/rsshub/preview-url"
|
||||
|
||||
interface RsshubFormParams {
|
||||
route: RSSHubRoute
|
||||
routePrefix: string
|
||||
name: string
|
||||
}
|
||||
export default function RsshubForm() {
|
||||
const params = useLocalSearchParams()
|
||||
|
||||
const { route, routePrefix, name } = (params || {}) as Record<string, string>
|
||||
|
||||
const parsedRoute = useMemo(() => {
|
||||
if (!route) return null
|
||||
try {
|
||||
return typeof route === "string" ? JSON.parse(route) : route
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [route])
|
||||
|
||||
const canBack = router.canDismiss()
|
||||
useEffect(() => {
|
||||
if (!parsedRoute && canBack) {
|
||||
router.dismiss()
|
||||
}
|
||||
}, [canBack, parsedRoute])
|
||||
if (!parsedRoute || !routePrefix) {
|
||||
return null
|
||||
}
|
||||
return <FormImpl route={parsedRoute} routePrefix={routePrefix as string} name={name} />
|
||||
}
|
||||
|
||||
function FormImpl({ route, routePrefix, name }: RsshubFormParams) {
|
||||
const { name: routeName } = route
|
||||
const keys = useMemo(
|
||||
() =>
|
||||
parseRegexpPathParams(route.path, {
|
||||
excludeNames: [
|
||||
"routeParams",
|
||||
"functionalFlag",
|
||||
"fulltext",
|
||||
"disableEmbed",
|
||||
"date",
|
||||
"language",
|
||||
"lang",
|
||||
"sort",
|
||||
],
|
||||
}),
|
||||
[route.path],
|
||||
)
|
||||
|
||||
const formPlaceholder = useMemo<Record<string, string>>(() => {
|
||||
if (!route.example) return {}
|
||||
return parseFullPathParams(route.example.replace(`/${routePrefix}`, ""), route.path)
|
||||
}, [route.example, route.path, routePrefix])
|
||||
const dynamicFormSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
...Object.fromEntries(
|
||||
keys.map((keyItem) => [
|
||||
keyItem.name,
|
||||
keyItem.optional ? z.string().optional().nullable() : z.string().min(1),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
[keys],
|
||||
)
|
||||
|
||||
const defaultValue = useMemo(() => {
|
||||
const ret = {} as Record<string, string | null>
|
||||
if (!route.parameters) return ret
|
||||
for (const key in route.parameters) {
|
||||
const params = normalizeRSSHubParameters(route.parameters[key])
|
||||
if (!params) continue
|
||||
ret[key] = params.default
|
||||
}
|
||||
return ret
|
||||
}, [route.parameters])
|
||||
|
||||
const form = useForm<z.infer<typeof dynamicFormSchema>>({
|
||||
resolver: zodResolver(dynamicFormSchema),
|
||||
defaultValues: defaultValue,
|
||||
mode: "all",
|
||||
}) as UseFormReturn<any>
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Stack.Screen options={{ headerLeft: ModalHeaderCloseButton }} />
|
||||
</View>
|
||||
<PortalProvider>
|
||||
<KeyboardAwareScrollView>
|
||||
<Stack.Screen
|
||||
options={{ headerLeft: ModalHeaderCloseButton, headerTitle: `${name} - ${routeName}` }}
|
||||
/>
|
||||
|
||||
<PreviewUrl
|
||||
className="my-6 px-6"
|
||||
watch={form.watch}
|
||||
path={route.path}
|
||||
routePrefix={routePrefix}
|
||||
/>
|
||||
{/* Form */}
|
||||
<View className="gap-4 px-2">
|
||||
{keys.map((keyItem) => {
|
||||
const parameters = normalizeRSSHubParameters(route.parameters[keyItem.name])
|
||||
const formRegister = form.register(keyItem.name)
|
||||
|
||||
return (
|
||||
<View key={keyItem.name}>
|
||||
<FormLabel className="pl-1" label={keyItem.name} optional={keyItem.optional} />
|
||||
{!parameters?.options && (
|
||||
<TextField
|
||||
wrapperClassName="mt-2"
|
||||
placeholder={formPlaceholder[keyItem.name]}
|
||||
value={form.getValues(keyItem.name)}
|
||||
onChangeText={(text) => {
|
||||
formRegister.onChange({
|
||||
target: { value: text },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!parameters?.options && (
|
||||
<Select
|
||||
wrapperClassName="mt-2"
|
||||
options={parameters.options}
|
||||
value={form.getValues(keyItem.name)}
|
||||
onValueChange={(value) => {
|
||||
formRegister.onChange({ target: { value } })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
</PortalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeRSSHubParameters = (parameters: RSSHubParameter): RSSHubParameterObject | null =>
|
||||
parameters
|
||||
? typeof parameters === "string"
|
||||
? { description: parameters, default: null }
|
||||
: parameters
|
||||
: null
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { View } from "react-native"
|
|||
import { Gesture, GestureDetector } from "react-native-gesture-handler"
|
||||
import { runOnJS } from "react-native-reanimated"
|
||||
|
||||
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
|
||||
import { BlurEffect } from "@/src/components/common/HeaderBlur"
|
||||
import { FollowIcon } from "@/src/components/ui/logo"
|
||||
import { SafariCuteFi } from "@/src/icons/safari_cute_fi"
|
||||
import { SafariCuteIcon } from "@/src/icons/safari_cute-re"
|
||||
|
|
@ -23,7 +23,7 @@ export default function TabLayout() {
|
|||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarBackground: HeaderBlur,
|
||||
tabBarBackground: BlurEffect,
|
||||
tabBarStyle: {
|
||||
position: "absolute",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Stack, useLocalSearchParams } from "expo-router"
|
||||
import { ScrollView, Text, View } from "react-native"
|
||||
|
||||
import { HeaderBlur } from "@/src/components/common/HeaderBlur"
|
||||
import { BlurEffect } from "@/src/components/common/HeaderBlur"
|
||||
|
||||
export default function Feed() {
|
||||
const { feedId } = useLocalSearchParams()
|
||||
|
|
@ -12,7 +12,7 @@ export default function Feed() {
|
|||
options={{
|
||||
headerShown: true,
|
||||
headerBackTitle: "Subscriptions",
|
||||
headerBackground: HeaderBlur,
|
||||
headerBackground: BlurEffect,
|
||||
|
||||
headerTransparent: true,
|
||||
headerTitle: "Feed",
|
||||
|
|
|
|||
|
|
@ -74,7 +74,6 @@
|
|||
"mdast-util-to-markdown": "^2.1.2",
|
||||
"nanoid": "5.0.9",
|
||||
"ofetch": "1.4.1",
|
||||
"path-to-regexp": "8.2.0",
|
||||
"plain-shiki": "0.0.12",
|
||||
"re-resizable": "6.10.1",
|
||||
"react-blurhash": "^0.3.0",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {
|
|||
} from "@follow/components/ui/select/index.jsx"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import {
|
||||
MissingOptionalParamError,
|
||||
parseFullPathParams,
|
||||
parseRegexpPathParams,
|
||||
regexpPathToPath,
|
||||
} from "@follow/utils/path-parser"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { omit } from "es-toolkit/compat"
|
||||
|
|
@ -25,12 +31,6 @@ import { getSidebarActiveView } from "~/atoms/sidebar"
|
|||
import { CopyButton } from "~/components/ui/code-highlighter"
|
||||
import { Markdown } from "~/components/ui/markdown/Markdown"
|
||||
import { useCurrentModal, useIsTopModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import {
|
||||
MissingOptionalParamError,
|
||||
parseFullPathParams,
|
||||
parseRegexpPathParams,
|
||||
regexpPathToPath,
|
||||
} from "~/lib/path-parser"
|
||||
import { getViewFromRoute } from "~/lib/utils"
|
||||
|
||||
import { FeedForm } from "./feed-form"
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export * from "./src/cjk"
|
|||
export * from "./src/color"
|
||||
export * from "./src/dom"
|
||||
export * from "./src/jotai"
|
||||
export * from "./src/path-parser"
|
||||
export * from "./src/url-for-video"
|
||||
export * from "./src/utils"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"clsx": "2.1.1",
|
||||
"framer-motion": "11.13.1",
|
||||
"nanoid": "5.0.9",
|
||||
"path-to-regexp": "8.2.0",
|
||||
"tailwind-merge": "2.5.5",
|
||||
"tldts": "6.1.66",
|
||||
"uniqolor": "1.1.1"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
parseRegexpPathParams,
|
||||
regexpPathToPath,
|
||||
transformUriPath,
|
||||
} from "../path-parser"
|
||||
} from "./path-parser"
|
||||
|
||||
describe("test `transformUriPath()`", () => {
|
||||
test("normal path", () => {
|
||||
|
|
@ -427,12 +427,18 @@ importers:
|
|||
'@follow/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@gorhom/portal':
|
||||
specifier: 1.0.14
|
||||
version: 1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
|
||||
'@hookform/resolvers':
|
||||
specifier: 3.9.1
|
||||
version: 3.9.1(react-hook-form@7.54.0(react@18.3.1))
|
||||
'@react-native-cookies/cookies':
|
||||
specifier: ^6.2.1
|
||||
version: 6.2.1(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))
|
||||
'@react-native-picker/picker':
|
||||
specifier: 2.9.0
|
||||
version: 2.9.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
|
||||
'@react-navigation/bottom-tabs':
|
||||
specifier: ^7.0.0
|
||||
version: 7.2.0(36el4dfrgnbt7wqu67o6leoq5q)
|
||||
|
|
@ -806,9 +812,6 @@ importers:
|
|||
ofetch:
|
||||
specifier: 1.4.1
|
||||
version: 1.4.1
|
||||
path-to-regexp:
|
||||
specifier: 8.2.0
|
||||
version: 8.2.0
|
||||
plain-shiki:
|
||||
specifier: 0.0.12
|
||||
version: 0.0.12(shiki@1.24.1)
|
||||
|
|
@ -1449,6 +1452,9 @@ importers:
|
|||
nanoid:
|
||||
specifier: 5.0.9
|
||||
version: 5.0.9
|
||||
path-to-regexp:
|
||||
specifier: 8.2.0
|
||||
version: 8.2.0
|
||||
tailwind-merge:
|
||||
specifier: 2.5.5
|
||||
version: 2.5.5
|
||||
|
|
@ -3716,6 +3722,12 @@ packages:
|
|||
'@gar/promisify@1.1.3':
|
||||
resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==}
|
||||
|
||||
'@gorhom/portal@1.0.14':
|
||||
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
resolution: {integrity: sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==}
|
||||
engines: {node: ^8.13.0 || >=10.10.0}
|
||||
|
|
@ -4918,6 +4930,12 @@ packages:
|
|||
peerDependencies:
|
||||
react-native: '>= 0.60.2'
|
||||
|
||||
'@react-native-picker/picker@2.9.0':
|
||||
resolution: {integrity: sha512-khEhIW/uhfMqq/+tvg4rEAiPGT8GX+Y6QydlP2TSMSmRHoSJK+ShXvXZXSr4Sii4imkj4BwvLunGywwtQDODqg==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
'@react-native/assets-registry@0.76.5':
|
||||
resolution: {integrity: sha512-MN5dasWo37MirVcKWuysRkRr4BjNc81SXwUtJYstwbn8oEkfnwR9DaqdDTo/hHOnTdhafffLIa2xOOHcjDIGEw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -17868,6 +17886,12 @@ snapshots:
|
|||
|
||||
'@gar/promisify@1.1.3': {}
|
||||
|
||||
'@gorhom/portal@1.0.14(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
nanoid: 3.3.8
|
||||
react: 18.3.1
|
||||
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
|
||||
|
||||
'@grpc/grpc-js@1.9.15':
|
||||
dependencies:
|
||||
'@grpc/proto-loader': 0.7.13
|
||||
|
|
@ -19417,6 +19441,11 @@ snapshots:
|
|||
invariant: 2.2.4
|
||||
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
|
||||
|
||||
'@react-native-picker/picker@2.9.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@react-native-community/cli-server-api@14.1.0(bufferutil@4.0.8))(@types/react@18.3.14)(bufferutil@4.0.8)(encoding@0.1.13)(react@18.3.1)
|
||||
|
||||
'@react-native/assets-registry@0.76.5': {}
|
||||
|
||||
'@react-native/babel-plugin-codegen@0.76.5(@babel/preset-env@7.26.0(@babel/core@7.26.0))':
|
||||
|
|
|
|||
Loading…
Reference in New Issue