Files
nysoure/frontend/src/pages/manage_storage_page.tsx
2025-09-07 14:45:10 +08:00

552 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { Storage } from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx";
import { MdAdd, MdMoreHoriz } from "react-icons/md";
import { ErrorAlert } from "../components/alert.tsx";
import { useTranslation } from "../utils/i18n";
import { app } from "../app.ts";
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
import Badge from "../components/badge.tsx";
export default function StorageView() {
const { t } = useTranslation();
const [storages, setStorages] = useState<Storage[] | null>(null);
const [loadingId, setLoadingId] = useState<number | null>(null);
useEffect(() => {
if (app.user == null || !app.user.is_admin) {
return;
}
network.listStorages().then((response) => {
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error",
});
}
});
}, []);
if (!app.user) {
return (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
if (!app.user?.is_admin) {
return (
<ErrorAlert
className={"m-4"}
message={t("You are not authorized to access this page.")}
/>
);
}
if (storages == null) {
return <Loading />;
}
const updateStorages = async () => {
setStorages(null);
const response = await network.listStorages();
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error",
});
}
};
const handleDelete = async (id: number) => {
if (loadingId != null) {
return;
}
setLoadingId(id);
const response = await network.deleteStorage(id);
if (response.success) {
showToast({
message: t("Storage deleted successfully"),
});
updateStorages();
} else {
showToast({
message: response.message,
type: "error",
});
}
setLoadingId(null);
};
const handleSetDefault = async (id: number) => {
if (loadingId != null) {
return;
}
setLoadingId(id);
const response = await network.setDefaultStorage(id);
if (response.success) {
showToast({
message: t("Storage set as default successfully"),
});
updateStorages();
} else {
showToast({
message: response.message,
type: "error",
});
}
setLoadingId(null);
};
return (
<>
<div
role="alert"
className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
className="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>{t("No storage found. Please create a new storage.")}</span>
</div>
<div
className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto ${storages.length === 0 ? "hidden" : ""}`}
>
<table className={"table"}>
<thead>
<tr>
<td>{t("Name")}</td>
<td>{t("Created At")}</td>
<td>{t("Space")}</td>
<td>{t("Action")}</td>
</tr>
</thead>
<tbody>
{storages.map((s) => {
return (
<tr key={s.id} className={"hover"}>
<td>
{s.name}
{s.isDefault && (
<Badge className={"ml-1"}>{t("Default")}</Badge>
)}
</td>
<td>{new Date(s.createdAt).toLocaleString()}</td>
<td>
{(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
{s.maxSize / 1024 / 1024} MB
</td>
<td>
<button
id={`set_default_button_${s.id}`}
className={"btn btn-square"}
type={"button"}
onClick={() => {
showPopup(
<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
<h4 className="text-sm font-bold px-3 py-1 text-primary">
{t("Actions")}
</h4>
<PopupMenuItem
onClick={() => {
const dialog = document.getElementById(
`confirm_delete_dialog_${s.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
<a>{t("Delete")}</a>
</PopupMenuItem>
{!s.isDefault && (
<PopupMenuItem
onClick={() => {
handleSetDefault(s.id);
}}
>
<a>{t("Set as Default")}</a>
</PopupMenuItem>
)}
</ul>,
document.getElementById(
`set_default_button_${s.id}`,
)!,
);
}}
>
{loadingId === s.id ? (
<span
className={"loading loading-spinner loading-sm"}
></span>
) : (
<MdMoreHoriz size={24} />
)}
</button>
<dialog
id={`confirm_delete_dialog_${s.id}`}
className="modal"
>
<div className="modal-box">
<h3 className="text-lg font-bold">
{t("Delete Storage")}
</h3>
<p className="py-4">
{t(
"Are you sure you want to delete this storage? This action cannot be undone.",
)}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">{t("Cancel")}</button>
</form>
<button
className="btn btn-error"
onClick={() => {
handleDelete(s.id);
}}
>
{t("Delete")}
</button>
</div>
</div>
</dialog>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className={"flex flex-row-reverse px-4"}>
<NewStorageDialog onAdded={updateStorages} />
</div>
</>
);
}
enum StorageType {
local,
s3,
}
function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
const { t } = useTranslation();
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [params, setParams] = useState({
name: "",
path: "",
endPoint: "",
accessKeyID: "",
secretAccessKey: "",
bucketName: "",
maxSizeInMB: 0,
domain: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (storageType == null) {
return;
}
setIsLoading(true);
let response;
if (storageType === StorageType.local) {
if (params.path === "" || params.name === "" || params.maxSizeInMB <= 0) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createLocalStorage(
params.name,
params.path,
params.maxSizeInMB,
);
} else if (storageType === StorageType.s3) {
if (
params.endPoint === "" ||
params.accessKeyID === "" ||
params.secretAccessKey === "" ||
params.bucketName === "" ||
params.name === "" ||
params.maxSizeInMB <= 0
) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createS3Storage(
params.name,
params.endPoint,
params.accessKeyID,
params.secretAccessKey,
params.bucketName,
params.maxSizeInMB,
params.domain,
);
}
if (response!.success) {
showToast({
message: t("Storage created successfully"),
});
onAdded();
const dialog = document.getElementById(
"new_storage_dialog",
) as HTMLDialogElement;
dialog.close();
} else {
setError(response!.message);
}
setIsLoading(false);
};
return (
<>
<button
className="btn"
onClick={() => {
const dialog = document.getElementById(
"new_storage_dialog",
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdAdd />
{t("New Storage")}
</button>
<dialog id="new_storage_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg pb-4">{t("New Storage")}</h3>
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2">
<input
className="btn btn-square"
type="reset"
value="×"
onClick={() => {
setStorageType(null);
}}
/>
<input
className="btn"
type="radio"
name="type"
aria-label={t("Local")}
onInput={() => {
setStorageType(StorageType.local);
}}
/>
<input
className="btn"
type="radio"
name="type"
aria-label={t("S3")}
onInput={() => {
setStorageType(StorageType.s3);
}}
/>
</form>
{storageType === StorageType.local && (
<>
<label className="input w-full my-2">
{t("Name")}
<input
type="text"
className="w-full"
value={params.name}
onChange={(e) => {
setParams({
...params,
name: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Path")}
<input
type="text"
className="w-full"
value={params.path}
onChange={(e) => {
setParams({
...params,
path: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
});
}}
/>
</label>
</>
)}
{storageType === StorageType.s3 && (
<>
<label className="input w-full my-2">
{t("Name")}
<input
type="text"
className="w-full"
value={params.name}
onChange={(e) => {
setParams({
...params,
name: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Endpoint")}
<input
type="text"
className="w-full"
value={params.endPoint}
onChange={(e) => {
setParams({
...params,
endPoint: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Access Key ID")}
<input
type="text"
className="w-full"
value={params.accessKeyID}
onChange={(e) => {
setParams({
...params,
accessKeyID: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Secret Access Key")}
<input
type="text"
className="w-full"
value={params.secretAccessKey}
onChange={(e) => {
setParams({
...params,
secretAccessKey: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Bucket Name")}
<input
type="text"
className="w-full"
value={params.bucketName}
onChange={(e) => {
setParams({
...params,
bucketName: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Domain")}
<input
type="text"
placeholder={t("Optional")}
className="w-full"
value={params.domain}
onChange={(e) => {
setParams({
...params,
domain: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
});
}}
/>
</label>
</>
)}
{error !== "" && <ErrorAlert message={error} className={"my-2"} />}
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button
className={"btn btn-primary"}
onClick={handleSubmit}
type={"button"}
>
{isLoading && (
<span
className={"loading loading-spinner loading-sm mr-2"}
></span>
)}
{t("Submit")}
</button>
</div>
</div>
</dialog>
</>
);
}