diff --git a/backend/routes/files.ts b/backend/routes/files.ts index 6d48c78..25df8b7 100644 --- a/backend/routes/files.ts +++ b/backend/routes/files.ts @@ -13,6 +13,11 @@ const getFilesSchema = z .partial() .optional(); +const uploadSchema = z.object({ + path: z.string().min(1), + size: z.string().min(1), +}); + const filesDirList = process.env.FILE_DIRS ? process.env.FILE_DIRS.split(";").map((i) => ({ name: i.split("/").at(-1), @@ -68,6 +73,50 @@ const route = new Hono() return c.json([]); }) + .post("/upload", async (c) => { + const input: any = (await c.req.parseBody()) as never; + const data = await uploadSchema.parseAsync(input); + + const size = parseInt(input.size); + if (Number.isNaN(size) || !size) { + throw new HTTPException(400, { message: "Size is empty!" }); + } + + const files: File[] = [...Array(size)] + .map((_, idx) => input[`files.${idx}`]) + .filter((i) => !!i); + + if (!files.length) { + throw new HTTPException(400, { message: "Files is empty!" }); + } + + const pathSlices = data.path.split("/"); + const baseName = pathSlices[1] || null; + const path = pathSlices.slice(2).join("/"); + const baseDir = filesDirList.find((i) => i.name === baseName)?.path; + if (!baseDir?.length) { + throw new HTTPException(400, { message: "Path not found!" }); + } + + const targetDir = [baseDir, path].join("/"); + + // files.forEach((file) => { + // const filepath = targetDir + "/" + file.name; + // if (existsSync(filepath)) { + // throw new HTTPException(400, { message: "File already exists!" }); + // } + // }); + + await Promise.all( + files.map(async (file) => { + const filepath = targetDir + "/" + file.name; + const buffer = await file.arrayBuffer(); + await fs.writeFile(filepath, new Uint8Array(buffer)); + }) + ); + + return c.json({ success: true }); + }) .get("/download/*", async (c) => { const dlFile = c.req.query("dl") === "true"; const url = new URL(c.req.url, `http://${c.req.header("host")}`); @@ -132,4 +181,23 @@ const route = new Hono() } }); +function getFilePath(path: string) { + const pathSlices = path.split("/"); + const baseName = pathSlices[1] || null; + const filePath = pathSlices.slice(2).join("/"); + + const baseDir = filesDirList.find((i) => i.name === baseName)?.path; + if (!baseDir?.length) { + throw new HTTPException(400, { message: "Path not found!" }); + } + + return { + path: [baseDir, filePath].join("/"), + pathname: ["", baseName, filePath].join("/"), + baseName, + baseDir, + filePath, + }; +} + export default route; diff --git a/src/app/apps/files/index.tsx b/src/app/apps/files/index.tsx index 5c821b7..5ce94c1 100644 --- a/src/app/apps/files/index.tsx +++ b/src/app/apps/files/index.tsx @@ -1,14 +1,18 @@ -import FileList, { FileItem } from "@/components/pages/files/FileList"; +import FileList from "@/components/pages/files/FileList"; import { useAsyncStorage } from "@/hooks/useAsyncStorage"; import api from "@/lib/api"; import { useAuth } from "@/stores/authStore"; import BackButton from "@ui/BackButton"; -import Box from "@ui/Box"; import Input from "@ui/Input"; import { Stack } from "expo-router"; import React from "react"; -import { useQuery } from "react-query"; +import { useMutation, useQuery } from "react-query"; import { openFile } from "./utils"; +import FileDrop from "@/components/pages/files/FileDrop"; +import { showToast } from "@/stores/toastStore"; +import { HStack } from "@ui/Stack"; +import Button from "@ui/Button"; +import { Ionicons } from "@ui/Icons"; const FilesPage = () => { const { isLoggedIn } = useAuth(); @@ -20,40 +24,72 @@ const FilesPage = () => { ? params.path.split("/").slice(0, -1).join("/") : null; - const { data } = useQuery({ + const { data, refetch } = useQuery({ queryKey: ["app/files", params], queryFn: () => api.files.$get({ query: params }).then((r) => r.json()), enabled: isLoggedIn, }); + const upload = useMutation({ + mutationFn: async (files: File[]) => { + const form: any = { + path: params.path, + size: files.length, + }; + + files.forEach((file, idx) => { + form[`files.${idx}`] = file; + }); + + const res = await api.files.upload.$post({ form }); + return res.json(); + }, + onSuccess: () => { + showToast("Upload success!"); + refetch(); + }, + }); + + const onFileDrop = (files: File[]) => { + if (!upload.isLoading) { + upload.mutate(files); + } + }; + return ( <> <Stack.Screen options={{ headerLeft: () => <BackButton />, title: "Files" }} /> - <Box className="px-2 py-2 bg-white"> + <HStack className="px-2 py-2 bg-white gap-2"> + <Button + icon={<Ionicons name="chevron-back" />} + disabled={parentPath == null} + className="px-3 border-gray-300" + labelClasses="text-gray-500" + variant="outline" + onPress={() => setParams({ ...params, path: parentPath })} + /> <Input placeholder="/" value={params.path} onChangeText={(path) => setParams({ path })} + className="flex-1" /> - </Box> + </HStack> - <FileList - files={data} - onSelect={(file) => { - if (file.path === "..") { - return setParams({ ...params, path: parentPath }); - } - if (file.isDirectory) { - return setParams({ ...params, path: file.path }); - } - - openFile(file); - }} - canGoBack={parentPath != null} - /> + <FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}> + <FileList + files={data} + onSelect={(file) => { + if (file.isDirectory) { + return setParams({ ...params, path: file.path }); + } + openFile(file); + }} + /> + </FileDrop> </> ); }; diff --git a/src/app/apps/files/utils.ts b/src/app/apps/files/utils.ts index 05ce887..cc98fea 100644 --- a/src/app/apps/files/utils.ts +++ b/src/app/apps/files/utils.ts @@ -1,6 +1,6 @@ -import { FileItem } from "@/components/pages/files/FileList"; import { API_BASEURL } from "@/lib/constants"; import authStore from "@/stores/authStore"; +import { FileItem } from "@/types/files"; export function openFile(file: FileItem, dl = false) { const url = getFileUrl(file, dl); diff --git a/src/components/pages/files/FileDrop.tsx b/src/components/pages/files/FileDrop.tsx new file mode 100644 index 0000000..01554d9 --- /dev/null +++ b/src/components/pages/files/FileDrop.tsx @@ -0,0 +1,97 @@ +// @ts-ignore +import { cn } from "@/lib/utils"; +import Box from "@ui/Box"; +import { Ionicons } from "@ui/Icons"; +import Text from "@ui/Text"; +import React, { useRef, useState } from "react"; +import { Platform } from "react-native"; + +type Props = { + children: React.ReactNode; + isDisabled?: boolean; + onFileDrop?: (files: File[]) => void; + onDrop?: React.DragEventHandler<HTMLDivElement>; + onDragOver?: React.DragEventHandler<HTMLDivElement>; + onDragLeave?: React.DragEventHandler<HTMLDivElement>; + className?: string; +}; +const isWeb = Platform.OS === "web"; + +const FileDrop = ({ className, children, isDisabled, ...props }: Props) => { + const dragContainerRef = useRef<any>(null); + const overlayRef = useRef<any>(null); + const [isDragging, setDragging] = useState(false); + + if (!isWeb) { + return children; + } + + return ( + <div + style={cn("flex-1 relative flex overflow-hidden")} + ref={dragContainerRef} + onDrop={(e) => { + e.preventDefault(); + if (!isDragging || isDisabled) { + return; + } + + setDragging(false); + props.onDrop && props.onDrop(e); + + if (props.onFileDrop) { + const files = Array.from(e.dataTransfer.items) + .filter((i) => i.kind === "file") + .map((i) => i.getAsFile()); + props.onFileDrop(files); + } + }} + onDragOver={(e) => { + e.preventDefault(); + if (isDragging || isDisabled) { + return; + } + + // ignore if not a file + if ( + !e.dataTransfer.items || + !e.dataTransfer.items.length || + e.dataTransfer.items[0].kind !== "file" + ) { + return; + } + + setDragging(true); + props.onDragOver && props.onDragOver(e); + }} + onDragLeave={(e) => { + e.preventDefault(); + if (!isDragging || e.target !== overlayRef.current) { + return; + } + + setDragging(false); + props.onDragLeave && props.onDragLeave(e); + }} + > + {children} + + {isDragging && ( + <Box + ref={overlayRef} + className="flex flex-col items-center justify-center absolute top-0 left-0 w-full h-full bg-black/10 z-10" + > + <Box + className="bg-white p-8 rounded-xl flex flex-col items-center gap-2" + style={{ pointerEvents: "none" }} + > + <Ionicons name="cloud-upload" style={{ fontSize: 48 }} /> + <Text className="text-primary">Drop files here</Text> + </Box> + </Box> + )} + </div> + ); +}; + +export default React.memo(FileDrop); diff --git a/src/components/pages/files/FileList.tsx b/src/components/pages/files/FileList.tsx index b1aca6c..7c01656 100644 --- a/src/components/pages/files/FileList.tsx +++ b/src/components/pages/files/FileList.tsx @@ -1,48 +1,54 @@ -import { FlatList, Pressable } from "react-native"; -import React, { useMemo } from "react"; +import { FlatList } from "react-native"; +import React from "react"; import Text from "@ui/Text"; import { HStack } from "@ui/Stack"; import { cn } from "@/lib/utils"; import { Ionicons } from "@ui/Icons"; - -export type FileItem = { - name: string; - path: string; - isDirectory: boolean; -}; +import Button from "@ui/Button"; +import Pressable from "@ui/Pressable"; +import { FileItem } from "@/types/files"; +import FileMenu, { openFileMenu } from "./FileMenu"; type FileListProps = { files?: FileItem[]; onSelect?: (file: FileItem) => void; - canGoBack?: boolean; + // onMenu?: (file: FileItem) => void; + onLongPress?: (file: FileItem) => void; }; -const FileList = ({ files, onSelect, canGoBack }: FileListProps) => { - const fileList = useMemo(() => { - if (canGoBack) { - return [{ name: "..", path: "..", isDirectory: true }, ...(files || [])]; - } - return files || []; - }, [files, canGoBack]); - +const FileList = ({ files, onSelect, onLongPress }: FileListProps) => { return ( - <FlatList - contentContainerStyle={cn("bg-white")} - data={fileList || []} - renderItem={({ item }) => ( - <FileItem file={item} onPress={() => onSelect?.(item)} /> - )} - keyExtractor={(item) => item.path} - /> + <> + <FlatList + style={cn("flex-1")} + contentContainerStyle={cn("bg-white")} + data={files || []} + renderItem={({ item }) => ( + <FileItemList + file={item} + onPress={() => onSelect?.(item)} + onLongPress={() => onLongPress?.(item)} + onMenuPress={() => openFileMenu(item)} + /> + )} + keyExtractor={(item) => item.path} + /> + + <FileMenu /> + </> ); }; -const FileItem = ({ +const FileItemList = ({ file, onPress, + onLongPress, + onMenuPress, }: { file: FileItem; onPress?: () => void; + onLongPress?: () => void; + onMenuPress?: () => void; }) => { return ( <HStack className="bg-white border-b border-gray-200 items-center"> @@ -54,6 +60,13 @@ const FileItem = ({ ) } onPress={onPress} + onLongPress={onLongPress} + onContextMenu={(e) => { + if (onMenuPress) { + e.preventDefault(); + onMenuPress(); + } + }} > <Ionicons name={file.isDirectory ? "folder" : "document"} @@ -64,6 +77,12 @@ const FileItem = ({ /> <Text numberOfLines={1}>{file.name}</Text> </Pressable> + <Button + icon={<Ionicons name="ellipsis-vertical" />} + variant="ghost" + className="h-full px-4" + onPress={onMenuPress} + /> </HStack> ); }; diff --git a/src/components/pages/files/FileMenu.tsx b/src/components/pages/files/FileMenu.tsx new file mode 100644 index 0000000..95b66b8 --- /dev/null +++ b/src/components/pages/files/FileMenu.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { createStore, useStore } from "zustand"; +import { FileItem } from "@/types/files"; +import Text from "@ui/Text"; +import List from "@ui/List"; +import { Ionicons } from "@ui/Icons"; +import { cn } from "@/lib/utils"; +import ActionSheet from "@ui/ActionSheet"; +import { HStack } from "@ui/Stack"; +import Button from "@ui/Button"; + +type Store = { + isVisible: boolean; + file?: FileItem | null; +}; + +const store = createStore<Store>(() => ({ + isVisible: false, + file: null, +})); + +export const openFileMenu = (file: FileItem) => { + store.setState({ isVisible: true, file }); +}; + +const FileMenu = () => { + const { isVisible, file } = useStore(store); + const onClose = () => store.setState({ isVisible: false }); + + return ( + <ActionSheet isVisible={isVisible} onClose={onClose}> + <Text className="text-lg md:text-xl" numberOfLines={1}> + {file?.name} + </Text> + + <List className="mt-4"> + <List.Item icon={<Ionicons name="pencil" />}>Rename</List.Item> + <List.Item icon={<Ionicons name="copy" />}>Copy</List.Item> + <List.Item icon={<Ionicons name="cut-outline" />}>Move</List.Item> + <List.Item icon={<Ionicons name="trash" />}>Delete</List.Item> + </List> + + <HStack className="justify-end mt-6"> + <Button variant="ghost" onPress={onClose}> + Cancel + </Button> + </HStack> + </ActionSheet> + ); +}; + +export default FileMenu; diff --git a/src/components/ui/ActionSheet.tsx b/src/components/ui/ActionSheet.tsx new file mode 100644 index 0000000..edb2e5b --- /dev/null +++ b/src/components/ui/ActionSheet.tsx @@ -0,0 +1,26 @@ +import React, { ComponentProps } from "react"; +import Modal from "react-native-modal"; +import { cn } from "@/lib/utils"; +import Container from "./Container"; + +type ActionSheetProps = Partial<ComponentProps<typeof Modal>> & { + onClose?: () => void; +}; + +const ActionSheet = ({ onClose, children, ...props }: ActionSheetProps) => { + return ( + <Modal + style={cn("justify-end md:justify-center m-0 md:m-4")} + onBackButtonPress={onClose} + onBackdropPress={onClose} + backdropOpacity={0.3} + {...props} + > + <Container className="bg-white p-4 md:p-8 rounded-t-xl md:rounded-xl"> + {children} + </Container> + </Modal> + ); +}; + +export default ActionSheet; diff --git a/src/components/ui/Box.tsx b/src/components/ui/Box.tsx index 5524e56..d254c83 100644 --- a/src/components/ui/Box.tsx +++ b/src/components/ui/Box.tsx @@ -1,13 +1,18 @@ import { cn } from "@/lib/utils"; import { ComponentPropsWithClassName } from "@/types/components"; +import { forwardRef } from "react"; import { View } from "react-native"; type Props = ComponentPropsWithClassName<typeof View>; -const Box = ({ className, style, ...props }: Props) => { +const Box = forwardRef(({ className, style, ...props }: Props, ref: any) => { return ( - <View style={{ ...cn(className), ...((style || {}) as any) }} {...props} /> + <View + ref={ref} + style={{ ...cn(className), ...((style || {}) as any) }} + {...props} + /> ); -}; +}); export default Box; diff --git a/src/components/ui/List.tsx b/src/components/ui/List.tsx new file mode 100644 index 0000000..cd65c9b --- /dev/null +++ b/src/components/ui/List.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { HStack, VStack } from "./Stack"; +import { cn } from "@/lib/utils"; +import Pressable from "./Pressable"; +import Text from "./Text"; +import Slot from "./Slot"; + +type Props = { + className?: any; + children: React.ReactNode; +}; + +const List = ({ className, children }: Props) => { + return <VStack className={cn(className)}>{children}</VStack>; +}; + +type ListItemProps = { + className?: any; + children: React.ReactNode; + icon?: React.ReactNode; +}; + +const ListItem = ({ className, icon, children }: ListItemProps) => { + return ( + <Pressable style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}> + <HStack className={cn("py-2 border-b border-gray-200", className)}> + {icon ? ( + <Slot.Text style={cn("text-gray-800 text-xl w-8")}>{icon}</Slot.Text> + ) : null} + + <Text>{children}</Text> + </HStack> + </Pressable> + ); +}; +List.Item = ListItem; + +export default List; diff --git a/src/components/ui/Pressable.tsx b/src/components/ui/Pressable.tsx new file mode 100644 index 0000000..a599aaa --- /dev/null +++ b/src/components/ui/Pressable.tsx @@ -0,0 +1,12 @@ +import { ComponentProps, forwardRef } from "react"; +import { Pressable as BasePressable } from "react-native"; + +type Props = ComponentProps<typeof BasePressable> & { + onContextMenu?: (event: PointerEvent) => void; +}; + +const Pressable = forwardRef((props: Props, ref: any) => { + return <BasePressable ref={ref} {...props} />; +}); + +export default Pressable; diff --git a/src/types/files.ts b/src/types/files.ts new file mode 100644 index 0000000..fbdeb23 --- /dev/null +++ b/src/types/files.ts @@ -0,0 +1,5 @@ +export type FileItem = { + name: string; + path: string; + isDirectory: boolean; +};