feat: add file operation

This commit is contained in:
Khairul Hidayat 2024-03-17 02:17:08 +07:00
parent b7c1ceb2b3
commit a371a9e569
11 changed files with 408 additions and 50 deletions

View File

@ -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;

View File

@ -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>
</>
);
};

View File

@ -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);

View File

@ -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);

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

5
src/types/files.ts Normal file
View File

@ -0,0 +1,5 @@
export type FileItem = {
name: string;
path: string;
isDirectory: boolean;
};