mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 08:39:34 +07:00
feat: add file operation
This commit is contained in:
parent
b7c1ceb2b3
commit
a371a9e569
@ -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;
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
|
97
src/components/pages/files/FileDrop.tsx
Normal file
97
src/components/pages/files/FileDrop.tsx
Normal 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);
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
52
src/components/pages/files/FileMenu.tsx
Normal file
52
src/components/pages/files/FileMenu.tsx
Normal 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;
|
26
src/components/ui/ActionSheet.tsx
Normal file
26
src/components/ui/ActionSheet.tsx
Normal 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;
|
@ -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;
|
||||
|
38
src/components/ui/List.tsx
Normal file
38
src/components/ui/List.tsx
Normal 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;
|
12
src/components/ui/Pressable.tsx
Normal file
12
src/components/ui/Pressable.tsx
Normal 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
5
src/types/files.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export type FileItem = {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user