YAMPD/gui/app/pack/page.tsx

470 lines
13 KiB
TypeScript

"use client";
import {
DOWNLOAD_ENDPOINT,
MOD_ENDPOINT,
PACK_ENDPOINT,
PACKS_ENDPOINT,
} from "@/api/ENDPOINTS";
import { Pack } from "@/types/pack";
import {
Button,
Card,
Label,
Progress,
Spinner,
TextInput,
} from "flowbite-react";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Modal, ModalBody, ModalFooter, ModalHeader } from "flowbite-react";
import { HiDownload, HiPlusCircle, HiTrash } from "react-icons/hi";
import { ModTable } from "../components/ModTable";
import { toast } from "react-toastify";
import { io } from "socket.io-client";
const socketURL = process.env.NEXT_PUBLIC_SOCKET_URL
? process.env.NEXT_PUBLIC_SOCKET_URL
: undefined;
const socket = io(socketURL);
export default function PackPage() {
const [packData, setPackData] = useState<Pack | null>(null);
const [packDataLoading, setPackDataLoading] = useState(true);
const router = useRouter();
const id = useSearchParams().get("id") || "";
const [addModModalOpen, setAddModModalOpen] = useState(false);
const [modUrl, setModUrl] = useState("");
const [downloadModalOpen, setdownloadModalOpen] = useState(false);
const [downloadProgressFile, setDownloadProgressFile] = useState({
filename: "filename",
total: 1,
current: 1,
});
const [downloadProgressTotal, setDownloadProgressTotal] = useState(100);
const [downloadProgressCurrent, setDownloadProgressCurrent] = useState(100);
async function _getPacksData() {
const res = await fetch(PACK_ENDPOINT("getPack", id));
if (!res.ok) router.push("/404");
const data: Pack = await res.json();
if (data._id) {
setPackData(data);
setPackDataLoading(false);
}
}
useEffect(() => {
if (id) {
_getPacksData();
} else {
router.push("/404");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
function percentage(partialValue: number, totalValue: number) {
const flo = (100 * partialValue) / totalValue;
return Number(flo.toFixed(2));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onDownloadCurrent(data: any) {
switch (data.status) {
case "pending":
const progress = percentage(data.download_bytes, data.total_bytes);
setDownloadProgressCurrent(progress);
break;
case "ok":
setDownloadProgressCurrent(0);
break;
case "error":
toast.error(data.message, {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
break;
default:
console.log(`GET UNKNOWN WS STATUS: ${data.status}`);
break;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onDownloadTotal(data: any) {
const progress = percentage(data.current, data.total);
setDownloadProgressTotal(progress);
setDownloadProgressFile({
filename: data.filename,
total: data.total,
current: data.current,
});
}
socket.on("download_current", (data) => onDownloadCurrent(data));
socket.on("download_total", (data) => onDownloadTotal(data));
return () => {
socket.off("download_current", (data) => onDownloadCurrent(data));
socket.off("download_total", (data) => onDownloadTotal(data));
};
}, []);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleImageUpdate = (e: any) => {
if (!packData || !window) return;
const file = e.target.files[0];
const fileReader = new FileReader();
fileReader.onloadend = () => {
const content = fileReader.result;
fetch(`${PACK_ENDPOINT("editPackImage", packData._id)}`, {
method: "POST",
body: JSON.stringify({
image: content,
mimetype: file.type,
}),
headers: {
"content-type": "application/json",
accept: "application/json",
},
}).then(() => location.reload());
e.target.value = "";
};
fileReader.readAsDataURL(file);
};
function deletePack() {
if (!packData || !window) return;
if (window.confirm(`Delete pack ${packData.title}?`)) {
fetch(`${PACKS_ENDPOINT("deletePack", packData._id)}`);
const ur = new URL(window.location.href);
ur.searchParams.delete("id");
ur.pathname = "/";
window.location.href = ur.href;
}
}
async function addMod() {
let slug = null;
let version = null;
if (!modUrl) {
toast.error("Mod url is required", {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
let source = null;
if (modUrl.includes("modrinth.com")) {
source = "Modrinth";
} else if (modUrl.includes("curseforge.com")) {
source = "CurseForge";
} else {
toast.error("unknown source", {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
switch (source) {
case "Modrinth":
const _tmp = modUrl.split("/mod/");
if (_tmp.length == 1) {
toast.error("invalid Modrinth url", {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
const _tmp2 = _tmp[1].split("/version/");
slug = _tmp2[0];
if (_tmp2.length > 1) {
version = _tmp2[1];
}
break;
case "CurseForge":
const _tmp3 = modUrl.split("/mc-mods/");
if (_tmp3.length == 1) {
toast.error("invalid CurseForge url", {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
const _tmp4 = _tmp3[1].split("/files/");
slug = _tmp4[0];
if (_tmp4.length > 1) {
version = _tmp4[1];
}
break;
default:
toast.error("failed to parse url", {
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
slug = slug.replace("/", "");
version = version ? version.replace("/", "") : null;
if (!packData) return;
const tid = toast.loading(`Adding mod`);
const res = await fetch(MOD_ENDPOINT("addMod", packData._id), {
method: "POST",
body: JSON.stringify({
slug,
version,
source,
}),
headers: {
"content-type": "application/json",
accept: "application/json",
},
});
const data = await res.json();
if (data.status != "ok") {
toast.update(tid, {
render: data.message,
type: "error",
isLoading: false,
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
return;
}
toast.update(tid, {
render: data.message,
type: "success",
isLoading: false,
autoClose: 2500,
closeOnClick: true,
draggable: true,
});
// setModUrl({ ...modUrl, value: "" });
_getPacksData();
}
async function downloadPack() {
if (!packData) return;
fetch(`${DOWNLOAD_ENDPOINT["downloadPack"]}`, {
method: "POST",
body: JSON.stringify({
pack_id: packData._id,
}),
headers: {
"content-type": "application/json",
accept: "application/json",
},
});
setdownloadModalOpen(true);
}
async function downloadMods(mods: string[]) {
if (!packData) return;
fetch(`${DOWNLOAD_ENDPOINT["downloadMods"]}`, {
method: "POST",
body: JSON.stringify({
pack_id: packData._id,
mods: mods,
}),
headers: {
"content-type": "application/json",
accept: "application/json",
},
});
setdownloadModalOpen(true);
}
return (
<div>
{packDataLoading && (
<div className="w-full flex justify-center items-center">
<Spinner></Spinner>
</div>
)}
{packData && (
<div>
<Card className="sticky top-0 left-0 right-0 z-10">
<div className="flex gap-4 items-center justify-between">
<div>
<p className="text-xl font-semibold">{packData.version}</p>
<p className="text-sm text-gray-400">{packData.modloader}</p>
</div>
<div className="flex gap-2 items-center">
<label htmlFor="pack-icon" className="cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt=""
src={PACK_ENDPOINT("getPackImage", packData._id)}
className="w-12 h-12 rounded-md"
/>
</label>
<input
onChange={(e) => handleImageUpdate(e)}
id="pack-icon"
className="hidden"
name="image"
type="file"
/>
<div>
<p className="text-xl font-semibold">{packData.title}</p>
<p className="text-sm text-gray-400">by {packData.author}</p>
</div>
</div>
<div className="flex gap-2 items-center">
<div>
<p className="text-lg font-semibold">
{packData.mods.length} mods
</p>
<p className="text-sm text-gray-400">
v{packData.modpackVersion}
</p>
</div>
<Button onClick={() => setAddModModalOpen(true)}>
Add mod <HiPlusCircle className="ml-2 h-5 w-5" />
</Button>
<Button onClick={() => downloadPack()}>
Download <HiDownload className="ml-2 h-5 w-5" />
</Button>
<Button color={"red"} onClick={() => deletePack()}>
Delete <HiTrash className="ml-2 h-5 w-5" />
</Button>
</div>
</div>
</Card>
<div className="mt-4">
<ModTable
mods={packData.mods}
updatePack={_getPacksData}
downloadMods={downloadMods}
packID={id}
/>
</div>
</div>
)}
<Modal
dismissible
show={addModModalOpen}
onClose={() => setAddModModalOpen(false)}
>
<ModalHeader>Add a mod</ModalHeader>
<ModalBody>
<div className="space-y-6">
<div className="flex-1">
<div className="mb-2 block">
<Label htmlFor="mod_url" className="text-lg">
Link
</Label>
</div>
<TextInput
id="mod_url"
type="text"
sizing="md"
name="mod_url"
onChange={(e) => setModUrl(e.target.value)}
value={modUrl}
required
/>
<p className="mt-2 text-gray-400">Can be one of:</p>
<ul className="text-gray-400 list-disc">
<li className="ml-4">
https://modrinth.com/mod/[slug][/version/file_id]
</li>
<li className="ml-4">
https://www.curseforge.com/minecraft/mc-mods/[slug][/files/file_id]
</li>
</ul>
</div>
</div>
</ModalBody>
<ModalFooter>
<Button onClick={() => addMod()}>Save</Button>
</ModalFooter>
</Modal>
<Modal
show={downloadModalOpen}
dismissible={downloadProgressFile.current == downloadProgressFile.total}
onClose={() => {
if (downloadProgressFile.current == downloadProgressFile.total) {
setdownloadModalOpen(false);
}
}}
>
<ModalHeader>Download progress</ModalHeader>
<ModalBody>
<div className="mb-4">
<Progress
progress={downloadProgressTotal}
progressLabelPosition="inside"
textLabel="Total"
textLabelPosition="outside"
size="lg"
labelProgress
labelText
/>
<div className="flex justify-end items-center mb-2">
<p>
{downloadProgressFile.current} / {downloadProgressFile.total}
</p>
</div>
</div>
<div>
<Progress
progress={downloadProgressCurrent}
progressLabelPosition="inside"
textLabel={downloadProgressFile.filename}
textLabelPosition="outside"
size="lg"
labelProgress
labelText
/>
</div>
{downloadProgressFile.current == downloadProgressFile.total ? (
<p>
Mods downloaded, You can find them in path: packs/{packData?._id}
/mods
</p>
) : (
""
)}
</ModalBody>
<ModalFooter>
<Button
disabled={
downloadProgressFile.current != downloadProgressFile.total
}
onClick={() => setdownloadModalOpen(false)}
>
Close
</Button>
</ModalFooter>
</Modal>
</div>
);
}