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

View File

@ -5,7 +5,7 @@ import { useAuth } from "@/stores/authStore";
import BackButton from "@ui/BackButton"; import BackButton from "@ui/BackButton";
import Input from "@ui/Input"; import Input from "@ui/Input";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { useMutation, useQuery } from "react-query"; import { useMutation, useQuery } from "react-query";
import FileDrop from "@/components/pages/files/FileDrop"; import FileDrop from "@/components/pages/files/FileDrop";
import { showToast } from "@/stores/toastStore"; import { showToast } from "@/stores/toastStore";
@ -28,6 +28,9 @@ const FilesPage = () => {
path: "", path: "",
}); });
const [viewFile, setViewFile] = useState<FileItem | null>(null); const [viewFile, setViewFile] = useState<FileItem | null>(null);
const [isSearching, setSearching] = useState(false);
const [search, setSearch] = useState("");
const parentPath = const parentPath =
params.path.length > 0 params.path.length > 0
? params.path.split("/").slice(0, -1).join("/") ? 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) { if (!isLoggedIn) {
return null; return null;
} }
@ -81,7 +96,34 @@ const FilesPage = () => {
> >
<Head title="Files" /> <Head title="Files" />
<Stack.Screen <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"> <Container className="flex-1">
@ -106,8 +148,8 @@ const FilesPage = () => {
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}> <FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
<FileList <FileList
files={data} files={files}
onSelect={(file, idx) => { onSelect={(file: FileItem & { idx: number }) => {
if (file.isDirectory) { if (file.isDirectory) {
return setParams({ path: file.path }); return setParams({ path: file.path });
} }
@ -115,7 +157,7 @@ const FilesPage = () => {
const fileType = getFileType(file.path); const fileType = getFileType(file.path);
if (fileType === "audio") { if (fileType === "audio") {
audioPlayer.expand(); audioPlayer.expand();
return audioPlayer.play(data, idx); return audioPlayer.play(data, file.idx);
} }
setViewFile(file); setViewFile(file);

View File

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

View File

@ -8,6 +8,11 @@ import ActionSheet from "@ui/ActionSheet";
import { HStack } from "@ui/Stack"; import { HStack } from "@ui/Stack";
import Button from "@ui/Button"; import Button from "@ui/Button";
import { openFile } from "@/app/apps/lib"; 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 = { type Store = {
isVisible: boolean; isVisible: boolean;
@ -26,12 +31,30 @@ export const openFileMenu = (file: FileItem) => {
const FileMenu = () => { const FileMenu = () => {
const { isVisible, file } = useStore(store); const { isVisible, file } = useStore(store);
const onClose = () => store.setState({ isVisible: false }); 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 = () => { const onDownload = () => {
openFile(file, true); openFile(file, true);
onClose(); onClose();
}; };
const onDelete = () => {
showDialog(
"Delete file",
"Are you sure you want to delete this file?",
() => deleteMutation.mutate({ path: file?.path })
);
onClose();
};
return ( return (
<ActionSheet isVisible={isVisible} onClose={onClose}> <ActionSheet isVisible={isVisible} onClose={onClose}>
<Text className="text-lg md:text-xl" numberOfLines={1}> <Text className="text-lg md:text-xl" numberOfLines={1}>
@ -45,7 +68,9 @@ const FileMenu = () => {
<List.Item icon={<Ionicons name="download" />} onPress={onDownload}> <List.Item icon={<Ionicons name="download" />} onPress={onDownload}>
Download Download
</List.Item> </List.Item>
<List.Item icon={<Ionicons name="trash" />}>Delete</List.Item> <List.Item icon={<Ionicons name="trash" />} onPress={onDelete}>
Delete
</List.Item>
</List> </List>
<HStack className="justify-end mt-6 hidden md:flex"> <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 { useStore } from "zustand";
import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore"; import { audioPlayer, audioPlayerStore } from "@/stores/audioPlayerStore";
import Modal from "react-native-modal"; import Modal from "react-native-modal";
import { getFileUrl } from "@/app/apps/lib";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
import { API_BASEURL } from "@/lib/constants"; import { API_BASEURL } from "@/lib/constants";
import authStore from "@/stores/authStore"; import authStore from "@/stores/authStore";

View File

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

View File

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

View File

@ -10,6 +10,7 @@ type BaseInputProps = ComponentPropsWithClassName<typeof TextInput> & {
label?: string; label?: string;
inputClassName?: string; inputClassName?: string;
error?: string; error?: string;
leftElement?: React.ReactNode;
}; };
type InputProps<T extends FieldValues> = BaseInputProps & { type InputProps<T extends FieldValues> = BaseInputProps & {
@ -22,20 +23,30 @@ const BaseInput = ({
inputClassName, inputClassName,
label, label,
error, error,
leftElement,
...props ...props
}: BaseInputProps) => { }: BaseInputProps) => {
return ( return (
<Box className={className}> <Box className={className}>
{label ? <Text className="text-sm mb-1">{label}</Text> : null} {label ? <Text className="text-sm mb-1">{label}</Text> : null}
<TextInput <Box className="relative w-full">
style={cn( {leftElement ? (
"border border-gray-300 rounded-lg px-3 h-10 w-full", <Box className="absolute left-0 top-0 h-full aspect-square flex items-center justify-center">
inputClassName {leftElement}
)} </Box>
placeholderTextColor="#787878" ) : null}
{...props}
/> <TextInput
style={cn(
"border border-gray-300 rounded-lg px-3 h-10 w-full",
leftElement ? "pl-10" : "",
inputClassName
)}
placeholderTextColor="#787878"
{...props}
/>
</Box>
{error ? ( {error ? (
<Text className="text-red-500 text-sm mt-1">{error}</Text> <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; status: AVPlaybackStatusSuccess | null;
mediaTags: MediaTags | null; mediaTags: MediaTags | null;
expanded: boolean; expanded: boolean;
shouldPlay: boolean;
}; };
export const audioPlayerStore = createStore( export const audioPlayerStore = createStore(
@ -32,6 +33,7 @@ export const audioPlayerStore = createStore(
status: null, status: null,
mediaTags: null, mediaTags: null,
expanded: false, expanded: false,
shouldPlay: false,
}), }),
{ {
name: "audioPlayer", name: "audioPlayer",
@ -42,6 +44,7 @@ export const audioPlayerStore = createStore(
status: null, status: null,
mediaTags: null, mediaTags: null,
expanded: false, expanded: false,
shouldPlay: false,
}), }),
} }
) )
@ -58,6 +61,7 @@ const play = (files: FileItem[], idx: number) => {
currentIdx, currentIdx,
status: null, status: null,
mediaTags: null, mediaTags: null,
shouldPlay: true,
}); });
}; };
@ -72,7 +76,14 @@ const advanceBy = (increment: number) => {
}; };
const togglePlay = async () => { 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) { if (!sound) {
return; return;
} }