mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-28 12:37:25 +00:00
Initial commit
This commit is contained in:
6
frontend/src/pages/home_page.tsx
Normal file
6
frontend/src/pages/home_page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import {network} from "../network/network.ts";
|
||||
|
||||
export default function HomePage() {
|
||||
return <ResourcesView loader={(page) => network.getResources(page)}></ResourcesView>
|
||||
}
|
69
frontend/src/pages/login_page.tsx
Normal file
69
frontend/src/pages/login_page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import {FormEvent, useState} from "react";
|
||||
import {network} from "../network/network.ts";
|
||||
import {app} from "../app.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
export default function LoginPage() {
|
||||
const {t} = useTranslation();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
setError(t("Username and password cannot be empty"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await network.login(username, password);
|
||||
if (res.success) {
|
||||
app.user = res.data!;
|
||||
app.token = res.data!.token;
|
||||
app.saveData();
|
||||
navigate("/", {replace: true});
|
||||
} else {
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"login-page"}>
|
||||
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className={"card-body"}>
|
||||
<h1 className={"text-2xl font-bold"}>{t("Login")}</h1>
|
||||
{error && <div role="alert" className="alert alert-error my-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>}
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Username")}</legend>
|
||||
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Password")}</legend>
|
||||
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
|
||||
</fieldset>
|
||||
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
||||
{isLoading && <span className="loading loading-spinner"></span>}
|
||||
{t("Continue")}
|
||||
</button>
|
||||
<button className="btn" type={"button"} onClick={() => {
|
||||
navigate("/register", {replace: true});
|
||||
}}>
|
||||
{t("Don't have an account? Register")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
87
frontend/src/pages/manage_page.tsx
Normal file
87
frontend/src/pages/manage_page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {app} from "../app.ts";
|
||||
import {MdMenu, MdOutlinePerson, MdOutlineStorage} from "react-icons/md";
|
||||
import {ReactNode, useEffect, useState} from "react";
|
||||
import StorageView from "./manage_storage_page.tsx";
|
||||
import UserView from "./manage_user_page.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {ErrorAlert} from "../components/alert.tsx";
|
||||
|
||||
export default function ManagePage() {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const [lg, setLg] = useState(window.innerWidth >= 1024);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setLg(window.innerWidth >= 1024);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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.")}/>
|
||||
}
|
||||
|
||||
const buildItem = (title: string, icon: ReactNode, p: number) => {
|
||||
return <li key={title} onClick={() => setPage(p)} className={"my-1"}>
|
||||
<a className={`flex items-center h-9 px-4 ${page == p && "bg-primary text-primary-content"}`}>
|
||||
{icon}
|
||||
<span className={"text"}>
|
||||
{title}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
|
||||
const pageNames = [
|
||||
t("Storage"),
|
||||
t("Users")
|
||||
]
|
||||
|
||||
const pageComponents = [
|
||||
<StorageView/>,
|
||||
<UserView/>
|
||||
]
|
||||
|
||||
return <div className="drawer lg:drawer-open">
|
||||
<input id="my-drawer-2" type="checkbox" className="drawer-toggle"/>
|
||||
<div className="drawer-content" style={{
|
||||
height: "calc(100vh - 64px)",
|
||||
}}>
|
||||
<div className={"flex w-full h-14 items-center gap-2 px-3"}>
|
||||
<label className={"btn btn-square btn-ghost lg:hidden"} htmlFor="my-drawer-2">
|
||||
<MdMenu size={24}/>
|
||||
</label>
|
||||
<h1 className={"text-xl font-bold"}>
|
||||
{pageNames[page]}
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
{pageComponents[page]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="drawer-side" style={{
|
||||
height: lg ? "calc(100vh - 64px)" : "100vh",
|
||||
}}>
|
||||
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
|
||||
<ul className="menu bg-base-100 min-h-full lg:min-h-0 w-72 px-4 lg:mt-1">
|
||||
<h2 className={"text-lg font-bold p-4"}>
|
||||
{t("Manage")}
|
||||
</h2>
|
||||
{buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"}/>, 0)}
|
||||
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 1)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
336
frontend/src/pages/manage_storage_page.tsx
Normal file
336
frontend/src/pages/manage_storage_page.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
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, MdDelete} from "react-icons/md";
|
||||
import {ErrorAlert} from "../components/alert.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function StorageView() {
|
||||
const { t } = useTranslation();
|
||||
const [storages, setStorages] = useState<Storage[] | null>(null);
|
||||
const [loadingId, setLoadingId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
network.listStorages().then((response) => {
|
||||
if (response.success) {
|
||||
setStorages(response.data!);
|
||||
} else {
|
||||
showToast({
|
||||
message: response.message,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
})
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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("Action")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
storages.map((s) => {
|
||||
return <tr key={s.id} className={"hover"}>
|
||||
<td>
|
||||
{s.name}
|
||||
</td>
|
||||
<td>
|
||||
{(new Date(s.createdAt)).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
||||
const dialog = document.getElementById(`confirm_delete_dialog_${s.id}`) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
{loadingId === s.id ? <span className={"loading loading-spinner loading-sm"}></span> : <MdDelete 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,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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("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>
|
||||
</>
|
||||
}
|
267
frontend/src/pages/manage_user_page.tsx
Normal file
267
frontend/src/pages/manage_user_page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { createRef, useCallback, useEffect, useState } from "react";
|
||||
import { User } from "../network/models";
|
||||
import { network } from "../network/network";
|
||||
import showToast from "../components/toast";
|
||||
import Loading from "../components/loading";
|
||||
import { MdMoreHoriz, MdSearch } from "react-icons/md";
|
||||
import Pagination from "../components/pagination";
|
||||
import showPopup, { PopupMenuItem } from "../components/popup";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function UserView() {
|
||||
const { t } = useTranslation();
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
return <>
|
||||
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
|
||||
<form className={"flex flex-row gap-2 items-center w-64"} onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setPage(0);
|
||||
const input = e.currentTarget.querySelector("input[type=search]") as HTMLInputElement;
|
||||
setSearchKeyword(input.value);
|
||||
}}>
|
||||
<label className="input">
|
||||
<MdSearch size={20} className="opacity-50" />
|
||||
<input type="search" className="grow" placeholder={t("Search")} id="search" />
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
|
||||
<div className={"flex flex-row justify-center items-center my-4"}>
|
||||
{totalPages ? <Pagination page={page} setPage={setPage} totalPages={totalPages} /> : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number, searchKeyword: string, totalPagesCallback: (totalPages: number) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(() => {
|
||||
if (searchKeyword) {
|
||||
network.searchUsers(searchKeyword, page).then((response) => {
|
||||
if (response.success) {
|
||||
setUsers(response.data!);
|
||||
totalPagesCallback(response.totalPages!);
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: response.message,
|
||||
})
|
||||
}
|
||||
});
|
||||
} else {
|
||||
network.listUsers(page).then((response) => {
|
||||
if (response.success) {
|
||||
setUsers(response.data!);
|
||||
totalPagesCallback(response.totalPages!);
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: response.message,
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [page, searchKeyword, totalPagesCallback]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [page, searchKeyword]);
|
||||
|
||||
const handleChanged = useCallback(async () => {
|
||||
setUsers(null);
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
if (users === null) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}>
|
||||
<table className={"table"}>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{t("Username")}</td>
|
||||
<td>{t("Created At")}</td>
|
||||
<td>{t("Admin")}</td>
|
||||
<td>{t("Can Upload")}</td>
|
||||
<td>{t("Actions")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
users.map((u) => {
|
||||
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const buttonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const res = await network.deleteUser(user.id);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: t("User deleted successfully"),
|
||||
});
|
||||
onChanged();
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const handleSetAdmin = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const res = await network.setUserAdmin(user.id, true);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: t("User set as admin successfully"),
|
||||
});
|
||||
onChanged();
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const handleSetUser = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const res = await network.setUserAdmin(user.id, false);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: t("User set as user successfully"),
|
||||
});
|
||||
onChanged();
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const handleSetUploadPermission = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const res = await network.setUserUploadPermission(user.id, true);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: t("User set as upload permission successfully"),
|
||||
});
|
||||
onChanged();
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const handleRemoveUploadPermission = async () => {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const res = await network.setUserUploadPermission(user.id, false);
|
||||
if (res.success) {
|
||||
showToast({
|
||||
type: "success",
|
||||
message: t("User removed upload permission successfully"),
|
||||
});
|
||||
onChanged();
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message,
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
return <tr key={user.id} className={"hover"}>
|
||||
<td>
|
||||
{user.username}
|
||||
</td>
|
||||
<td>
|
||||
{(new Date(user.created_at)).toLocaleDateString()}
|
||||
</td>
|
||||
<td>
|
||||
{user.is_admin ? t("Yes") : t("No")}
|
||||
</td>
|
||||
<td>
|
||||
{user.can_upload ? t("Yes") : t("No")}
|
||||
</td>
|
||||
<td>
|
||||
<div className="dropdown dropdown-bottom dropdown-end">
|
||||
<button ref={buttonRef} className="btn btn-square m-1" 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(`delete_user_dialog_${user.id}`) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>
|
||||
<a>{t("Delete")}</a>
|
||||
</PopupMenuItem>
|
||||
{user.is_admin ? <PopupMenuItem onClick={handleSetUser}><a>{t("Set as user")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetAdmin}><a>{t("Set as admin")}</a></PopupMenuItem>}
|
||||
{user.is_admin ? (
|
||||
user.can_upload ? <PopupMenuItem onClick={handleRemoveUploadPermission}><a>{t("Remove upload permission")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetUploadPermission}><a>{t("Grant upload permission")}</a></PopupMenuItem>
|
||||
) : null}
|
||||
</ul>, buttonRef.current!);
|
||||
}}>
|
||||
{isLoading
|
||||
? <span className="loading loading-spinner loading-sm"></span>
|
||||
: <MdMoreHoriz size={20} className="opacity-50" />}
|
||||
</button>
|
||||
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
|
||||
<p className="py-4">{t("Are you sure you want to delete user")} <span className="font-bold">{user.username}</span>? {t("This action cannot be undone.")}</p>
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-ghost">{t("Close")}</button>
|
||||
<button className="btn btn-error" onClick={handleDelete}>{t("Delete")}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
361
frontend/src/pages/publish_page.tsx
Normal file
361
frontend/src/pages/publish_page.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import {useRef, useState} from "react";
|
||||
import {MdAdd, MdDelete, MdOutlineInfo} from "react-icons/md";
|
||||
import {Tag} from "../network/models.ts";
|
||||
import {network} from "../network/network.ts";
|
||||
import {LuInfo} from "react-icons/lu";
|
||||
import {useNavigate} from "react-router";
|
||||
import showToast from "../components/toast.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {app} from "../app.ts";
|
||||
import {ErrorAlert} from "../components/alert.tsx";
|
||||
|
||||
export default function PublishPage() {
|
||||
const [title, setTitle] = useState<string>("")
|
||||
const [altTitles, setAltTitles] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [article, setArticle] = useState<string>("")
|
||||
const [images, setImages] = useState<number[]>([])
|
||||
const [isUploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) {
|
||||
return
|
||||
}
|
||||
if (!title) {
|
||||
setError(t("Title cannot be empty"))
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < altTitles.length; i++) {
|
||||
if (!altTitles[i]) {
|
||||
setError(t("Alternative title cannot be empty"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
setError(t("At least one tag required"))
|
||||
return
|
||||
}
|
||||
if (!article) {
|
||||
setError(t("Description cannot be empty"))
|
||||
return
|
||||
}
|
||||
const res = await network.createResource({
|
||||
title: title,
|
||||
alternative_titles: altTitles,
|
||||
tags: tags.map((tag) => tag.id),
|
||||
article: article,
|
||||
images: images,
|
||||
})
|
||||
if (res.success) {
|
||||
setSubmitting(false)
|
||||
navigate("/resources/" + res.data!, {replace: true})
|
||||
} else {
|
||||
setSubmitting(false)
|
||||
setError(res.message)
|
||||
}
|
||||
}
|
||||
|
||||
const addImage = () => {
|
||||
if (isUploading) {
|
||||
return
|
||||
}
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/*"
|
||||
input.onchange = async () => {
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) {
|
||||
return
|
||||
}
|
||||
const image = files[0]
|
||||
setUploading(true)
|
||||
const res = await network.uploadImage(image)
|
||||
if (res.success) {
|
||||
setUploading(false)
|
||||
setImages([...images, res.data!])
|
||||
} else {
|
||||
setUploading(false)
|
||||
showToast({message: t("Failed to upload image"), type: "error"})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
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.")}/>
|
||||
}
|
||||
|
||||
return <div className={"p-4"}>
|
||||
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
|
||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||
<MdOutlineInfo size={24}/>
|
||||
<span>{t("All information, images, and files can be modified after publishing")}</span>
|
||||
</div>
|
||||
<p className={"my-1"}>{t("Title")}</p>
|
||||
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)}/>
|
||||
<div className={"h-4"}></div>
|
||||
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||
{
|
||||
altTitles.map((title, index) => {
|
||||
return <div key={index} className={"flex items-center my-2"}>
|
||||
<input type="text" className="input w-full" value={title} onChange={(e) => {
|
||||
const newAltTitles = [...altTitles]
|
||||
newAltTitles[index] = e.target.value
|
||||
setAltTitles(newAltTitles)
|
||||
}}/>
|
||||
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
|
||||
const newAltTitles = [...altTitles]
|
||||
newAltTitles.splice(index, 1)
|
||||
setAltTitles(newAltTitles)
|
||||
}}>
|
||||
<MdDelete size={24}/>
|
||||
</button>
|
||||
</div>
|
||||
})
|
||||
}
|
||||
<button className={"btn my-2"} type={"button"} onClick={() => {
|
||||
setAltTitles([...altTitles, ""])
|
||||
}}>
|
||||
<MdAdd/>
|
||||
{t("Add Alternative Title")}
|
||||
</button>
|
||||
<div className={"h-2"}></div>
|
||||
<p className={"my-1"}>{t("Tags")}</p>
|
||||
<p className={"my-1 pb-1"}>
|
||||
{
|
||||
tags.map((tag, index) => {
|
||||
return <span key={index} className={"badge badge-primary mr-2"}>{tag.name}</span>
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<TagInput onAdd={(tag) => {
|
||||
setTags([...tags, tag])
|
||||
}}/>
|
||||
<div className={"h-4"}></div>
|
||||
<p className={"my-1"}>{t("Description")}</p>
|
||||
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)}/>
|
||||
<div className={"flex items-center py-1 "}>
|
||||
<MdOutlineInfo className={"inline mr-1"}/>
|
||||
<span className={"text-sm"}>{t("Use Markdown format")}</span>
|
||||
</div>
|
||||
<div className={"h-4"}></div>
|
||||
<p className={"my-1"}>{t("Images")}</p>
|
||||
<div role="alert" className="alert alert-info alert-soft my-2">
|
||||
<MdOutlineInfo size={24}/>
|
||||
<span>{t("Images will not be displayed automatically, you need to reference them in the description")}</span>
|
||||
</div>
|
||||
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
|
||||
<table className={"table"}>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{t("Preview")}</td>
|
||||
<td>{t("Link")}</td>
|
||||
<td>{t("Action")}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
images.map((image, index) => {
|
||||
return <tr key={index} className={"hover"}>
|
||||
<td>
|
||||
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"}/>
|
||||
</td>
|
||||
<td>
|
||||
{network.getImageUrl(image)}
|
||||
</td>
|
||||
<td>
|
||||
<button className={"btn btn-square"} type={"button"} onClick={() => {
|
||||
const id = images[index]
|
||||
const newImages = [...images]
|
||||
newImages.splice(index, 1)
|
||||
setImages(newImages)
|
||||
network.deleteImage(id)
|
||||
}}>
|
||||
<MdDelete size={24}/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button className={"btn my-2"} type={"button"} onClick={addImage}>
|
||||
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd/>}
|
||||
{t("Upload Image")}
|
||||
</button>
|
||||
<div className={"h-4"}></div>
|
||||
{
|
||||
error && <div role="alert" className="alert alert-error my-2 shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{t("Error")}: {error}</span>
|
||||
</div>
|
||||
}
|
||||
<div className={"flex flex-row-reverse mt-4"}>
|
||||
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
|
||||
{isSubmitting && <span className="loading loading-spinner"></span>}
|
||||
{t("Publish")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function TagInput({onAdd}: { onAdd: (tag: Tag) => void }) {
|
||||
const [keyword, setKeyword] = useState<string>("")
|
||||
const [tags, setTags] = useState<Tag[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setLoading] = useState(false)
|
||||
|
||||
const debounce = useRef(new Debounce(500))
|
||||
|
||||
const {t} = useTranslation();
|
||||
|
||||
const searchTags = async (keyword: string) => {
|
||||
if (keyword.length === 0) {
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setTags([])
|
||||
setError(null)
|
||||
const res = await network.searchTags(keyword)
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setTags(res.data!)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleChange = async (v: string) => {
|
||||
setKeyword(v)
|
||||
setTags([])
|
||||
setError(null)
|
||||
if (v.length !== 0) {
|
||||
setLoading(true)
|
||||
debounce.current.run(() => searchTags(v))
|
||||
} else {
|
||||
setLoading(false)
|
||||
debounce.current.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateTag = async (name: string) => {
|
||||
setLoading(true)
|
||||
const res = await network.createTag(name)
|
||||
if (!res.success) {
|
||||
setError(res.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
onAdd(res.data!)
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
setLoading(false)
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
}
|
||||
|
||||
let dropdownContent = <></>
|
||||
if (error) {
|
||||
dropdownContent = <div className="alert alert-error my-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
} else if (!keyword) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
<LuInfo size={20}/>
|
||||
<span className={"w-2"}/>
|
||||
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
|
||||
</div>
|
||||
} else if(isLoading) {
|
||||
dropdownContent = <div className="flex flex-row py-2 px-4">
|
||||
<span className={"loading loading-spinner loading-sm"}></span>
|
||||
<span className={"w-2"}/>
|
||||
<span className={"flex-1"}>{t("Searching...")}</span>
|
||||
</div>
|
||||
} else {
|
||||
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
|
||||
dropdownContent = <>
|
||||
{
|
||||
tags.map((t) => {
|
||||
return <li key={t.id} onClick={() => {
|
||||
onAdd(t);
|
||||
setKeyword("")
|
||||
setTags([])
|
||||
const input = document.getElementById("search_tags_input") as HTMLInputElement
|
||||
input.blur()
|
||||
}}><a>{t.name}</a></li>
|
||||
})
|
||||
}
|
||||
{
|
||||
!haveExactMatch && <li onClick={() => {
|
||||
handleCreateTag(keyword)
|
||||
}}><a>{t("Create Tag")}: {keyword}</a></li>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
return <div className={"dropdown dropdown-end"}>
|
||||
<label className="input">
|
||||
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.3-4.3"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)}/>
|
||||
</label>
|
||||
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow mt-2 border border-base-300">
|
||||
{dropdownContent}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
class Debounce {
|
||||
private timer: number | null = null
|
||||
private readonly delay: number
|
||||
|
||||
constructor(delay: number) {
|
||||
this.delay = delay
|
||||
}
|
||||
|
||||
run(callback: () => void) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
callback()
|
||||
}, this.delay)
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
78
frontend/src/pages/register_page.tsx
Normal file
78
frontend/src/pages/register_page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {FormEvent, useState} from "react";
|
||||
import {network} from "../network/network.ts";
|
||||
import {app} from "../app.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const {t} = useTranslation();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!username || !password) {
|
||||
setError(t("Username and password cannot be empty"));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError(t("Passwords do not match"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await network.register(username, password);
|
||||
if (res.success) {
|
||||
app.user = res.data!;
|
||||
app.token = res.data!.token;
|
||||
app.saveData();
|
||||
navigate("/", {replace: true});
|
||||
} else {
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
|
||||
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className={"card-body"}>
|
||||
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
|
||||
{error && <div role="alert" className="alert alert-error my-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>}
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Username")}</legend>
|
||||
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Password")}</legend>
|
||||
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
|
||||
</fieldset>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
|
||||
<input type="password" className="input w-full" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}/>
|
||||
</fieldset>
|
||||
<button className={"btn my-4 btn-primary"} type={"submit"}>
|
||||
{isLoading && <span className="loading loading-spinner"></span>}
|
||||
{t("Continue")}
|
||||
</button>
|
||||
<button className="btn" type={"button"} onClick={() => {
|
||||
navigate("/login", {replace: true});
|
||||
}}>
|
||||
{t("Already have an account? Login")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
151
frontend/src/pages/resource_details_page.tsx
Normal file
151
frontend/src/pages/resource_details_page.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useParams } from "react-router";
|
||||
import {createContext, useCallback, useEffect, useState} from "react";
|
||||
import {ResourceDetails, RFile} from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "../components/toast.ts";
|
||||
import Markdown from "react-markdown";
|
||||
import "../markdown.css";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset} from "react-icons/md";
|
||||
import {app} from "../app.ts";
|
||||
|
||||
export default function ResourcePage() {
|
||||
const params = useParams()
|
||||
|
||||
const idStr = params.id
|
||||
|
||||
const id = idStr ? parseInt(idStr) : NaN
|
||||
|
||||
const [resource, setResource] = useState<ResourceDetails | null>(null)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!isNaN(id)) {
|
||||
setResource(null)
|
||||
const res = await network.getResourceDetails(id)
|
||||
if (res.success) {
|
||||
setResource(res.data!)
|
||||
} else {
|
||||
showToast({ message: res.message, type: "error" })
|
||||
}
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNaN(id)) {
|
||||
network.getResourceDetails(id).then((res) => {
|
||||
if (res.success) {
|
||||
setResource(res.data!)
|
||||
} else {
|
||||
showToast({ message: res.message, type: "error" })
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [id])
|
||||
|
||||
if (isNaN(id)) {
|
||||
return <div className="alert alert-error shadow-lg">
|
||||
<div>
|
||||
<span>Resource ID is required</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (!resource) {
|
||||
return <Loading />
|
||||
}
|
||||
|
||||
return <context.Provider value={reload}>
|
||||
<div className={"pt-2"}>
|
||||
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
|
||||
{
|
||||
resource.alternativeTitles.map((e) => {
|
||||
return <h2 className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2>
|
||||
})
|
||||
}
|
||||
<button className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out">
|
||||
<div className="flex items-center ">
|
||||
<div className="avatar">
|
||||
<div className="w-6 rounded-full">
|
||||
<img src={network.getUserAvatar(resource.author)} alt={"avatar"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-2"></div>
|
||||
<div className="text-sm">{resource.author.username}</div>
|
||||
</div>
|
||||
</button>
|
||||
<p className={"px-4 pt-2"}>
|
||||
{
|
||||
resource.tags.map((e) => {
|
||||
return <span className="badge badge-primary mr-2">{e.name}</span>
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className="tabs tabs-box my-4 mx-2 p-4">
|
||||
<label className="tab ">
|
||||
<input type="radio" name="my_tabs" defaultChecked/>
|
||||
<MdOutlineArticle className="text-xl mr-2"/>
|
||||
<span className="text-sm">
|
||||
Description
|
||||
</span>
|
||||
</label>
|
||||
<div className="tab-content p-2">
|
||||
<Article article={resource.article} />
|
||||
</div>
|
||||
|
||||
<label className="tab">
|
||||
<input type="radio" name="my_tabs"/>
|
||||
<MdOutlineDataset className="text-xl mr-2"/>
|
||||
<span className="text-sm">
|
||||
Files
|
||||
</span>
|
||||
</label>
|
||||
<div className="tab-content p-2">
|
||||
<Files files={resource.files} />
|
||||
</div>
|
||||
|
||||
<label className="tab">
|
||||
<input type="radio" name="my_tabs"/>
|
||||
<MdOutlineComment className="text-xl mr-2"/>
|
||||
<span className="text-sm">
|
||||
Comments
|
||||
</span>
|
||||
</label>
|
||||
<div className="tab-content p-2">Comments</div>
|
||||
</div>
|
||||
<div className="h-4"></div>
|
||||
</div>
|
||||
</context.Provider>
|
||||
}
|
||||
|
||||
const context = createContext<() => void>(() => {})
|
||||
|
||||
function Article({ article }: { article: string }) {
|
||||
return <article>
|
||||
<Markdown>{article}</Markdown>
|
||||
</article>
|
||||
}
|
||||
|
||||
function FileTile({ file }: { file: RFile }) {
|
||||
// TODO: implement file tile
|
||||
return <div></div>
|
||||
}
|
||||
|
||||
function Files({files}: { files: RFile[]}) {
|
||||
return <div>
|
||||
{
|
||||
files.map((file) => {
|
||||
return <FileTile file={file} key={file.id}></FileTile>
|
||||
})
|
||||
}
|
||||
{
|
||||
app.isAdmin() && <div className={"flex flex-row-reverse"}>
|
||||
<button className={"btn btn-accent shadow"}>
|
||||
<MdAdd size={24}/>
|
||||
<span className={"text-sm"}>
|
||||
Upload
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
25
frontend/src/pages/search_page.tsx
Normal file
25
frontend/src/pages/search_page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import {useSearchParams} from "react-router";
|
||||
import {network} from "../network/network.ts";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import {useEffect} from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, _] = useSearchParams()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const keyword = params.get("keyword")
|
||||
|
||||
useEffect(() => {}, [])
|
||||
|
||||
if (keyword === null || keyword === "") {
|
||||
return <div role="alert" className="alert alert-info alert-dash">
|
||||
<span>{t("Enter a search keyword to continue")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div key={keyword}>
|
||||
<h1 className={"text-2xl px-4 pt-4 font-bold my-2"}>{t("Search")}: {keyword}</h1>
|
||||
<ResourcesView loader={(page) => network.searchResources(keyword, page)}></ResourcesView>
|
||||
</div>
|
||||
}
|
Reference in New Issue
Block a user