mirror of
https://github.com/khairul169/home-lab.git
synced 2026-10-02 17:13:57 +07:00
feat: add yt mp3 downloader
This commit is contained in:
+45
-37
@@ -4,7 +4,7 @@ import api from "@/lib/api";
|
||||
import { useAuth } from "@/stores/authStore";
|
||||
import BackButton from "@ui/BackButton";
|
||||
import Input from "@ui/Input";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { Stack } from "expo-router";
|
||||
import React, { useState } from "react";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import FileDrop from "@/components/pages/files/FileDrop";
|
||||
@@ -16,6 +16,9 @@ import FileInlineViewer from "@/components/pages/files/FileInlineViewer";
|
||||
import { FilesContext } from "@/components/pages/files/FilesContext";
|
||||
import { FileItem } from "@/types/files";
|
||||
import Head from "@/components/utility/Head";
|
||||
import Container from "@ui/Container";
|
||||
import FileUpload from "@/components/pages/files/FileUpload";
|
||||
import ActionButton from "@/components/pages/files/ActionButton";
|
||||
|
||||
const FilesPage = () => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
@@ -65,48 +68,53 @@ const FilesPage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FilesContext.Provider value={{ files: data, viewFile, setViewFile }}>
|
||||
<FilesContext.Provider
|
||||
value={{
|
||||
path: params.path,
|
||||
files: data,
|
||||
viewFile,
|
||||
setViewFile,
|
||||
refresh: refetch,
|
||||
}}
|
||||
>
|
||||
<Head title="Files" />
|
||||
<Stack.Screen
|
||||
options={{ headerLeft: () => <BackButton />, title: "Files" }}
|
||||
/>
|
||||
|
||||
<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 })}
|
||||
/>
|
||||
<Button
|
||||
icon={<Ionicons name="home-outline" />}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={() => setParams({ ...params, path: "" })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={params.path}
|
||||
onChangeText={(path) => setParams({ path })}
|
||||
className="flex-1"
|
||||
/>
|
||||
</HStack>
|
||||
<Container className="flex-1">
|
||||
<HStack className="px-2 py-2 bg-white gap-2">
|
||||
<ActionButton
|
||||
icon={<Ionicons name="chevron-back" />}
|
||||
disabled={parentPath == null}
|
||||
onPress={() => setParams({ ...params, path: parentPath })}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Ionicons name="home-outline" />}
|
||||
onPress={() => setParams({ ...params, path: "" })}
|
||||
/>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={params.path}
|
||||
onChangeText={(path) => setParams({ path })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<FileUpload />
|
||||
</HStack>
|
||||
|
||||
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
|
||||
<FileList
|
||||
files={data}
|
||||
onSelect={(file) => {
|
||||
if (file.isDirectory) {
|
||||
setParams({ ...params, path: file.path });
|
||||
} else {
|
||||
setViewFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileDrop>
|
||||
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
|
||||
<FileList
|
||||
files={data}
|
||||
onSelect={(file) => {
|
||||
if (file.isDirectory) {
|
||||
setParams({ ...params, path: file.path });
|
||||
} else {
|
||||
setViewFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FileDrop>
|
||||
</Container>
|
||||
|
||||
<FileInlineViewer file={viewFile} onClose={() => setViewFile(null)} />
|
||||
</FilesContext.Provider>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Button from "@ui/Button";
|
||||
|
||||
type ActionButtonProps = {
|
||||
icon: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const ActionButton = ({ icon, onPress, disabled }: ActionButtonProps) => (
|
||||
<Button
|
||||
disabled={disabled}
|
||||
icon={icon}
|
||||
className="px-3 border-gray-300"
|
||||
labelClasses="text-gray-500"
|
||||
variant="outline"
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ActionButton from "./ActionButton";
|
||||
import { Ionicons } from "@ui/Icons";
|
||||
import Modal, { createModal } from "@ui/Modal";
|
||||
import { HStack, VStack } from "@ui/Stack";
|
||||
import Button from "@ui/Button";
|
||||
import Box from "@ui/Box";
|
||||
import { useFilesContext } from "./FilesContext";
|
||||
import Input from "@ui/Input";
|
||||
import { useMutation, useQuery } from "react-query";
|
||||
import api from "@/lib/api";
|
||||
import { showToast } from "@/stores/toastStore";
|
||||
import Text from "@ui/Text";
|
||||
|
||||
const modal = createModal();
|
||||
|
||||
const FileUpload = () => {
|
||||
// const [type, setType] = useState<"file" | "youtube" | "url">("file");
|
||||
const [type, setType] = useState<"file" | "youtube" | "url">("youtube");
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionButton
|
||||
icon={<Ionicons name="cloud-upload-outline" />}
|
||||
onPress={() => modal.open()}
|
||||
/>
|
||||
<Modal modal={modal} className="max-w-xl" title="Upload File">
|
||||
<HStack className="gap-2 mt-4">
|
||||
{/* <Button
|
||||
variant={type === "file" ? "default" : "ghost"}
|
||||
onPress={() => setType("file")}
|
||||
>
|
||||
File
|
||||
</Button> */}
|
||||
<Button
|
||||
variant={type === "youtube" ? "default" : "ghost"}
|
||||
onPress={() => setType("youtube")}
|
||||
>
|
||||
Youtube
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant={type === "url" ? "default" : "ghost"}
|
||||
onPress={() => setType("url")}
|
||||
>
|
||||
URL
|
||||
</Button> */}
|
||||
</HStack>
|
||||
|
||||
<Box className="mt-4">
|
||||
{type === "file" && <FileDownload />}
|
||||
{type === "youtube" && <YoutubeDownload />}
|
||||
{type === "url" && <UrlDownload />}
|
||||
</Box>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FileDownload = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
type YtDlTask = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
path: string;
|
||||
length: string;
|
||||
thumb: string;
|
||||
status?: "pending" | "resolved" | "rejected";
|
||||
};
|
||||
|
||||
const YoutubeDownload = () => {
|
||||
const { path } = useFilesContext();
|
||||
const [url, setUrl] = useState("");
|
||||
const [tasks, setTasks] = useState<YtDlTask[]>([]);
|
||||
|
||||
const start = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.files.ytdl.$post({ json: { path, url } });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setTasks((i) => [data, ...i]);
|
||||
setUrl("");
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast((err as any)?.message || "An error occured!", {
|
||||
type: "danger",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<HStack className="gap-3">
|
||||
<Input
|
||||
placeholder="URL"
|
||||
className="flex-1"
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
icon={<Ionicons name="cloud-upload" />}
|
||||
disabled={!url.length || start.isLoading}
|
||||
onPress={() => start.mutate()}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{tasks.length > 0 && (
|
||||
<VStack className="gap-2 mt-4">
|
||||
{tasks.map((task) => (
|
||||
<YtDownloadTaskStatus key={task.id} task={task} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const YtDownloadTaskStatus = ({ task }: { task: YtDlTask }) => {
|
||||
const { refresh } = useFilesContext();
|
||||
const [isPending, setPending] = useState(true);
|
||||
|
||||
const { data, isError } = useQuery({
|
||||
queryKey: ["ytdl", task.id],
|
||||
queryFn: async () =>
|
||||
api.files.ytdl[":id"]
|
||||
.$get({ param: { id: task.id } })
|
||||
.then((i) => i.json()),
|
||||
enabled: isPending,
|
||||
refetchInterval: 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data && ["rejected", "resolved"].includes(data.status)) {
|
||||
setPending(false);
|
||||
refresh();
|
||||
}
|
||||
}, [data, isError, refresh]);
|
||||
|
||||
const status = data?.status || task.status;
|
||||
|
||||
return (
|
||||
<HStack className="gap-2">
|
||||
{!status ? <Ionicons name="hourglass" size={24} /> : null}
|
||||
{status === "pending" ? (
|
||||
<Ionicons size={24} name="sync-circle-outline" />
|
||||
) : null}
|
||||
{status === "resolved" ? (
|
||||
<Ionicons name="checkmark-circle" size={24} color="green" />
|
||||
) : null}
|
||||
{status === "rejected" ? (
|
||||
<Ionicons name="close-circle" size={24} color="red" />
|
||||
) : null}
|
||||
|
||||
<Text className="text-lg font-medium flex-1" numberOfLines={1}>
|
||||
{task.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
const UrlDownload = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export default React.memo(FileUpload);
|
||||
@@ -2,15 +2,19 @@ import { FileItem } from "@/types/files";
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type FilesContextType = {
|
||||
path: string;
|
||||
files: FileItem[];
|
||||
viewFile: FileItem | null;
|
||||
setViewFile: (file: FileItem | null) => void;
|
||||
refresh: () => void;
|
||||
};
|
||||
|
||||
export const FilesContext = createContext<FilesContextType>({
|
||||
path: "",
|
||||
files: [],
|
||||
viewFile: null,
|
||||
setViewFile: () => null,
|
||||
refresh: () => null,
|
||||
});
|
||||
|
||||
export const useFilesContext = () => useContext(FilesContext);
|
||||
|
||||
@@ -13,7 +13,7 @@ const Container = ({
|
||||
children,
|
||||
scrollable = false,
|
||||
}: ContainerProps) => {
|
||||
const style = cn("mx-auto w-full max-w-xl", className);
|
||||
const style = cn("mx-auto w-full max-w-3xl", className);
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
|
||||
@@ -33,6 +33,7 @@ const BaseInput = ({
|
||||
"border border-gray-300 rounded-lg px-3 h-10 w-full",
|
||||
inputClassName
|
||||
)}
|
||||
placeholderTextColor="#787878"
|
||||
{...props}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
import BaseModal from "react-native-modal";
|
||||
import Container from "./Container";
|
||||
import { createStore, useStore } from "zustand";
|
||||
import Text from "./Text";
|
||||
import { HStack } from "./Stack";
|
||||
import Button from "./Button";
|
||||
|
||||
type ModalStore = {
|
||||
isOpen: boolean;
|
||||
data?: any;
|
||||
};
|
||||
|
||||
export const createModal = <T,>() => {
|
||||
const store = createStore<ModalStore>(() => ({
|
||||
isOpen: false,
|
||||
data: null,
|
||||
}));
|
||||
const open = (data?: T) => store.setState({ isOpen: true, data });
|
||||
const close = () => store.setState({ isOpen: false });
|
||||
return { ...store, open, close };
|
||||
};
|
||||
|
||||
type CreateModalReturn = ReturnType<typeof createModal>;
|
||||
|
||||
type ModalProps = {
|
||||
modal: CreateModalReturn;
|
||||
className?: string;
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Modal = ({ title, modal, actions, children, className }: ModalProps) => {
|
||||
const { isOpen } = useStore(modal);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
isVisible={isOpen}
|
||||
onBackdropPress={modal.close}
|
||||
onBackButtonPress={modal.close}
|
||||
animationIn="fadeInDown"
|
||||
>
|
||||
<Container
|
||||
className={["bg-white rounded-lg p-6 md:p-8 max-w-xl", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<Text className="text-2xl font-medium">{title}</Text>
|
||||
|
||||
{children}
|
||||
|
||||
<HStack className="justify-end gap-4 mt-6">
|
||||
<Button variant="ghost" onPress={modal.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
{actions}
|
||||
</HStack>
|
||||
</Container>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
Reference in New Issue
Block a user