mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-29 17:19:36 +07:00
feat: add file operation
This commit is contained in:
parent
b7c1ceb2b3
commit
a371a9e569
@ -13,6 +13,11 @@ const getFilesSchema = z
|
|||||||
.partial()
|
.partial()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
|
const uploadSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
size: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
const filesDirList = process.env.FILE_DIRS
|
const filesDirList = process.env.FILE_DIRS
|
||||||
? process.env.FILE_DIRS.split(";").map((i) => ({
|
? process.env.FILE_DIRS.split(";").map((i) => ({
|
||||||
name: i.split("/").at(-1),
|
name: i.split("/").at(-1),
|
||||||
@ -68,6 +73,50 @@ const route = new Hono()
|
|||||||
|
|
||||||
return c.json([]);
|
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) => {
|
.get("/download/*", async (c) => {
|
||||||
const dlFile = c.req.query("dl") === "true";
|
const dlFile = c.req.query("dl") === "true";
|
||||||
const url = new URL(c.req.url, `http://${c.req.header("host")}`);
|
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;
|
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 { useAsyncStorage } from "@/hooks/useAsyncStorage";
|
||||||
import api from "@/lib/api";
|
import api from "@/lib/api";
|
||||||
import { useAuth } from "@/stores/authStore";
|
import { useAuth } from "@/stores/authStore";
|
||||||
import BackButton from "@ui/BackButton";
|
import BackButton from "@ui/BackButton";
|
||||||
import Box from "@ui/Box";
|
|
||||||
import Input from "@ui/Input";
|
import Input from "@ui/Input";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { useQuery } from "react-query";
|
import { useMutation, useQuery } from "react-query";
|
||||||
import { openFile } from "./utils";
|
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 FilesPage = () => {
|
||||||
const { isLoggedIn } = useAuth();
|
const { isLoggedIn } = useAuth();
|
||||||
@ -20,40 +24,72 @@ const FilesPage = () => {
|
|||||||
? params.path.split("/").slice(0, -1).join("/")
|
? params.path.split("/").slice(0, -1).join("/")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data, refetch } = useQuery({
|
||||||
queryKey: ["app/files", params],
|
queryKey: ["app/files", params],
|
||||||
queryFn: () => api.files.$get({ query: params }).then((r) => r.json()),
|
queryFn: () => api.files.$get({ query: params }).then((r) => r.json()),
|
||||||
enabled: isLoggedIn,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
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
|
<Input
|
||||||
placeholder="/"
|
placeholder="/"
|
||||||
value={params.path}
|
value={params.path}
|
||||||
onChangeText={(path) => setParams({ path })}
|
onChangeText={(path) => setParams({ path })}
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</HStack>
|
||||||
|
|
||||||
<FileList
|
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
|
||||||
files={data}
|
<FileList
|
||||||
onSelect={(file) => {
|
files={data}
|
||||||
if (file.path === "..") {
|
onSelect={(file) => {
|
||||||
return setParams({ ...params, path: parentPath });
|
if (file.isDirectory) {
|
||||||
}
|
return setParams({ ...params, path: file.path });
|
||||||
if (file.isDirectory) {
|
}
|
||||||
return setParams({ ...params, path: file.path });
|
openFile(file);
|
||||||
}
|
}}
|
||||||
|
/>
|
||||||
openFile(file);
|
</FileDrop>
|
||||||
}}
|
|
||||||
canGoBack={parentPath != null}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { FileItem } from "@/components/pages/files/FileList";
|
|
||||||
import { API_BASEURL } from "@/lib/constants";
|
import { API_BASEURL } from "@/lib/constants";
|
||||||
import authStore from "@/stores/authStore";
|
import authStore from "@/stores/authStore";
|
||||||
|
import { FileItem } from "@/types/files";
|
||||||
|
|
||||||
export function openFile(file: FileItem, dl = false) {
|
export function openFile(file: FileItem, dl = false) {
|
||||||
const url = getFileUrl(file, dl);
|
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 { FlatList } from "react-native";
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import Text from "@ui/Text";
|
import Text from "@ui/Text";
|
||||||
import { HStack } from "@ui/Stack";
|
import { HStack } from "@ui/Stack";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Ionicons } from "@ui/Icons";
|
import { Ionicons } from "@ui/Icons";
|
||||||
|
import Button from "@ui/Button";
|
||||||
export type FileItem = {
|
import Pressable from "@ui/Pressable";
|
||||||
name: string;
|
import { FileItem } from "@/types/files";
|
||||||
path: string;
|
import FileMenu, { openFileMenu } from "./FileMenu";
|
||||||
isDirectory: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileListProps = {
|
type FileListProps = {
|
||||||
files?: FileItem[];
|
files?: FileItem[];
|
||||||
onSelect?: (file: FileItem) => void;
|
onSelect?: (file: FileItem) => void;
|
||||||
canGoBack?: boolean;
|
// onMenu?: (file: FileItem) => void;
|
||||||
|
onLongPress?: (file: FileItem) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileList = ({ files, onSelect, canGoBack }: FileListProps) => {
|
const FileList = ({ files, onSelect, onLongPress }: FileListProps) => {
|
||||||
const fileList = useMemo(() => {
|
|
||||||
if (canGoBack) {
|
|
||||||
return [{ name: "..", path: "..", isDirectory: true }, ...(files || [])];
|
|
||||||
}
|
|
||||||
return files || [];
|
|
||||||
}, [files, canGoBack]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<>
|
||||||
contentContainerStyle={cn("bg-white")}
|
<FlatList
|
||||||
data={fileList || []}
|
style={cn("flex-1")}
|
||||||
renderItem={({ item }) => (
|
contentContainerStyle={cn("bg-white")}
|
||||||
<FileItem file={item} onPress={() => onSelect?.(item)} />
|
data={files || []}
|
||||||
)}
|
renderItem={({ item }) => (
|
||||||
keyExtractor={(item) => item.path}
|
<FileItemList
|
||||||
/>
|
file={item}
|
||||||
|
onPress={() => onSelect?.(item)}
|
||||||
|
onLongPress={() => onLongPress?.(item)}
|
||||||
|
onMenuPress={() => openFileMenu(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
keyExtractor={(item) => item.path}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileMenu />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileItem = ({
|
const FileItemList = ({
|
||||||
file,
|
file,
|
||||||
onPress,
|
onPress,
|
||||||
|
onLongPress,
|
||||||
|
onMenuPress,
|
||||||
}: {
|
}: {
|
||||||
file: FileItem;
|
file: FileItem;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
|
onLongPress?: () => void;
|
||||||
|
onMenuPress?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<HStack className="bg-white border-b border-gray-200 items-center">
|
<HStack className="bg-white border-b border-gray-200 items-center">
|
||||||
@ -54,6 +60,13 @@ const FileItem = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
|
onLongPress={onLongPress}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
if (onMenuPress) {
|
||||||
|
e.preventDefault();
|
||||||
|
onMenuPress();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={file.isDirectory ? "folder" : "document"}
|
name={file.isDirectory ? "folder" : "document"}
|
||||||
@ -64,6 +77,12 @@ const FileItem = ({
|
|||||||
/>
|
/>
|
||||||
<Text numberOfLines={1}>{file.name}</Text>
|
<Text numberOfLines={1}>{file.name}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
<Button
|
||||||
|
icon={<Ionicons name="ellipsis-vertical" />}
|
||||||
|
variant="ghost"
|
||||||
|
className="h-full px-4"
|
||||||
|
onPress={onMenuPress}
|
||||||
|
/>
|
||||||
</HStack>
|
</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 { cn } from "@/lib/utils";
|
||||||
import { ComponentPropsWithClassName } from "@/types/components";
|
import { ComponentPropsWithClassName } from "@/types/components";
|
||||||
|
import { forwardRef } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
|
||||||
type Props = ComponentPropsWithClassName<typeof View>;
|
type Props = ComponentPropsWithClassName<typeof View>;
|
||||||
|
|
||||||
const Box = ({ className, style, ...props }: Props) => {
|
const Box = forwardRef(({ className, style, ...props }: Props, ref: any) => {
|
||||||
return (
|
return (
|
||||||
<View style={{ ...cn(className), ...((style || {}) as any) }} {...props} />
|
<View
|
||||||
|
ref={ref}
|
||||||
|
style={{ ...cn(className), ...((style || {}) as any) }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default Box;
|
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