mirror of
https://github.com/khairul169/home-lab.git
synced 2025-04-28 08:39:34 +07:00
feat: update file manager
This commit is contained in:
parent
1160b30c3d
commit
2ba5da4b73
22
backend/routes/files/delete.ts
Normal file
22
backend/routes/files/delete.ts
Normal 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 });
|
||||
};
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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";
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
||||
|
17
src/hooks/useDisclosure.ts
Normal file
17
src/hooks/useDisclosure.ts
Normal 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 };
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user