feat: update file manager

This commit is contained in:
Khairul Hidayat 2024-04-04 06:40:16 +07:00
parent 1160b30c3d
commit 2ba5da4b73
11 changed files with 199 additions and 36 deletions

View File

@ -0,0 +1,22 @@
import type { Context } from "hono";
import { z } from "zod";
import { getFilePath } from "./utils";
import fs from "fs";
import { HTTPException } from "hono/http-exception";
const schema = z.object({
path: z.string().min(1),
});
export const deleteFile = async (c: Context) => {
const data = schema.parse(await c.req.json());
const { path } = getFilePath(data.path);
if (!fs.existsSync(path)) {
throw new HTTPException(404, { message: "File not found!" });
}
await fs.promises.unlink(path);
return c.json({ result: true });
};

View File

@ -6,6 +6,7 @@ import { download } from "./download";
import { getYtdl, ytdl } from "./ytdl";
import cache from "../../middlewares/cache";
import { getId3Tags, getId3Image } from "./id3Tags";
import { deleteFile } from "./delete";
const cacheFile = cache({ ttl: 86400 });
@ -16,6 +17,7 @@ const route = new Hono()
.get("/ytdl/:id", getYtdl)
.get("/download/*", cacheFile, download)
.get("/id3-tags/*", cacheFile, getId3Tags)
.get("/id3-img/*", cacheFile, getId3Image);
.get("/id3-img/*", cacheFile, getId3Image)
.delete("/delete", deleteFile);
export default route;

View File

@ -5,7 +5,7 @@ import { useAuth } from "@/stores/authStore";
import BackButton from "@ui/BackButton";
import Input from "@ui/Input";
import { Stack } from "expo-router";
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import { useMutation, useQuery } from "react-query";
import FileDrop from "@/components/pages/files/FileDrop";
import { showToast } from "@/stores/toastStore";
@ -28,6 +28,9 @@ const FilesPage = () => {
path: "",
});
const [viewFile, setViewFile] = useState<FileItem | null>(null);
const [isSearching, setSearching] = useState(false);
const [search, setSearch] = useState("");
const parentPath =
params.path.length > 0
? params.path.split("/").slice(0, -1).join("/")
@ -65,6 +68,18 @@ const FilesPage = () => {
}
};
const files = useMemo(() => {
let items = [...(data || [])].map((item, idx) => ({ ...item, idx }));
if (search) {
items = items.filter((item) =>
item.name.toLowerCase().includes(search.toLowerCase())
);
}
return items;
}, [data, search]);
if (!isLoggedIn) {
return null;
}
@ -81,7 +96,34 @@ const FilesPage = () => {
>
<Head title="Files" />
<Stack.Screen
options={{ headerLeft: () => <BackButton />, title: "Files" }}
options={{
headerLeft: () => <BackButton />,
title: "Files",
headerRight: () => (
<Button
variant="icon"
size="lg"
icon={<Ionicons name={!isSearching ? "search" : "close"} />}
onPress={() => {
setSearching(!isSearching);
setSearch("");
}}
/>
),
headerTitleAlign: isSearching ? "center" : undefined,
headerTitle: isSearching
? () => (
<Input
placeholder="Search..."
value={search}
onChangeText={setSearch}
autoFocus
className="lg:w-screen lg:max-w-3xl"
leftElement={<Ionicons name="search" size={18} />}
/>
)
: undefined,
}}
/>
<Container className="flex-1">
@ -106,8 +148,8 @@ const FilesPage = () => {
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
<FileList
files={data}
onSelect={(file, idx) => {
files={files}
onSelect={(file: FileItem & { idx: number }) => {
if (file.isDirectory) {
return setParams({ path: file.path });
}
@ -115,7 +157,7 @@ const FilesPage = () => {
const fileType = getFileType(file.path);
if (fileType === "audio") {
audioPlayer.expand();
return audioPlayer.play(data, idx);
return audioPlayer.play(data, file.idx);
}
setViewFile(file);

View File

@ -1,7 +1,6 @@
import { getFileUrl } from "@/app/apps/lib";
import { fetchAPI } from "@/lib/api";
import { API_BASEURL } from "@/lib/constants";
import { base64encode } from "@/lib/utils";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import authStore from "@/stores/authStore";
import { AVPlaybackStatusSuccess, Audio } from "expo-av";
@ -9,9 +8,10 @@ import { useEffect, useRef } from "react";
import { useStore } from "zustand";
const AudioPlayerProvider = () => {
const { currentIdx, playlist, repeat, status } = useStore(audioPlayerStore);
const { currentIdx, playlist, repeat, status, shouldPlay } =
useStore(audioPlayerStore);
const soundRef = useRef<Audio.Sound | null>(null);
const lastStatusRef = useRef<Date>(new Date());
useEffect(() => {
if (!playlist?.length || currentIdx < 0) {
@ -27,8 +27,16 @@ const AudioPlayerProvider = () => {
soundRef.current = sound;
audioPlayerStore.setState({ sound });
sound.setProgressUpdateIntervalAsync(1000);
sound.setIsLoopingAsync(repeat);
sound.setOnPlaybackStatusUpdate((st: AVPlaybackStatusSuccess) => {
const curDate = new Date();
const diff = curDate.getTime() - lastStatusRef.current.getTime();
if (diff < 1000) {
return;
}
lastStatusRef.current = curDate;
audioPlayerStore.setState({ status: st as any });
if (st.didJustFinish) {
@ -65,17 +73,24 @@ const AudioPlayerProvider = () => {
}
loadMediaTags();
play();
if (shouldPlay) {
play();
}
return () => {
soundRef.current?.unloadAsync();
soundRef.current = null;
const sound = soundRef.current;
if (sound) {
sound.unloadAsync();
sound.setOnPlaybackStatusUpdate(null);
soundRef.current = null;
}
};
}, [currentIdx, playlist]);
}, [currentIdx, playlist, shouldPlay]);
useEffect(() => {
if (status?.isLoaded) {
soundRef.current?.setIsLoopingAsync(repeat);
if (status?.isLoaded && soundRef.current) {
soundRef.current.setIsLoopingAsync(repeat);
}
}, [repeat, status?.isLoaded]);

View File

@ -8,6 +8,11 @@ import ActionSheet from "@ui/ActionSheet";
import { HStack } from "@ui/Stack";
import Button from "@ui/Button";
import { openFile } from "@/app/apps/lib";
import { showDialog } from "@/stores/dialogStore";
import api from "@/lib/api";
import { useMutation } from "react-query";
import { useFilesContext } from "./FilesContext";
import { showToast } from "@/stores/toastStore";
type Store = {
isVisible: boolean;
@ -26,12 +31,30 @@ export const openFileMenu = (file: FileItem) => {
const FileMenu = () => {
const { isVisible, file } = useStore(store);
const onClose = () => store.setState({ isVisible: false });
const { refresh } = useFilesContext();
const deleteMutation = useMutation({
mutationFn: (json: any) => api.files.delete.$delete({ json }),
onSuccess: () => {
refresh();
showToast("File deleted!");
},
});
const onDownload = () => {
openFile(file, true);
onClose();
};
const onDelete = () => {
showDialog(
"Delete file",
"Are you sure you want to delete this file?",
() => deleteMutation.mutate({ path: file?.path })
);
onClose();
};
return (
<ActionSheet isVisible={isVisible} onClose={onClose}>
<Text className="text-lg md:text-xl" numberOfLines={1}>
@ -45,7 +68,9 @@ const FileMenu = () => {
<List.Item icon={<Ionicons name="download" />} onPress={onDownload}>
Download
</List.Item>
<List.Item icon={<Ionicons name="trash" />}>Delete</List.Item>
<List.Item icon={<Ionicons name="trash" />} onPress={onDelete}>
Delete
</List.Item>
</List>
<HStack className="justify-end mt-6 hidden md:flex">

View File

@ -13,7 +13,6 @@ import Input from "@ui/Input";
import { useStore } from "zustand";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import Modal from "react-native-modal";
import { getFileUrl } from "@/app/apps/lib";
import { useQuery } from "react-query";
import { API_BASEURL } from "@/lib/constants";
import authStore from "@/stores/authStore";

View File

@ -6,31 +6,39 @@ import Button from "@ui/Button";
import { Ionicons } from "@ui/Icons";
import { HStack } from "@ui/Stack";
import Text from "@ui/Text";
import React from "react";
import React, { useState } from "react";
import { Pressable } from "react-native";
import { useStore } from "zustand";
const MiniPlayer = () => {
const { status, playlist, currentIdx, mediaTags } =
useStore(audioPlayerStore);
const [minimize, setMinimize] = useState(true);
const current = playlist[currentIdx];
const filename = getFilename(current?.path);
if (!status?.isLoaded) {
return null;
const onExpand = () => audioPlayerStore.setState({ expanded: true });
if (minimize) {
return (
<Button
icon={<Ionicons name="musical-notes" />}
className="absolute bottom-4 right-4 rounded-full"
size="icon-lg"
onPress={() => {
setMinimize(false);
onExpand();
}}
/>
);
}
return (
<>
<Box className="w-full h-20 flex md:hidden" />
<HStack className="absolute bottom-0 right-0 md:bottom-4 md:right-4 bg-white md:rounded-lg shadow-lg w-full max-w-sm">
<Pressable
style={cn("flex-1 p-4")}
onPress={() => audioPlayerStore.setState({ expanded: true })}
>
<Text numberOfLines={1}>
{mediaTags?.tags?.title || filename || "..."}
</Text>
<Pressable style={cn("flex-1 p-4")} onPress={onExpand}>
<Text numberOfLines={1}>{mediaTags?.title || filename || "..."}</Text>
<Slider
minimumValue={0}
maximumValue={100}
@ -64,6 +72,13 @@ const MiniPlayer = () => {
icon={<Ionicons name="play-forward" />}
onPress={audioPlayer.next}
/>
<Button
variant="ghost"
size="icon"
icon={<Ionicons name="chevron-down" />}
iconClassName="text-xl"
onPress={() => setMinimize(true)}
/>
</HStack>
</HStack>
</>

View File

@ -15,12 +15,14 @@ const buttonVariants = cva(
ghost: "",
link: "text-primary underline-offset-4",
outline: "border border-primary",
icon: "",
},
size: {
default: "h-10 px-4",
sm: "h-8 px-2",
lg: "h-12 px-8",
icon: "h-10 w-10 px-0",
"icon-lg": "h-14 w-14 px-0",
},
},
defaultVariants: {
@ -39,12 +41,14 @@ const buttonTextVariants = cva("text-center font-medium", {
ghost: "text-primary",
link: "text-primary-foreground underline",
outline: "text-primary",
icon: "text-secondary-foreground",
},
size: {
default: "text-base",
sm: "text-sm",
lg: "text-xl",
icon: "text-base",
"icon-lg": "text-lg",
},
},
defaultVariants: {

View File

@ -10,6 +10,7 @@ type BaseInputProps = ComponentPropsWithClassName<typeof TextInput> & {
label?: string;
inputClassName?: string;
error?: string;
leftElement?: React.ReactNode;
};
type InputProps<T extends FieldValues> = BaseInputProps & {
@ -22,20 +23,30 @@ const BaseInput = ({
inputClassName,
label,
error,
leftElement,
...props
}: BaseInputProps) => {
return (
<Box className={className}>
{label ? <Text className="text-sm mb-1">{label}</Text> : null}
<TextInput
style={cn(
"border border-gray-300 rounded-lg px-3 h-10 w-full",
inputClassName
)}
placeholderTextColor="#787878"
{...props}
/>
<Box className="relative w-full">
{leftElement ? (
<Box className="absolute left-0 top-0 h-full aspect-square flex items-center justify-center">
{leftElement}
</Box>
) : null}
<TextInput
style={cn(
"border border-gray-300 rounded-lg px-3 h-10 w-full",
leftElement ? "pl-10" : "",
inputClassName
)}
placeholderTextColor="#787878"
{...props}
/>
</Box>
{error ? (
<Text className="text-red-500 text-sm mt-1">{error}</Text>

View File

@ -0,0 +1,17 @@
import { useState } from "react";
export const useDisclosure = () => {
const [isOpen, setIsOpen] = useState(false);
const [data, setData] = useState<any>();
const open = (data?: any) => {
setIsOpen(true);
setData(data);
};
const close = () => {
setIsOpen(false);
};
return { isOpen, open, close, data };
};

View File

@ -19,6 +19,7 @@ type AudioPlayerStore = {
status: AVPlaybackStatusSuccess | null;
mediaTags: MediaTags | null;
expanded: boolean;
shouldPlay: boolean;
};
export const audioPlayerStore = createStore(
@ -32,6 +33,7 @@ export const audioPlayerStore = createStore(
status: null,
mediaTags: null,
expanded: false,
shouldPlay: false,
}),
{
name: "audioPlayer",
@ -42,6 +44,7 @@ export const audioPlayerStore = createStore(
status: null,
mediaTags: null,
expanded: false,
shouldPlay: false,
}),
}
)
@ -58,6 +61,7 @@ const play = (files: FileItem[], idx: number) => {
currentIdx,
status: null,
mediaTags: null,
shouldPlay: true,
});
};
@ -72,7 +76,14 @@ const advanceBy = (increment: number) => {
};
const togglePlay = async () => {
const { sound, status } = audioPlayerStore.getState();
const { sound, status, shouldPlay } = audioPlayerStore.getState();
if (!shouldPlay || !sound || !status?.isPlaying) {
console.log("shoud play toggle");
audioPlayerStore.setState({ shouldPlay: true });
return;
}
if (!sound) {
return;
}