feat: add yt mp3 downloader

This commit is contained in:
Khairul Hidayat 2024-03-19 11:08:43 +07:00
parent 93cd056cd8
commit 07f1598795
20 changed files with 998 additions and 233 deletions

View File

@ -1,5 +1,6 @@
# Config # Config
JWT_SECRET= JWT_SECRET=
TMP_DIR=
AUTH_USERNAME= AUTH_USERNAME=
AUTH_PASSWORD= AUTH_PASSWORD=

View File

@ -1,6 +1,7 @@
import { Mime } from "mime/lite"; import { Mime } from "mime/lite";
import standardTypes from "mime/types/standard.js"; import standardTypes from "mime/types/standard.js";
import otherTypes from "mime/types/other.js"; import otherTypes from "mime/types/other.js";
import { nanoid } from "nanoid";
export const formatBytes = (bytes: number) => { export const formatBytes = (bytes: number) => {
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
@ -29,3 +30,36 @@ mime.define(
export const getMimeType = (path: string) => { export const getMimeType = (path: string) => {
return mime.getType(path) || "application/octet-stream"; return mime.getType(path) || "application/octet-stream";
}; };
export const slugify = (text: string, lowerCase = true) => {
let str = text.replace(/^\s+|\s+$/g, "");
// Make the string lowercase
if (lowerCase) {
str = str.toLowerCase();
}
// Remove accents, swap ñ for n, etc
const from =
"ÁÄÂÀÃÅČÇĆĎÉĚËÈÊẼĔȆÍÌÎÏŇÑÓÖÒÔÕØŘŔŠŤÚŮÜÙÛÝŸŽáäâàãåčçćďéěëèêẽĕȇíìîïňñóöòôõøðřŕšťúůüùûýÿžþÞĐđ߯a·/_,:;";
const to =
"AAAAAACCCDEEEEEEEEIIIINNOOOOOORRSTUUUUUYYZaaaaaacccdeeeeeeeeiiiinnooooooorrstuuuuuyyzbBDdBAa------";
for (let i = 0, l = from.length; i < l; i += 1) {
str = str.replace(new RegExp(from.charAt(i), "g"), to.charAt(i));
}
// Remove invalid chars
str = str
// .replace(/[^A-Za-z0-9 -]/g, '')
.replace(/[\\/:"*?<>|]/g, "")
// Collapse whitespace and replace by -
.replace(/\s+/g, "-")
// Collapse dashes
.replace(/-+/g, "-");
if (!str.length) {
str = nanoid();
}
return str;
};

219
backend/lib/yt2mp3.ts Normal file
View File

@ -0,0 +1,219 @@
import YTDL from "ytdl-core";
import ffmpeg from "fluent-ffmpeg";
import fs from "node:fs";
import fetch from "node-fetch";
import path from "node:path";
import { slugify } from "./utils";
import { nanoid } from "nanoid";
const TMP_DIR = process.env.TMP_DIR || "/tmp/homelab";
const ytDownload = (url: string): Promise<YTDownloadFnResult> => {
return new Promise((resolve, reject) => {
const dl = YTDL(url, {
quality: "highestaudio",
filter: (i) => i.hasAudio,
});
dl.on("info", async (info: YTDL.videoInfo, format: YTDL.videoFormat) => {
const type = format.container;
// Download thumbnail
const thumbnail = getBestThumbnail(info.videoDetails.thumbnails);
const uid = nanoid();
const thumbDest = `${TMP_DIR}/${uid}.jpeg`;
const albumCover = await downloadFile(thumbnail.url, thumbDest);
const filename = `${uid}.${type}`;
const tmpSrc = `${TMP_DIR}/${filename}`;
const onClean = () => {
fs.unlinkSync(tmpSrc);
if (albumCover) fs.unlinkSync(albumCover);
};
dl.pipe(fs.createWriteStream(tmpSrc));
dl.on("end", () => {
resolve({ info, path: tmpSrc, clean: onClean, album: albumCover });
});
});
dl.on("progress", (_, progress: number, total: number) => {
console.log("info", `${Math.round((progress / total) * 100)}%`);
});
dl.on("error", reject);
});
};
type YTDownloadFnResult = {
info: YTDL.videoInfo;
path: string;
album: string | null;
clean: () => void;
};
const convertToAudio = (
src: string,
output: string,
format = "mp3"
): Promise<string> => {
return new Promise((resolve, reject) => {
const cmd = ffmpeg(src)
.output(output)
.format(format)
.on("end", () => {
resolve(output);
});
cmd.on("error", (err, stdout, stderr) => {
console.log(`Cannot process video: ${err.message}`);
console.log(stdout, stderr);
reject(err);
});
// cmd.on("start", (cmdline) => {
// console.log("cmdline", cmdline);
// });
cmd.run();
});
};
const embedMetadata = (
src: string,
output: string,
title: string,
album: string | null
) => {
return new Promise((resolve, reject) => {
const cmd = ffmpeg(src)
.output(output)
.outputOptions(
// '-c:a libmp3lame',
"-id3v2_version",
"3",
"-write_id3v1",
"1",
"-metadata",
`title=${title}`
// '-metadata',
// 'comment="Cover (front)"'
)
.on("end", () => {
resolve(output);
});
if (album) {
cmd.addInput(album);
cmd.addOutputOptions(["-map 0:0", "-map 1:0"]);
}
cmd.on("error", (err, stdout, stderr) => {
console.log(`Cannot process video: ${err.message}`);
console.log(stdout, stderr);
reject(err);
});
cmd.on("start", (cmdline) => {
console.log("cmdline", cmdline);
});
cmd.run();
});
};
type YT2MP3Options = {
filename?: string;
format?: string;
};
const yt2mp3 = async (url: string, outDir: string, options?: YT2MP3Options) => {
let srcFile: YTDownloadFnResult | null = null;
let tmpAudio: string | undefined;
let result: string | undefined;
try {
if (!fs.existsSync(TMP_DIR)) {
fs.mkdirSync(TMP_DIR, { recursive: true });
}
srcFile = await ytDownload(url);
const { info } = srcFile;
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
const { title } = info.videoDetails;
const format = options?.format || "mp3";
const tmpAudioPath = path.join(TMP_DIR, `${Date.now()}.${format}`);
tmpAudio = await convertToAudio(srcFile.path, tmpAudioPath, format);
const audioPath = path.join(
outDir,
options?.filename || `${slugify(title, false)}.${format}`
);
await embedMetadata(tmpAudio, audioPath, title, srcFile.album);
result = audioPath;
} catch (err) {
throw err;
} finally {
// clean tmp files
if (srcFile) {
srcFile.clean();
}
if (tmpAudio) {
fs.unlinkSync(tmpAudio);
}
}
return result;
};
export const getVideoInfo = async (url: string) => {
const info = await YTDL.getBasicInfo(url);
const { videoDetails: details } = info;
return {
url: details.video_url,
title: details.title,
length: details.lengthSeconds,
thumb: getBestThumbnail(details.thumbnails),
};
};
function getBestThumbnail(thumbnails: YTDL.thumbnail[]) {
let thumbnail = thumbnails[0] || null;
thumbnails.forEach((thumb) => {
if (!thumbnail) {
thumbnail = thumb;
return;
}
if (thumb.width > thumbnail.width || thumb.height > thumbnail.height) {
thumbnail = thumb;
}
});
return thumbnail;
}
async function downloadFile(url: string, out: string) {
try {
const res = await fetch(url);
if (!res.ok || !res.body) {
throw new Error(res.statusText);
}
res.body.pipe(fs.createWriteStream(out));
return out;
} catch (err) {
console.log("err download file", err);
return null;
}
}
export default yt2mp3;

View File

@ -7,6 +7,8 @@
"start": "tsx index.ts" "start": "tsx index.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/better-queue": "^3.8.6",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/jsonwebtoken": "^9.0.6", "@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.11.28", "@types/node": "^20.11.28",
"@types/ws": "^8.5.10", "@types/ws": "^8.5.10",
@ -18,14 +20,18 @@
"dependencies": { "dependencies": {
"@hono/node-server": "^1.8.2", "@hono/node-server": "^1.8.2",
"@hono/zod-validator": "^0.2.0", "@hono/zod-validator": "^0.2.0",
"better-queue": "^3.8.12",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"fluent-ffmpeg": "^2.1.2",
"hono": "^4.1.0", "hono": "^4.1.0",
"mime": "^4.0.1", "mime": "^4.0.1",
"nanoid": "^5.0.6", "nanoid": "^5.0.6",
"node-fetch": "^3.3.2",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"systeminformation": "^5.22.2", "systeminformation": "^5.22.2",
"wol": "^1.0.7", "wol": "^1.0.7",
"ws": "^8.16.0", "ws": "^8.16.0",
"ytdl-core": "^4.11.5",
"zod": "^3.22.4" "zod": "^3.22.4"
} }
} }

View File

@ -1,195 +0,0 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import fs from "node:fs/promises";
import { HTTPException } from "hono/http-exception";
import { createReadStream } from "node:fs";
import { getMimeType } from "../lib/utils";
const getFilesSchema = z
.object({
path: z.string(),
})
.partial()
.optional();
const uploadSchema = z.object({
path: z.string().min(1),
size: z.string().min(1),
});
const filesDirList = process.env.FILE_DIRS
? process.env.FILE_DIRS.split(";").map((i) => ({
name: i.split("/").at(-1),
path: i,
}))
: [];
const route = new Hono()
.get("/", zValidator("query", getFilesSchema), async (c) => {
const input: z.infer<typeof getFilesSchema> = c.req.query();
const { baseName, path, pathname } = getFilePath(input.path);
if (!baseName?.length) {
return c.json(
filesDirList.map((i) => ({
name: i.name,
path: "/" + i.name,
isDirectory: true,
}))
);
}
try {
const entities = await fs.readdir(path, { withFileTypes: true });
const files = entities
.filter((e) => !e.name.startsWith("."))
.map((e) => ({
name: e.name,
path: [pathname, e.name].join("/"),
isDirectory: e.isDirectory(),
}))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) {
return -1;
} else if (!a.isDirectory && b.isDirectory) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
});
return c.json(files);
} catch (err) {}
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 { baseDir, path: targetDir } = getFilePath(data.path);
if (!baseDir?.length) {
throw new HTTPException(400, { message: "Path not found!" });
}
// 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) => {
const dlFile = c.req.query("dl") === "true";
const url = new URL(c.req.url, `http://${c.req.header("host")}`);
const pathname = decodeURI(url.pathname).split("/");
const pathSlice = pathname.slice(pathname.indexOf("download") + 1);
const baseName = pathSlice[0];
const path = "/" + pathSlice.slice(1).join("/");
const filename = path.substring(1);
try {
if (!baseName?.length) {
throw new Error("baseName is empty");
}
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
if (!baseDir) {
throw new Error("baseDir not found");
}
const filepath = baseDir + path;
const stat = await fs.stat(filepath);
const size = stat.size;
if (dlFile) {
c.header("Content-Type", "application/octet-stream");
c.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`
);
} else {
c.header("Content-Type", getMimeType(filepath));
}
if (c.req.method == "HEAD" || c.req.method == "OPTIONS") {
c.header("Content-Length", size.toString());
c.status(200);
return c.body(null);
}
const range = c.req.header("range") || "";
if (!range || dlFile) {
c.header("Content-Length", size.toString());
return c.body(createReadStream(filepath), 200);
}
c.header("Accept-Ranges", "bytes");
c.header("Date", stat.birthtime.toUTCString());
const parts = range.replace(/bytes=/, "").split("-", 2);
const start = parts[0] ? parseInt(parts[0], 10) : 0;
let end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
if (size < end - start + 1) {
end = size - 1;
}
const chunksize = end - start + 1;
const stream = createReadStream(filepath, { start, end });
c.header("Content-Length", chunksize.toString());
c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`);
return c.body(stream, 206);
} catch (err) {
// console.log("err", err);
throw new HTTPException(404, { message: "Not Found!" });
}
});
function getFilePath(path?: string) {
const pathSlices =
path
?.replace(/\/{2,}/g, "/")
.replace(/\/$/, "")
.split("/") || [];
const baseName = pathSlices[1] || null;
const filePath = pathSlices.slice(2).join("/");
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
return {
path: [baseDir || "", filePath].join("/").replace(/\/$/, ""),
pathname: ["", baseName, filePath].join("/").replace(/\/$/, ""),
baseName,
baseDir,
filePath,
};
}
export default route;

View File

@ -0,0 +1,75 @@
import { createReadStream } from "fs";
import fs from "node:fs/promises";
import { HTTPException } from "hono/http-exception";
import { getMimeType } from "../../lib/utils";
import { filesDirList } from "./utils";
import type { Context } from "hono";
export const download = async (c: Context) => {
const dlFile = c.req.query("dl") === "true";
const url = new URL(c.req.url, `http://${c.req.header("host")}`);
const pathname = decodeURI(url.pathname).split("/");
const pathSlice = pathname.slice(pathname.indexOf("download") + 1);
const baseName = pathSlice[0];
const path = "/" + pathSlice.slice(1).join("/");
const filename = path.substring(1);
try {
if (!baseName?.length) {
throw new Error("baseName is empty");
}
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
if (!baseDir) {
throw new Error("baseDir not found");
}
const filepath = baseDir + path;
const stat = await fs.stat(filepath);
const size = stat.size;
if (dlFile) {
c.header("Content-Type", "application/octet-stream");
c.header(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(filename)}"`
);
} else {
c.header("Content-Type", getMimeType(filepath));
}
if (c.req.method == "HEAD" || c.req.method == "OPTIONS") {
c.header("Content-Length", size.toString());
c.status(200);
return c.body(null);
}
const range = c.req.header("range") || "";
if (!range || dlFile) {
c.header("Content-Length", size.toString());
return c.body(createReadStream(filepath), 200);
}
c.header("Accept-Ranges", "bytes");
c.header("Date", stat.birthtime.toUTCString());
const parts = range.replace(/bytes=/, "").split("-", 2);
const start = parts[0] ? parseInt(parts[0], 10) : 0;
let end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
if (size < end - start + 1) {
end = size - 1;
}
const chunksize = end - start + 1;
const stream = createReadStream(filepath, { start, end });
c.header("Content-Length", chunksize.toString());
c.header("Content-Range", `bytes ${start}-${end}/${stat.size}`);
return c.body(stream, 206);
} catch (err) {
// console.log("err", err);
throw new HTTPException(404, { message: "Not Found!" });
}
};

View File

@ -0,0 +1,44 @@
import type { Context } from "hono";
import type { GetFilesSchema } from "./schema";
import fs from "node:fs/promises";
import { filesDirList, getFilePath } from "./utils";
export const getFiles = async (c: Context) => {
const input: GetFilesSchema = c.req.query();
const { baseName, path, pathname } = getFilePath(input.path);
if (!baseName?.length) {
return c.json(
filesDirList.map((i) => ({
name: i.name,
path: "/" + i.name,
isDirectory: true,
}))
);
}
try {
const entities = await fs.readdir(path, { withFileTypes: true });
const files = entities
.filter((e) => !e.name.startsWith("."))
.map((e) => ({
name: e.name,
path: [pathname, e.name].join("/"),
isDirectory: e.isDirectory(),
}))
.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) {
return -1;
} else if (!a.isDirectory && b.isDirectory) {
return 1;
} else {
return a.name.localeCompare(b.name);
}
});
return c.json(files);
} catch (err) {}
return c.json([]);
};

View File

@ -0,0 +1,15 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { getFilesSchema, ytdlSchema } from "./schema";
import { getFiles } from "./get";
import { download } from "./download";
import { getYtdl, ytdl } from "./ytdl";
const route = new Hono()
.get("/", zValidator("query", getFilesSchema), getFiles)
.post("/upload")
.post("/ytdl", zValidator("json", ytdlSchema), ytdl)
.get("/ytdl/:id", getYtdl)
.get("/download/*", download);
export default route;

View File

@ -0,0 +1,24 @@
import { z } from "zod";
export const getFilesSchema = z
.object({
path: z.string(),
})
.partial()
.optional();
export type GetFilesSchema = z.infer<typeof getFilesSchema>;
export const uploadSchema = z.object({
path: z.string().min(1),
size: z.string().min(1),
});
export type UploadSchema = z.infer<typeof uploadSchema>;
export const ytdlSchema = z.object({
url: z.string().min(1),
path: z.string().min(1),
});
export type YtdlSchema = z.infer<typeof ytdlSchema>;

View File

@ -0,0 +1,45 @@
import type { Context } from "hono";
import fs from "node:fs/promises";
import { uploadSchema } from "./schema";
import { HTTPException } from "hono/http-exception";
import { getFilePath } from "./utils";
export const upload = async (c: Context) => {
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 { baseDir, path: targetDir } = getFilePath(data.path);
if (!baseDir?.length) {
throw new HTTPException(400, { message: "Path not found!" });
}
// 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 });
};

View File

@ -0,0 +1,25 @@
export const filesDirList = process.env.FILE_DIRS
? process.env.FILE_DIRS.split(";").map((i) => ({
name: i.split("/").at(-1),
path: i,
}))
: [];
export function getFilePath(path?: string) {
const pathSlices =
path
?.replace(/\/{2,}/g, "/")
.replace(/\/$/, "")
.split("/") || [];
const baseName = pathSlices[1] || null;
const filePath = pathSlices.slice(2).join("/");
const baseDir = filesDirList.find((i) => i.name === baseName)?.path;
return {
path: [baseDir || "", filePath].join("/").replace(/\/$/, ""),
pathname: ["", baseName, filePath].join("/").replace(/\/$/, ""),
baseName,
baseDir,
filePath,
};
}

View File

@ -0,0 +1,77 @@
import type { Context } from "hono";
import type { YtdlSchema } from "./schema";
import Queue from "better-queue";
import { nanoid } from "nanoid";
import yt2mp3, { getVideoInfo } from "../../lib/yt2mp3";
import { getFilePath } from "./utils";
import { HTTPException } from "hono/http-exception";
type Task = {
id: string;
path: string;
url: string;
title: string;
thumb: string;
length: string;
status?: "pending" | "resolved" | "rejected";
};
const tasks = new Map<string, Task>();
const queue = new Queue<Task>((input: Task, cb) => {
const { id, path, url } = input;
const task = tasks.get(id);
if (!task) return cb(new Error("Task not found!"));
const out = getFilePath(path);
if (!out.path) return cb(new Error("Path not found!"));
tasks.set(id, { ...task, status: "pending" });
yt2mp3(url, out.path)
.then((data) => cb(null, data))
.catch(cb);
});
queue.on("task_finish", (taskId: string) => {
const task = tasks.get(taskId);
task && tasks.set(taskId, { ...task, status: "resolved" });
console.log("task finish!", taskId);
});
queue.on("task_failed", (taskId: string) => {
const task = tasks.get(taskId);
task && tasks.set(taskId, { ...task, status: "rejected" });
console.error("task failed!", taskId);
});
export const ytdl = async (c: Context) => {
const input: YtdlSchema = await c.req.json();
try {
const info = await getVideoInfo(input.url);
const task: Task = {
id: nanoid(),
url: input.url,
title: info.title,
path: input.path,
thumb: info.thumb?.url,
length: info.length,
};
queue.push(task);
tasks.set(task.id, task);
return c.json(task);
} catch (err) {
throw new HTTPException(400, {
message: (err as any)?.message || "Cannot download from youtube!",
});
}
};
export const getYtdl = async (c: Context) => {
const id = c.req.param("id");
return c.json(tasks.get(id));
};

View File

@ -127,6 +127,20 @@
resolved "https://registry.yarnpkg.com/@hono/zod-validator/-/zod-validator-0.2.0.tgz#77d8f1f167cba85b008a52f594ed823c841b2300" resolved "https://registry.yarnpkg.com/@hono/zod-validator/-/zod-validator-0.2.0.tgz#77d8f1f167cba85b008a52f594ed823c841b2300"
integrity sha512-PC7akbA/DCFY406BL3+ogjYb+7Fgfs/6XPvyURBYMczo0M7kYsTUMnF8hA9mez1RORNCWPqXuFGfKrkoUVPvrQ== integrity sha512-PC7akbA/DCFY406BL3+ogjYb+7Fgfs/6XPvyURBYMczo0M7kYsTUMnF8hA9mez1RORNCWPqXuFGfKrkoUVPvrQ==
"@types/better-queue@^3.8.6":
version "3.8.6"
resolved "https://registry.yarnpkg.com/@types/better-queue/-/better-queue-3.8.6.tgz#24366d66cf8f34304b90ff76585688b7676d6b3f"
integrity sha512-iC8L2LmVwgA0lcfrw9bLt0qQ8BVs2HOK/c2vtUnqQvoltMGn1GQ4OQChZFLRSx9AXgtdF6FDVsGDqyaV/urChw==
dependencies:
"@types/node" "*"
"@types/fluent-ffmpeg@^2.1.24":
version "2.1.24"
resolved "https://registry.yarnpkg.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.24.tgz#f53c57700bc4360ac638554545c8da2c465434c1"
integrity sha512-g5oQO8Jgi2kFS3tTub7wLvfLztr1s8tdXmRd8PiL/hLMLzTIAyMR2sANkTggM/rdEDAg3d63nYRRVepwBiCw5A==
dependencies:
"@types/node" "*"
"@types/jsonwebtoken@^9.0.6": "@types/jsonwebtoken@^9.0.6":
version "9.0.6" version "9.0.6"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3" resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz#d1af3544d99ad992fb6681bbe60676e06b032bd3"
@ -148,6 +162,30 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
async@>=0.2.9:
version "3.2.5"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66"
integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==
better-queue-memory@^1.0.1:
version "1.0.4"
resolved "https://registry.yarnpkg.com/better-queue-memory/-/better-queue-memory-1.0.4.tgz#f390d6b30bb3b36aaf2ce52b37a483e8a7a81a22"
integrity sha512-SWg5wFIShYffEmJpI6LgbL8/3Dqhku7xI1oEiy6FroP9DbcZlG0ZDjxvPdP9t7hTGW40IpIcC6zVoGT1oxjOuA==
better-queue@^3.8.12:
version "3.8.12"
resolved "https://registry.yarnpkg.com/better-queue/-/better-queue-3.8.12.tgz#15c18923d0f9778be94f19c3ef2bd85c632d0db3"
integrity sha512-D9KZ+Us+2AyaCz693/9AyjTg0s8hEmkiM/MB3i09cs4MdK1KgTSGJluXRYmOulR69oLZVo2XDFtqsExDt8oiLA==
dependencies:
better-queue-memory "^1.0.1"
node-eta "^0.9.0"
uuid "^9.0.0"
data-uri-to-buffer@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e"
integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==
dotenv@^16.4.5: dotenv@^16.4.5:
version "16.4.5" version "16.4.5"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f"
@ -182,6 +220,29 @@ esbuild@~0.19.10:
"@esbuild/win32-ia32" "0.19.12" "@esbuild/win32-ia32" "0.19.12"
"@esbuild/win32-x64" "0.19.12" "@esbuild/win32-x64" "0.19.12"
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
dependencies:
node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3"
fluent-ffmpeg@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.2.tgz#c952de2240f812ebda0aa8006d7776ee2acf7d74"
integrity sha512-IZTB4kq5GK0DPp7sGQ0q/BWurGHffRtQQwVkiqDgeO6wYJLLV5ZhgNOQ65loZxxuPMKZKZcICCUnaGtlxBiR0Q==
dependencies:
async ">=0.2.9"
which "^1.1.1"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
dependencies:
fetch-blob "^3.1.2"
fsevents@~2.3.3: fsevents@~2.3.3:
version "2.3.3" version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@ -199,11 +260,29 @@ hono@^4.1.0:
resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4" resolved "https://registry.yarnpkg.com/hono/-/hono-4.1.0.tgz#62cef81df0dbf731643155e1e5c1b9dffb230dc4"
integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ== integrity sha512-9no6DCHb4ijB1tWdFXU6JnrnFgzwVZ1cnIcS1BjAFnMcjbtBTOMsQrDrPH3GXbkNEEEkj8kWqcYBy8Qc0bBkJQ==
isexe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
m3u8stream@^0.8.6:
version "0.8.6"
resolved "https://registry.yarnpkg.com/m3u8stream/-/m3u8stream-0.8.6.tgz#0d6de4ce8ee69731734e6b616e7b05dd9d9a55b1"
integrity sha512-LZj8kIVf9KCphiHmH7sbFQTVe4tOemb202fWwvJwR9W5ENW/1hxJN6ksAWGhQgSBSa3jyWhnjKU1Fw1GaOdbyA==
dependencies:
miniget "^4.2.2"
sax "^1.2.4"
mime@^4.0.1: mime@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.1.tgz#ad7563d1bfe30253ad97dedfae2b1009d01b9470" resolved "https://registry.yarnpkg.com/mime/-/mime-4.0.1.tgz#ad7563d1bfe30253ad97dedfae2b1009d01b9470"
integrity sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA== integrity sha512-5lZ5tyrIfliMXzFtkYyekWbtRXObT9OWa8IwQ5uxTBDHucNNwniRqo0yInflj+iYi5CBa6qxadGzGarDfuEOxA==
miniget@^4.2.2:
version "4.2.3"
resolved "https://registry.yarnpkg.com/miniget/-/miniget-4.2.3.tgz#3707a24c7c11c25d359473291638ab28aab349bd"
integrity sha512-SjbDPDICJ1zT+ZvQwK0hUcRY4wxlhhNpHL9nJOB2MEAXRGagTljsO8MEDzQMTFf0Q8g4QNi8P9lEm/g7e+qgzA==
nan@^2.17.0: nan@^2.17.0:
version "2.19.0" version "2.19.0"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.19.0.tgz#bb58122ad55a6c5bc973303908d5b16cfdd5a8c0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.19.0.tgz#bb58122ad55a6c5bc973303908d5b16cfdd5a8c0"
@ -214,6 +293,25 @@ nanoid@^5.0.6:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.6.tgz#7f99a033aa843e4dcf9778bdaec5eb02f4dc44d5" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-5.0.6.tgz#7f99a033aa843e4dcf9778bdaec5eb02f4dc44d5"
integrity sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA== integrity sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==
node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-eta@^0.9.0:
version "0.9.0"
resolved "https://registry.yarnpkg.com/node-eta/-/node-eta-0.9.0.tgz#9fb0b099bcd2a021940e603c64254dc003d9a7a8"
integrity sha512-mTCTZk29tmX1OGfVkPt63H3c3VqXrI2Kvua98S7iUIB/Gbp0MNw05YtUomxQIxnnKMyRIIuY9izPcFixzhSBrA==
node-fetch@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.3.2.tgz#d1e889bacdf733b4ff3b2b243eb7a12866a0b78b"
integrity sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==
dependencies:
data-uri-to-buffer "^4.0.0"
fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10"
node-pty@^1.0.0: node-pty@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd" resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-1.0.0.tgz#7daafc0aca1c4ca3de15c61330373af4af5861fd"
@ -226,6 +324,11 @@ resolve-pkg-maps@^1.0.0:
resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f"
integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==
sax@^1.1.3, sax@^1.2.4:
version "1.3.0"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0"
integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==
systeminformation@^5.22.2: systeminformation@^5.22.2:
version "5.22.3" version "5.22.3"
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.22.3.tgz#33ef8bd045125d672f64e7000015fefae07a77cb" resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.22.3.tgz#33ef8bd045125d672f64e7000015fefae07a77cb"
@ -246,6 +349,23 @@ undici-types@~5.26.4:
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
uuid@^9.0.0:
version "9.0.1"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
web-streams-polyfill@^3.0.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
which@^1.1.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
dependencies:
isexe "^2.0.0"
wol@^1.0.7: wol@^1.0.7:
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/wol/-/wol-1.0.7.tgz#a2e70efca2a28324a744a5d12331d359da470ff7" resolved "https://registry.yarnpkg.com/wol/-/wol-1.0.7.tgz#a2e70efca2a28324a744a5d12331d359da470ff7"
@ -256,6 +376,15 @@ ws@^8.16.0:
resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4"
integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==
ytdl-core@^4.11.5:
version "4.11.5"
resolved "https://registry.yarnpkg.com/ytdl-core/-/ytdl-core-4.11.5.tgz#8cc3dc9e4884e24e8251250cfb56313a300811f0"
integrity sha512-27LwsW4n4nyNviRCO1hmr8Wr5J1wLLMawHCQvH8Fk0hiRqrxuIu028WzbJetiYH28K8XDbeinYW4/wcHQD1EXA==
dependencies:
m3u8stream "^0.8.6"
miniget "^4.2.2"
sax "^1.1.3"
zod@^3.22.4: zod@^3.22.4:
version "3.22.4" version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"

View File

@ -4,7 +4,7 @@ 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 Input from "@ui/Input"; import Input from "@ui/Input";
import { Stack, useLocalSearchParams } from "expo-router"; import { Stack } from "expo-router";
import React, { useState } from "react"; import React, { 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";
@ -16,6 +16,9 @@ import FileInlineViewer from "@/components/pages/files/FileInlineViewer";
import { FilesContext } from "@/components/pages/files/FilesContext"; import { FilesContext } from "@/components/pages/files/FilesContext";
import { FileItem } from "@/types/files"; import { FileItem } from "@/types/files";
import Head from "@/components/utility/Head"; 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 FilesPage = () => {
const { isLoggedIn } = useAuth(); const { isLoggedIn } = useAuth();
@ -65,26 +68,29 @@ const FilesPage = () => {
} }
return ( return (
<FilesContext.Provider value={{ files: data, viewFile, setViewFile }}> <FilesContext.Provider
value={{
path: params.path,
files: data,
viewFile,
setViewFile,
refresh: refetch,
}}
>
<Head title="Files" /> <Head title="Files" />
<Stack.Screen <Stack.Screen
options={{ headerLeft: () => <BackButton />, title: "Files" }} options={{ headerLeft: () => <BackButton />, title: "Files" }}
/> />
<Container className="flex-1">
<HStack className="px-2 py-2 bg-white gap-2"> <HStack className="px-2 py-2 bg-white gap-2">
<Button <ActionButton
icon={<Ionicons name="chevron-back" />} icon={<Ionicons name="chevron-back" />}
disabled={parentPath == null} disabled={parentPath == null}
className="px-3 border-gray-300"
labelClasses="text-gray-500"
variant="outline"
onPress={() => setParams({ ...params, path: parentPath })} onPress={() => setParams({ ...params, path: parentPath })}
/> />
<Button <ActionButton
icon={<Ionicons name="home-outline" />} icon={<Ionicons name="home-outline" />}
className="px-3 border-gray-300"
labelClasses="text-gray-500"
variant="outline"
onPress={() => setParams({ ...params, path: "" })} onPress={() => setParams({ ...params, path: "" })}
/> />
<Input <Input
@ -93,6 +99,7 @@ const FilesPage = () => {
onChangeText={(path) => setParams({ path })} onChangeText={(path) => setParams({ path })}
className="flex-1" className="flex-1"
/> />
<FileUpload />
</HStack> </HStack>
<FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}> <FileDrop onFileDrop={onFileDrop} isDisabled={upload.isLoading}>
@ -107,6 +114,7 @@ const FilesPage = () => {
}} }}
/> />
</FileDrop> </FileDrop>
</Container>
<FileInlineViewer file={viewFile} onClose={() => setViewFile(null)} /> <FileInlineViewer file={viewFile} onClose={() => setViewFile(null)} />
</FilesContext.Provider> </FilesContext.Provider>

View File

@ -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;

View File

@ -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);

View File

@ -2,15 +2,19 @@ import { FileItem } from "@/types/files";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
type FilesContextType = { type FilesContextType = {
path: string;
files: FileItem[]; files: FileItem[];
viewFile: FileItem | null; viewFile: FileItem | null;
setViewFile: (file: FileItem | null) => void; setViewFile: (file: FileItem | null) => void;
refresh: () => void;
}; };
export const FilesContext = createContext<FilesContextType>({ export const FilesContext = createContext<FilesContextType>({
path: "",
files: [], files: [],
viewFile: null, viewFile: null,
setViewFile: () => null, setViewFile: () => null,
refresh: () => null,
}); });
export const useFilesContext = () => useContext(FilesContext); export const useFilesContext = () => useContext(FilesContext);

View File

@ -13,7 +13,7 @@ const Container = ({
children, children,
scrollable = false, scrollable = false,
}: ContainerProps) => { }: 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) { if (scrollable) {
return ( return (

View File

@ -33,6 +33,7 @@ const BaseInput = ({
"border border-gray-300 rounded-lg px-3 h-10 w-full", "border border-gray-300 rounded-lg px-3 h-10 w-full",
inputClassName inputClassName
)} )}
placeholderTextColor="#787878"
{...props} {...props}
/> />

View File

@ -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;