mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-28 04:27:24 +00:00
format
This commit is contained in:
@@ -1,38 +1,55 @@
|
||||
import Markdown from "react-markdown";
|
||||
import {app} from "../app.ts";
|
||||
import {ReactElement, ReactNode} from "react";
|
||||
import { app } from "../app.ts";
|
||||
import { ReactElement, ReactNode } from "react";
|
||||
|
||||
export default function AboutPage() {
|
||||
return <article className={"p-4"}>
|
||||
<Markdown components={{
|
||||
"a": ({node, ...props}) => {
|
||||
const href = props.href as string
|
||||
// @ts-ignore
|
||||
if (props.children?.length === 2) {
|
||||
// @ts-ignore
|
||||
const first = props.children[0] as ReactNode
|
||||
// @ts-ignore
|
||||
const second = props.children[1] as ReactNode
|
||||
|
||||
if (typeof first === "object" && (typeof second === "string" || typeof second === "object")) {
|
||||
const img = first as ReactElement
|
||||
return (
|
||||
<article className={"p-4"}>
|
||||
<Markdown
|
||||
components={{
|
||||
a: ({ node, ...props }) => {
|
||||
const href = props.href as string;
|
||||
// @ts-ignore
|
||||
if (img.type === "img") {
|
||||
return <a className={"inline-block card card-border border-base-300 no-underline bg-base-200 hover:shadow transition-shadow"} target={"_blank"} href={href}>
|
||||
<figure className={"max-h-72 max-w-96"}>
|
||||
{img}
|
||||
</figure>
|
||||
<div className={"card-body text-base-content text-lg"}>
|
||||
{second}
|
||||
</div>
|
||||
</a>
|
||||
if (props.children?.length === 2) {
|
||||
// @ts-ignore
|
||||
const first = props.children[0] as ReactNode;
|
||||
// @ts-ignore
|
||||
const second = props.children[1] as ReactNode;
|
||||
|
||||
if (
|
||||
typeof first === "object" &&
|
||||
(typeof second === "string" || typeof second === "object")
|
||||
) {
|
||||
const img = first as ReactElement;
|
||||
// @ts-ignore
|
||||
if (img.type === "img") {
|
||||
return (
|
||||
<a
|
||||
className={
|
||||
"inline-block card card-border border-base-300 no-underline bg-base-200 hover:shadow transition-shadow"
|
||||
}
|
||||
target={"_blank"}
|
||||
href={href}
|
||||
>
|
||||
<figure className={"max-h-72 max-w-96"}>{img}</figure>
|
||||
<div className={"card-body text-base-content text-lg"}>
|
||||
{second}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return <a href={href} target={"_blank"}>{props.children}</a>
|
||||
}
|
||||
}}>
|
||||
{app.siteInfo}
|
||||
</Markdown>
|
||||
</article>
|
||||
}
|
||||
return (
|
||||
<a href={href} target={"_blank"}>
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{app.siteInfo}
|
||||
</Markdown>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
@@ -1,263 +1,330 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md";
|
||||
import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
|
||||
import { Tag } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import {useNavigate, useParams} from "react-router";
|
||||
import { useNavigate, useParams } 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";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
||||
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
||||
import TagInput, { QuickAddTagDialog } from "../components/tag_input.tsx";
|
||||
import {
|
||||
ImageDrapArea,
|
||||
SelectAndUploadImageButton,
|
||||
UploadClipboardImageButton,
|
||||
} from "../components/image_selector.tsx";
|
||||
|
||||
export default function EditResourcePage() {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
const [isLoading, setLoading] = useState(true)
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Edit Resource");
|
||||
}, [t])
|
||||
|
||||
const {rid} = useParams()
|
||||
const id = parseInt(rid || "")
|
||||
}, [t]);
|
||||
|
||||
const { rid } = useParams();
|
||||
const id = parseInt(rid || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (isNaN(id)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
network.getResourceDetails(id).then((res) => {
|
||||
if (res.success) {
|
||||
const data = res.data!
|
||||
setTitle(data.title)
|
||||
setAltTitles(data.alternativeTitles)
|
||||
setTags(data.tags)
|
||||
setArticle(data.article)
|
||||
setImages(data.images.map(i => i.id))
|
||||
setLoading(false)
|
||||
const data = res.data!;
|
||||
setTitle(data.title);
|
||||
setAltTitles(data.alternativeTitles);
|
||||
setTags(data.tags);
|
||||
setArticle(data.article);
|
||||
setImages(data.images.map((i) => i.id));
|
||||
setLoading(false);
|
||||
} else {
|
||||
showToast({ message: t("Failed to load resource"), type: "error" })
|
||||
showToast({ message: t("Failed to load resource"), type: "error" });
|
||||
}
|
||||
})
|
||||
});
|
||||
}, [id, t]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (!title) {
|
||||
setError(t("Title cannot be empty"))
|
||||
return
|
||||
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
|
||||
setError(t("Alternative title cannot be empty"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
setError(t("At least one tag required"))
|
||||
return
|
||||
setError(t("At least one tag required"));
|
||||
return;
|
||||
}
|
||||
if (!article) {
|
||||
setError(t("Description cannot be empty"))
|
||||
return
|
||||
setError(t("Description cannot be empty"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true)
|
||||
setSubmitting(true);
|
||||
const res = await network.editResource(id, {
|
||||
title: title,
|
||||
alternative_titles: altTitles,
|
||||
tags: tags.map((tag) => tag.id),
|
||||
article: article,
|
||||
images: images,
|
||||
})
|
||||
});
|
||||
if (res.success) {
|
||||
setSubmitting(false)
|
||||
navigate("/resources/" + id.toString(), { replace: true })
|
||||
setSubmitting(false);
|
||||
navigate("/resources/" + id.toString(), { replace: true });
|
||||
} else {
|
||||
setSubmitting(false)
|
||||
setError(res.message)
|
||||
setSubmitting(false);
|
||||
setError(res.message);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
if (isNaN(id)) {
|
||||
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />
|
||||
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />;
|
||||
}
|
||||
|
||||
if (!app.user) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading/>
|
||||
return (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not logged in. Please log in to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ImageDrapArea onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}>
|
||||
<div className={"p-4"}>
|
||||
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||
<MdOutlineInfo size={24} />
|
||||
<span>{t("All information 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 text-sm"}>
|
||||
{tag.name}
|
||||
<span onClick={() => {
|
||||
const newTags = [...tags]
|
||||
newTags.splice(index, 1)
|
||||
setTags(newTags)
|
||||
}}>
|
||||
<MdClose size={18}/>
|
||||
</span>
|
||||
</span>
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className={"flex items-center"}>
|
||||
<TagInput onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find(t => t.id === tag.id);
|
||||
if (existingTag) {
|
||||
return prev; // If the tag already exists, do not add it again
|
||||
}
|
||||
return [...prev, tag];
|
||||
})
|
||||
}} />
|
||||
<span className={"w-4"}/>
|
||||
<QuickAddTagDialog onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find(t => t.id === tag.id);
|
||||
if (!existingTag) {
|
||||
newTags.push(tag);
|
||||
}
|
||||
}
|
||||
return newTags;
|
||||
})
|
||||
}}/>
|
||||
</div>
|
||||
<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} />
|
||||
<div>
|
||||
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
||||
<p>{t("The first image will be used as the cover image")}</p>
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageDrapArea
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
>
|
||||
<div className={"p-4"}>
|
||||
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
|
||||
<div role="alert" className="alert alert-info mb-2 alert-dash">
|
||||
<MdOutlineInfo size={24} />
|
||||
<span>{t("All information can be modified after publishing")}</span>
|
||||
</div>
|
||||
</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>
|
||||
<div className={"flex"}>
|
||||
<SelectAndUploadImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
</div>
|
||||
<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")}
|
||||
<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 text-sm"}>
|
||||
{tag.name}
|
||||
<span
|
||||
onClick={() => {
|
||||
const newTags = [...tags];
|
||||
newTags.splice(index, 1);
|
||||
setTags(newTags);
|
||||
}}
|
||||
>
|
||||
<MdClose size={18} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex items-center"}>
|
||||
<TagInput
|
||||
onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find((t) => t.id === tag.id);
|
||||
if (existingTag) {
|
||||
return prev; // If the tag already exists, do not add it again
|
||||
}
|
||||
return [...prev, tag];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"} />
|
||||
<QuickAddTagDialog
|
||||
onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find((t) => t.id === tag.id);
|
||||
if (!existingTag) {
|
||||
newTags.push(tag);
|
||||
}
|
||||
}
|
||||
return newTags;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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} />
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
)}
|
||||
</p>
|
||||
<p>{t("The first image will be used as the cover image")}</p>
|
||||
</div>
|
||||
</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>
|
||||
<div className={"flex"}>
|
||||
<SelectAndUploadImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</ImageDrapArea>
|
||||
</ImageDrapArea>
|
||||
);
|
||||
}
|
||||
|
@@ -1,26 +1,26 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import {network} from "../network/network.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { app } from "../app.ts";
|
||||
import {RSort} from "../network/models.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {useAppContext} from "../components/AppContext.tsx";
|
||||
import { RSort } from "../network/models.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppContext } from "../components/AppContext.tsx";
|
||||
|
||||
export default function HomePage() {
|
||||
useEffect(() => {
|
||||
document.title = app.appName;
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const {t} = useTranslation()
|
||||
|
||||
const appContext = useAppContext()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appContext = useAppContext();
|
||||
|
||||
const [order, setOrder] = useState(() => {
|
||||
if (appContext && appContext.get("home_page_order") !== undefined) {
|
||||
return appContext.get("home_page_order");
|
||||
}
|
||||
return RSort.TimeDesc;
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (appContext && order !== RSort.TimeDesc) {
|
||||
@@ -28,37 +28,43 @@ export default function HomePage() {
|
||||
}
|
||||
}, [appContext, order]);
|
||||
|
||||
return <>
|
||||
<div className={"flex p-4 items-center"}>
|
||||
<select value={order} className="select w-52 select-info" onInput={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === "0") {
|
||||
setOrder(RSort.TimeAsc);
|
||||
} else if (value === "1") {
|
||||
setOrder(RSort.TimeDesc);
|
||||
} else if (value === "2") {
|
||||
setOrder(RSort.ViewsAsc);
|
||||
} else if (value === "3") {
|
||||
setOrder(RSort.ViewsDesc);
|
||||
} else if (value === "4") {
|
||||
setOrder(RSort.DownloadsAsc);
|
||||
} else if (value === "5") {
|
||||
setOrder(RSort.DownloadsDesc);
|
||||
}
|
||||
}}>
|
||||
<option disabled>{t("Select a Order")}</option>
|
||||
<option value="0">{t("Time Ascending")}</option>
|
||||
<option value="1">{t("Time Descending")}</option>
|
||||
<option value="2">{t("Views Ascending")}</option>
|
||||
<option value="3">{t("Views Descending")}</option>
|
||||
<option value="4">{t("Downloads Ascending")}</option>
|
||||
<option value="5">{t("Downloads Descending")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<ResourcesView
|
||||
key={`home_page_${order}`}
|
||||
storageKey={`home_page_${order}`}
|
||||
loader={(page) => network.getResources(page, order)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={"flex p-4 items-center"}>
|
||||
<select
|
||||
value={order}
|
||||
className="select w-52 select-info"
|
||||
onInput={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
if (value === "0") {
|
||||
setOrder(RSort.TimeAsc);
|
||||
} else if (value === "1") {
|
||||
setOrder(RSort.TimeDesc);
|
||||
} else if (value === "2") {
|
||||
setOrder(RSort.ViewsAsc);
|
||||
} else if (value === "3") {
|
||||
setOrder(RSort.ViewsDesc);
|
||||
} else if (value === "4") {
|
||||
setOrder(RSort.DownloadsAsc);
|
||||
} else if (value === "5") {
|
||||
setOrder(RSort.DownloadsDesc);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option disabled>{t("Select a Order")}</option>
|
||||
<option value="0">{t("Time Ascending")}</option>
|
||||
<option value="1">{t("Time Descending")}</option>
|
||||
<option value="2">{t("Views Ascending")}</option>
|
||||
<option value="3">{t("Views Descending")}</option>
|
||||
<option value="4">{t("Downloads Ascending")}</option>
|
||||
<option value="5">{t("Downloads Descending")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<ResourcesView
|
||||
key={`home_page_${order}`}
|
||||
storageKey={`home_page_${order}`}
|
||||
loader={(page) => network.getResources(page, order)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import {network} from "../network/network.ts";
|
||||
import {app} from "../app.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import { FormEvent, useEffect, 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 { t } = useTranslation();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -25,7 +25,7 @@ export default function LoginPage() {
|
||||
app.user = res.data!;
|
||||
app.token = res.data!.token;
|
||||
app.saveData();
|
||||
navigate("/", {replace: true});
|
||||
navigate("/", { replace: true });
|
||||
} else {
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
@@ -34,40 +34,69 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Login");
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
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>
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -3,7 +3,11 @@ import { app } from "../app";
|
||||
import { ErrorAlert } from "../components/alert";
|
||||
import { network } from "../network/network";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { MdOutlineAccountCircle, MdLockOutline, MdOutlineEditNote } from "react-icons/md";
|
||||
import {
|
||||
MdOutlineAccountCircle,
|
||||
MdLockOutline,
|
||||
MdOutlineEditNote,
|
||||
} from "react-icons/md";
|
||||
import Button from "../components/button";
|
||||
import showToast from "../components/toast";
|
||||
import { useNavigator } from "../components/navigator";
|
||||
@@ -13,26 +17,44 @@ export function ManageMePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!app.user) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||
return (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not logged in. Please log in to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="px-2">
|
||||
<ChangeAvatarDialog />
|
||||
<ChangeUsernameDialog />
|
||||
<ChangePasswordDialog />
|
||||
<ChangeBioDialog />
|
||||
</div>;
|
||||
return (
|
||||
<div className="px-2">
|
||||
<ChangeAvatarDialog />
|
||||
<ChangeUsernameDialog />
|
||||
<ChangePasswordDialog />
|
||||
<ChangeBioDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListTile({ title, icon, onClick }: { title: string, icon: ReactNode, onClick: () => void }) {
|
||||
return <div className="flex flex-row items-center h-12 px-2 bg-base-100 hover:bg-gray-200 cursor-pointer duration-200" onClick={onClick}>
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="text-2xl">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="ml-2">{title}</span>
|
||||
function ListTile({
|
||||
title,
|
||||
icon,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-row items-center h-12 px-2 bg-base-100 hover:bg-gray-200 cursor-pointer duration-200"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<span className="ml-2">{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeAvatarDialog() {
|
||||
@@ -73,41 +95,68 @@ function ChangeAvatarDialog() {
|
||||
showToast({
|
||||
message: t("Avatar changed successfully"),
|
||||
type: "success",
|
||||
})
|
||||
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
|
||||
});
|
||||
const dialog = document.getElementById(
|
||||
"change_avatar_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdOutlineAccountCircle />} title={t("Change Avatar")} onClick={() => {
|
||||
const dialog = document.getElementById("change_avatar_dialog") as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}} />
|
||||
<dialog id="change_avatar_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<div className="avatar">
|
||||
<div className="w-28 rounded-full cursor-pointer" onClick={selectAvatar}>
|
||||
<img src={avatar ? URL.createObjectURL(avatar) : network.getUserAvatar(app.user!)} alt={"avatar"} />
|
||||
return (
|
||||
<>
|
||||
<ListTile
|
||||
icon={<MdOutlineAccountCircle />}
|
||||
title={t("Change Avatar")}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"change_avatar_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<dialog id="change_avatar_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Avatar")}</h3>
|
||||
<div className="h-48 flex items-center justify-center">
|
||||
<div className="avatar">
|
||||
<div
|
||||
className="w-28 rounded-full cursor-pointer"
|
||||
onClick={selectAvatar}
|
||||
>
|
||||
<img
|
||||
src={
|
||||
avatar
|
||||
? URL.createObjectURL(avatar)
|
||||
: network.getUserAvatar(app.user!)
|
||||
}
|
||||
alt={"avatar"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} className={"m-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={avatar == null}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} className={"m-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button className="btn-primary" onClick={handleSubmit} isLoading={isLoading} disabled={avatar == null}>{t("Save")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameDialog() {
|
||||
@@ -135,7 +184,9 @@ function ChangeUsernameDialog() {
|
||||
message: t("Username changed successfully"),
|
||||
type: "success",
|
||||
});
|
||||
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
"change_username_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
@@ -144,44 +195,50 @@ function ChangeUsernameDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Username")} onClick={() => {
|
||||
const dialog = document.getElementById("change_username_dialog") as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}} />
|
||||
<dialog id="change_username_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
|
||||
<div className="input mt-4 w-full">
|
||||
<label className="label">
|
||||
{t("New Username")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("Enter new username")}
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
/>
|
||||
return (
|
||||
<>
|
||||
<ListTile
|
||||
icon={<MdOutlineEditNote />}
|
||||
title={t("Change Username")}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"change_username_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<dialog id="change_username_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Username")}</h3>
|
||||
<div className="input mt-4 w-full">
|
||||
<label className="label">{t("New Username")}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t("Enter new username")}
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!newUsername.trim()}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!newUsername.trim()}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePasswordDialog() {
|
||||
@@ -220,17 +277,19 @@ function ChangePasswordDialog() {
|
||||
// Update the token as it might have changed
|
||||
app.token = res.data!.token;
|
||||
app.user = res.data!;
|
||||
|
||||
|
||||
showToast({
|
||||
message: t("Password changed successfully"),
|
||||
type: "success",
|
||||
});
|
||||
|
||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
||||
|
||||
const dialog = document.getElementById(
|
||||
"change_password_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
|
||||
// Reset form
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
@@ -239,68 +298,78 @@ function ChangePasswordDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdLockOutline />} title={t("Change Password")} onClick={() => {
|
||||
const dialog = document.getElementById("change_password_dialog") as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}} />
|
||||
<dialog id="change_password_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Current Password")}</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Enter current password")}
|
||||
value={oldPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("New Password")}</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Enter new password")}
|
||||
value={newPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
return (
|
||||
<>
|
||||
<ListTile
|
||||
icon={<MdLockOutline />}
|
||||
title={t("Change Password")}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"change_password_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<dialog id="change_password_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg mb-2">{t("Change Password")}</h3>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Confirm New Password")}</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Confirm new password")}
|
||||
value={confirmPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!oldPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("Current Password")}</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Enter current password")}
|
||||
value={oldPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">{t("New Password")}</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Enter new password")}
|
||||
value={newPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">
|
||||
{t("Confirm New Password")}
|
||||
</legend>
|
||||
<input
|
||||
type="password"
|
||||
placeholder={t("Confirm new password")}
|
||||
value={confirmPassword}
|
||||
className="input w-full"
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!oldPassword || !newPassword || !confirmPassword}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeBioDialog() {
|
||||
@@ -329,7 +398,9 @@ function ChangeBioDialog() {
|
||||
message: t("Bio changed successfully"),
|
||||
type: "success",
|
||||
});
|
||||
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
"change_bio_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
@@ -338,32 +409,44 @@ function ChangeBioDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<ListTile icon={<MdOutlineEditNote />} title={t("Change Bio")} onClick={() => {
|
||||
const dialog = document.getElementById("change_bio_dialog") as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}} />
|
||||
<dialog id="change_bio_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
|
||||
<Input value={bio} onChange={(e) => setBio(e.target.value)} label={"bio"} />
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!bio.trim()}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
return (
|
||||
<>
|
||||
<ListTile
|
||||
icon={<MdOutlineEditNote />}
|
||||
title={t("Change Bio")}
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"change_bio_dialog",
|
||||
) as HTMLDialogElement;
|
||||
if (dialog) {
|
||||
dialog.showModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<dialog id="change_bio_dialog" className="modal">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold text-lg">{t("Change Bio")}</h3>
|
||||
<Input
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
label={"bio"}
|
||||
/>
|
||||
{error && <ErrorAlert message={error} className={"mt-4"} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button>{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
className="btn-primary"
|
||||
onClick={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
disabled={!bio.trim()}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,9 @@
|
||||
import { MdMenu, MdOutlineBadge, MdOutlinePerson, MdOutlineStorage } from "react-icons/md";
|
||||
import {
|
||||
MdMenu,
|
||||
MdOutlineBadge,
|
||||
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";
|
||||
@@ -26,70 +31,89 @@ export default function ManagePage() {
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Manage");
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const buildItem = (title: string, icon: ReactNode, p: number) => {
|
||||
return <li key={title} onClick={() => {
|
||||
setPage(p);
|
||||
const checkbox = document.getElementById("my-drawer-2") as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}} 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>
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={title}
|
||||
onClick={() => {
|
||||
setPage(p);
|
||||
const checkbox = document.getElementById(
|
||||
"my-drawer-2",
|
||||
) as HTMLInputElement;
|
||||
if (checkbox) {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
}}
|
||||
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("My Info"),
|
||||
t("Storage"),
|
||||
t("Users"),
|
||||
t("Server"),
|
||||
]
|
||||
const pageNames = [t("My Info"), t("Storage"), t("Users"), t("Server")];
|
||||
|
||||
const pageComponents = [
|
||||
<ManageMePage />,
|
||||
<StorageView />,
|
||||
<UserView />,
|
||||
<ManageServerConfigPage />,
|
||||
]
|
||||
];
|
||||
|
||||
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>
|
||||
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>
|
||||
{pageComponents[page]}
|
||||
<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("My Info"), <MdOutlineBadge className={"text-xl"} />, 0)}
|
||||
{buildItem(
|
||||
t("Storage"),
|
||||
<MdOutlineStorage className={"text-xl"} />,
|
||||
1,
|
||||
)}
|
||||
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"} />, 2)}
|
||||
{buildItem(
|
||||
t("Server"),
|
||||
<MdOutlineStorage className={"text-xl"} />,
|
||||
3,
|
||||
)}
|
||||
</ul>
|
||||
</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("My Info"), <MdOutlineBadge className={"text-xl"} />, 0)}
|
||||
{buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"} />, 1)}
|
||||
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"} />, 2)}
|
||||
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"} />, 3)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { app } from "../app"
|
||||
import { ErrorAlert, InfoAlert } from "../components/alert"
|
||||
import { app } from "../app";
|
||||
import { ErrorAlert, InfoAlert } from "../components/alert";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ServerConfig } from "../network/models";
|
||||
import Loading from "../components/loading";
|
||||
import Input, {TextArea} from "../components/input";
|
||||
import Input, { TextArea } from "../components/input";
|
||||
import { network } from "../network/network";
|
||||
import showToast from "../components/toast";
|
||||
import Button from "../components/button";
|
||||
@@ -24,21 +24,31 @@ export default function ManageServerConfigPage() {
|
||||
showToast({
|
||||
message: res.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.")} />
|
||||
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 (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not authorized to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (config == null) {
|
||||
return <Loading />
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
@@ -62,40 +72,107 @@ export default function ManageServerConfigPage() {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return <form className="px-4 pb-4" onSubmit={handleSubmit}>
|
||||
<Input type="number" value={config.max_uploading_size_in_mb.toString()} label="Max uploading size (MB)" onChange={(e) => {
|
||||
setConfig({...config, max_uploading_size_in_mb: parseInt(e.target.value) })
|
||||
}}></Input>
|
||||
<Input type="number" value={config.max_file_size_in_mb.toString()} label="Max file size (MB)" onChange={(e) => {
|
||||
setConfig({...config, max_file_size_in_mb: parseInt(e.target.value) })
|
||||
}}></Input>
|
||||
<Input type="number" value={config.max_downloads_per_day_for_single_ip.toString()} label="Max downloads per day for single IP" onChange={(e) => {
|
||||
setConfig({...config, max_downloads_per_day_for_single_ip: parseInt(e.target.value) })
|
||||
}}></Input>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">Allow register</legend>
|
||||
<input type="checkbox" checked={config.allow_register} className="toggle-primary toggle" onChange={(e) => {
|
||||
setConfig({ ...config, allow_register: e.target.checked })
|
||||
}} />
|
||||
</fieldset>
|
||||
<Input type="text" value={config.server_name} label="Server name" onChange={(e) => {
|
||||
setConfig({...config, server_name: e.target.value })
|
||||
}}></Input>
|
||||
<Input type="text" value={config.server_description} label="Server description" onChange={(e) => {
|
||||
setConfig({...config, server_description: e.target.value })
|
||||
}}></Input>
|
||||
<Input type="text" value={config.cloudflare_turnstile_site_key} label="Cloudflare Turnstile Site Key" onChange={(e) => {
|
||||
setConfig({...config, cloudflare_turnstile_site_key: e.target.value })
|
||||
}}></Input>
|
||||
<Input type="text" value={config.cloudflare_turnstile_secret_key} label="Cloudflare Turnstile Secret Key" onChange={(e) => {
|
||||
setConfig({...config, cloudflare_turnstile_secret_key: e.target.value })
|
||||
}}></Input>
|
||||
<TextArea value={config.site_info} onChange={(e) => {
|
||||
setConfig({...config, site_info: e.target.value })
|
||||
}} label="Site info (Markdown)" height={180} />
|
||||
<InfoAlert className="my-2" message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download." />
|
||||
<div className="flex justify-end">
|
||||
<Button className="btn-accent shadow" isLoading={isLoading}>{t("Submit")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
return (
|
||||
<form className="px-4 pb-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.max_uploading_size_in_mb.toString()}
|
||||
label="Max uploading size (MB)"
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
max_uploading_size_in_mb: parseInt(e.target.value),
|
||||
});
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.max_file_size_in_mb.toString()}
|
||||
label="Max file size (MB)"
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
max_file_size_in_mb: parseInt(e.target.value),
|
||||
});
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.max_downloads_per_day_for_single_ip.toString()}
|
||||
label="Max downloads per day for single IP"
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
max_downloads_per_day_for_single_ip: parseInt(e.target.value),
|
||||
});
|
||||
}}
|
||||
></Input>
|
||||
<fieldset className="fieldset w-full">
|
||||
<legend className="fieldset-legend">Allow register</legend>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.allow_register}
|
||||
className="toggle-primary toggle"
|
||||
onChange={(e) => {
|
||||
setConfig({ ...config, allow_register: e.target.checked });
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.server_name}
|
||||
label="Server name"
|
||||
onChange={(e) => {
|
||||
setConfig({ ...config, server_name: e.target.value });
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.server_description}
|
||||
label="Server description"
|
||||
onChange={(e) => {
|
||||
setConfig({ ...config, server_description: e.target.value });
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.cloudflare_turnstile_site_key}
|
||||
label="Cloudflare Turnstile Site Key"
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
cloudflare_turnstile_site_key: e.target.value,
|
||||
});
|
||||
}}
|
||||
></Input>
|
||||
<Input
|
||||
type="text"
|
||||
value={config.cloudflare_turnstile_secret_key}
|
||||
label="Cloudflare Turnstile Secret Key"
|
||||
onChange={(e) => {
|
||||
setConfig({
|
||||
...config,
|
||||
cloudflare_turnstile_secret_key: e.target.value,
|
||||
});
|
||||
}}
|
||||
></Input>
|
||||
<TextArea
|
||||
value={config.site_info}
|
||||
onChange={(e) => {
|
||||
setConfig({ ...config, site_info: e.target.value });
|
||||
}}
|
||||
label="Site info (Markdown)"
|
||||
height={180}
|
||||
/>
|
||||
<InfoAlert
|
||||
className="my-2"
|
||||
message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download."
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button className="btn-accent shadow" isLoading={isLoading}>
|
||||
{t("Submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
@@ -23,36 +23,46 @@ export default function StorageView() {
|
||||
} else {
|
||||
showToast({
|
||||
message: response.message,
|
||||
type: "error"
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!app.user) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||
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 (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not authorized to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (storages == null) {
|
||||
return <Loading />
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const updateStorages = async () => {
|
||||
setStorages(null)
|
||||
setStorages(null);
|
||||
const response = await network.listStorages();
|
||||
if (response.success) {
|
||||
setStorages(response.data!);
|
||||
} else {
|
||||
showToast({
|
||||
message: response.message,
|
||||
type: "error"
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (loadingId != null) {
|
||||
@@ -68,82 +78,114 @@ export default function StorageView() {
|
||||
} else {
|
||||
showToast({
|
||||
message: response.message,
|
||||
type: "error"
|
||||
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}
|
||||
</td>
|
||||
<td>
|
||||
{(new Date(s.createdAt)).toLocaleString()}
|
||||
</td>
|
||||
<td>
|
||||
{(s.currentSize / 1024 / 1024).toFixed(2)} / {s.maxSize / 1024 / 1024} MB
|
||||
</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>
|
||||
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}</td>
|
||||
<td>{new Date(s.createdAt).toLocaleString()}</td>
|
||||
<td>
|
||||
{(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
|
||||
{s.maxSize / 1024 / 1024} MB
|
||||
</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>
|
||||
</div>
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={"flex flex-row-reverse px-4"}>
|
||||
<NewStorageDialog onAdded={updateStorages} />
|
||||
</div>
|
||||
</>
|
||||
</dialog>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={"flex flex-row-reverse px-4"}>
|
||||
<NewStorageDialog onAdded={updateStorages} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
enum StorageType {
|
||||
@@ -183,14 +225,33 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
response = await network.createLocalStorage(params.name, params.path, params.maxSizeInMB);
|
||||
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) {
|
||||
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);
|
||||
response = await network.createS3Storage(
|
||||
params.name,
|
||||
params.endPoint,
|
||||
params.accessKeyID,
|
||||
params.secretAccessKey,
|
||||
params.bucketName,
|
||||
params.maxSizeInMB,
|
||||
params.domain,
|
||||
);
|
||||
}
|
||||
|
||||
if (response!.success) {
|
||||
@@ -198,165 +259,240 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
message: t("Storage created successfully"),
|
||||
});
|
||||
onAdded();
|
||||
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
|
||||
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>
|
||||
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>
|
||||
<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>
|
||||
<button className={"btn btn-primary"} onClick={handleSubmit} type={"button"}>
|
||||
{isLoading && <span className={"loading loading-spinner loading-sm mr-2"}></span>}
|
||||
{t("Submit")}
|
||||
</button>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
}
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -19,35 +19,72 @@ export default function UserView() {
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
||||
if (!app.user) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||
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 (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not authorized to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
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 }) {
|
||||
function UserTable({
|
||||
page,
|
||||
searchKeyword,
|
||||
totalPagesCallback,
|
||||
}: {
|
||||
page: number;
|
||||
searchKeyword: string;
|
||||
totalPagesCallback: (totalPages: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
|
||||
@@ -61,7 +98,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
||||
showToast({
|
||||
type: "error",
|
||||
message: response.message,
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -73,7 +110,7 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
||||
showToast({
|
||||
type: "error",
|
||||
message: response.message,
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -92,29 +129,31 @@ function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number,
|
||||
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>
|
||||
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 }) {
|
||||
function UserRow({ user, onChanged }: { user: User; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
@@ -139,7 +178,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetAdmin = async () => {
|
||||
if (isLoading) {
|
||||
@@ -160,7 +199,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetUser = async () => {
|
||||
if (isLoading) {
|
||||
@@ -181,7 +220,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetUploadPermission = async () => {
|
||||
if (isLoading) {
|
||||
@@ -202,7 +241,7 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUploadPermission = async () => {
|
||||
if (isLoading) {
|
||||
@@ -223,55 +262,86 @@ function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
|
||||
});
|
||||
}
|
||||
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>}
|
||||
{app.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>
|
||||
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>
|
||||
)}
|
||||
{app.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>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</dialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
@@ -1,238 +1,310 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md";
|
||||
import { MdAdd, MdClose, MdDelete, MdOutlineInfo } from "react-icons/md";
|
||||
import { Tag } from "../network/models.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { app } from "../app.ts";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import {useAppContext} from "../components/AppContext.tsx";
|
||||
import TagInput, {QuickAddTagDialog} from "../components/tag_input.tsx";
|
||||
import {ImageDrapArea, SelectAndUploadImageButton, UploadClipboardImageButton} from "../components/image_selector.tsx";
|
||||
import { useAppContext } from "../components/AppContext.tsx";
|
||||
import TagInput, { QuickAddTagDialog } from "../components/tag_input.tsx";
|
||||
import {
|
||||
ImageDrapArea,
|
||||
SelectAndUploadImageButton,
|
||||
UploadClipboardImageButton,
|
||||
} from "../components/image_selector.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 [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setSubmitting] = useState(false)
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const appContext = useAppContext()
|
||||
const appContext = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Publish Resource");
|
||||
}, [t])
|
||||
}, [t]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
if (!title) {
|
||||
setError(t("Title cannot be empty"))
|
||||
return
|
||||
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
|
||||
setError(t("Alternative title cannot be empty"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!tags || tags.length === 0) {
|
||||
setError(t("At least one tag required"))
|
||||
return
|
||||
setError(t("At least one tag required"));
|
||||
return;
|
||||
}
|
||||
if (!article) {
|
||||
setError(t("Description cannot be empty"))
|
||||
return
|
||||
setError(t("Description cannot be empty"));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true)
|
||||
setSubmitting(true);
|
||||
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)
|
||||
setSubmitting(false);
|
||||
appContext.clear();
|
||||
navigate("/resources/" + res.data!, { replace: true })
|
||||
navigate("/resources/" + res.data!, { replace: true });
|
||||
} else {
|
||||
setSubmitting(false)
|
||||
setError(res.message)
|
||||
setSubmitting(false);
|
||||
setError(res.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!app.user) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
|
||||
return (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not logged in. Please log in to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app.canUpload()) {
|
||||
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
|
||||
return (
|
||||
<ErrorAlert
|
||||
className={"m-4"}
|
||||
message={t("You are not authorized to access this page.")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ImageDrapArea onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}>
|
||||
<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 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 text-sm"}>
|
||||
{tag.name}
|
||||
<span onClick={() => {
|
||||
const newTags = [...tags]
|
||||
newTags.splice(index, 1)
|
||||
setTags(newTags)
|
||||
}}>
|
||||
<MdClose size={18}/>
|
||||
</span>
|
||||
</span>
|
||||
})
|
||||
}
|
||||
</p>
|
||||
<div className={"flex items-center"}>
|
||||
<TagInput onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find(t => t.id === tag.id);
|
||||
if (existingTag) {
|
||||
return prev; // If the tag already exists, do not add it again
|
||||
}
|
||||
return [...prev, tag];
|
||||
})
|
||||
}} />
|
||||
<span className={"w-4"}/>
|
||||
<QuickAddTagDialog onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find(t => t.id === tag.id);
|
||||
if (!existingTag) {
|
||||
newTags.push(tag);
|
||||
}
|
||||
}
|
||||
return newTags;
|
||||
})
|
||||
}}/>
|
||||
</div>
|
||||
<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} />
|
||||
<div>
|
||||
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
|
||||
<p>{t("The first image will be used as the cover image")}</p>
|
||||
return (
|
||||
<ImageDrapArea
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
>
|
||||
<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 can be modified after publishing")}</span>
|
||||
</div>
|
||||
</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>
|
||||
<div className={"flex"}>
|
||||
<SelectAndUploadImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton onUploaded={(images) => {
|
||||
setImages((prev) => ([...prev, ...images]));
|
||||
}}/>
|
||||
</div>
|
||||
<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")}
|
||||
<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 text-sm"}>
|
||||
{tag.name}
|
||||
<span
|
||||
onClick={() => {
|
||||
const newTags = [...tags];
|
||||
newTags.splice(index, 1);
|
||||
setTags(newTags);
|
||||
}}
|
||||
>
|
||||
<MdClose size={18} />
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
<div className={"flex items-center"}>
|
||||
<TagInput
|
||||
onAdd={(tag) => {
|
||||
setTags((prev) => {
|
||||
const existingTag = prev.find((t) => t.id === tag.id);
|
||||
if (existingTag) {
|
||||
return prev; // If the tag already exists, do not add it again
|
||||
}
|
||||
return [...prev, tag];
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"} />
|
||||
<QuickAddTagDialog
|
||||
onAdded={(tags) => {
|
||||
setTags((prev) => {
|
||||
const newTags = [...prev];
|
||||
for (const tag of tags) {
|
||||
const existingTag = newTags.find((t) => t.id === tag.id);
|
||||
if (!existingTag) {
|
||||
newTags.push(tag);
|
||||
}
|
||||
}
|
||||
return newTags;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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} />
|
||||
<div>
|
||||
<p>
|
||||
{t(
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
)}
|
||||
</p>
|
||||
<p>{t("The first image will be used as the cover image")}</p>
|
||||
</div>
|
||||
</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>
|
||||
<div className={"flex"}>
|
||||
<SelectAndUploadImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
<span className={"w-4"}></span>
|
||||
<UploadClipboardImageButton
|
||||
onUploaded={(images) => {
|
||||
setImages((prev) => [...prev, ...images]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</ImageDrapArea>
|
||||
}
|
||||
</ImageDrapArea>
|
||||
);
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
import {FormEvent, useEffect, useState} from "react";
|
||||
import {network} from "../network/network.ts";
|
||||
import {app} from "../app.ts";
|
||||
import {useNavigate} from "react-router";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Turnstile} from "@marsidev/react-turnstile";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { network } from "../network/network.ts";
|
||||
import { app } from "../app.ts";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Turnstile } from "@marsidev/react-turnstile";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const {t} = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -37,7 +37,7 @@ export default function RegisterPage() {
|
||||
app.user = res.data!;
|
||||
app.token = res.data!.token;
|
||||
app.saveData();
|
||||
navigate("/", {replace: true});
|
||||
navigate("/", { replace: true });
|
||||
} else {
|
||||
setError(res.message);
|
||||
setLoading(false);
|
||||
@@ -46,53 +46,87 @@ export default function RegisterPage() {
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Register");
|
||||
}, [t])
|
||||
}, [t]);
|
||||
|
||||
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>
|
||||
{
|
||||
app.cloudflareTurnstileSiteKey && <Turnstile
|
||||
siteKey={app.cloudflareTurnstileSiteKey}
|
||||
onSuccess={setCfToken}
|
||||
onExpire={() => setCfToken("")}
|
||||
/>
|
||||
}
|
||||
<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>
|
||||
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>
|
||||
{app.cloudflareTurnstileSiteKey && (
|
||||
<Turnstile
|
||||
siteKey={app.cloudflareTurnstileSiteKey}
|
||||
onSuccess={setCfToken}
|
||||
onExpire={() => setCfToken("")}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,35 @@
|
||||
import {useSearchParams} from "react-router";
|
||||
import {network} from "../network/network.ts";
|
||||
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";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, _] = useSearchParams()
|
||||
const [params, _] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const keyword = params.get("keyword")
|
||||
const keyword = params.get("keyword");
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Search") + ": " + (keyword || "");
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
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 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>
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@@ -2,18 +2,18 @@ import { useParams } from "react-router";
|
||||
import { ErrorAlert } from "../components/alert.tsx";
|
||||
import ResourcesView from "../components/resources_view.tsx";
|
||||
import { network } from "../network/network.ts";
|
||||
import {useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {Tag} from "../network/models.ts";
|
||||
import { Tag } from "../network/models.ts";
|
||||
import Button from "../components/button.tsx";
|
||||
import Markdown from "react-markdown";
|
||||
import {app} from "../app.ts";
|
||||
import Input, {TextArea} from "../components/input.tsx";
|
||||
import { app } from "../app.ts";
|
||||
import Input, { TextArea } from "../components/input.tsx";
|
||||
import TagInput from "../components/tag_input.tsx";
|
||||
import Badge from "../components/badge.tsx";
|
||||
|
||||
export default function TaggedResourcesPage() {
|
||||
const { tag: tagName } = useParams()
|
||||
const { tag: tagName } = useParams();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function TaggedResourcesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t("Tag: ") + tagName;
|
||||
}, [t, tagName])
|
||||
}, [t, tagName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tagName) {
|
||||
@@ -35,44 +35,59 @@ export default function TaggedResourcesPage() {
|
||||
}, [tagName]);
|
||||
|
||||
if (!tagName) {
|
||||
return <div className={"m-4"}>
|
||||
<ErrorAlert message={t("Tag not found")} />
|
||||
</div>
|
||||
return (
|
||||
<div className={"m-4"}>
|
||||
<ErrorAlert message={t("Tag not found")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className={"flex items-center"}>
|
||||
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
|
||||
{tag?.name ?? tagName}
|
||||
</h1>
|
||||
{
|
||||
(tag && app.canUpload()) && <EditTagButton tag={tag} onEdited={(t) => {
|
||||
setTag(t)
|
||||
}} />
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={"flex items-center"}>
|
||||
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
|
||||
{tag?.name ?? tagName}
|
||||
</h1>
|
||||
{tag && app.canUpload() && (
|
||||
<EditTagButton
|
||||
tag={tag}
|
||||
onEdited={(t) => {
|
||||
setTag(t);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{tag?.type && (
|
||||
<h2 className={"text-base-content/60 ml-2 text-lg pl-2 mb-2"}>
|
||||
{tag.type}
|
||||
</h2>
|
||||
)}
|
||||
<div className={"px-3"}>
|
||||
{(tag?.aliases ?? []).map((e) => {
|
||||
return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>;
|
||||
})}
|
||||
</div>
|
||||
{tag?.description && (
|
||||
<article className={"px-4 py-2"}>
|
||||
<Markdown>{tag.description}</Markdown>
|
||||
</article>
|
||||
)}
|
||||
<ResourcesView
|
||||
loader={(page) => {
|
||||
return network.getResourcesByTag(tagName, page);
|
||||
}}
|
||||
></ResourcesView>
|
||||
</div>
|
||||
{tag?.type && <h2 className={"text-base-content/60 ml-2 text-lg pl-2 mb-2"}>{tag.type}</h2>}
|
||||
<div className={"px-3"}>
|
||||
{
|
||||
(tag?.aliases ?? []).map((e) => {
|
||||
return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
{
|
||||
tag?.description && <article className={"px-4 py-2"}>
|
||||
<Markdown>
|
||||
{tag.description}
|
||||
</Markdown>
|
||||
</article>
|
||||
}
|
||||
<ResourcesView loader={(page) => {
|
||||
return network.getResourcesByTag(tagName, page)
|
||||
}}></ResourcesView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }) {
|
||||
function EditTagButton({
|
||||
tag,
|
||||
onEdited,
|
||||
}: {
|
||||
tag: Tag;
|
||||
onEdited: (t: Tag) => void;
|
||||
}) {
|
||||
const [description, setDescription] = useState(tag.description);
|
||||
const [isAlias, setIsAlias] = useState(false);
|
||||
const [aliasOf, setAliasOf] = useState<Tag | null>(null);
|
||||
@@ -82,7 +97,7 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(tag.description)
|
||||
setDescription(tag.description);
|
||||
}, [tag.description]);
|
||||
|
||||
const submit = async () => {
|
||||
@@ -92,10 +107,17 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const res = await network.setTagInfo(tag.id, description, aliasOf?.id ?? null, type);
|
||||
const res = await network.setTagInfo(
|
||||
tag.id,
|
||||
description,
|
||||
aliasOf?.id ?? null,
|
||||
type,
|
||||
);
|
||||
setIsLoading(false);
|
||||
if (res.success) {
|
||||
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
|
||||
const dialog = document.getElementById(
|
||||
"edit_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.close();
|
||||
onEdited(res.data!);
|
||||
} else {
|
||||
@@ -103,50 +125,87 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }
|
||||
}
|
||||
};
|
||||
|
||||
return <>
|
||||
<Button onClick={()=> {
|
||||
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}>{t("Edit")}</Button>
|
||||
<dialog id="edit_tag_dialog" className="modal">
|
||||
<div className="modal-box" style={{
|
||||
overflowY: "initial"
|
||||
}}>
|
||||
<h3 className="font-bold text-lg">{t("Edit Tag")}</h3>
|
||||
<div className={"flex py-3"}>
|
||||
<p className={"flex-1"}>The tag is an alias of another tag</p>
|
||||
<input type="checkbox" className="toggle toggle-primary" checked={isAlias} onChange={(e) => {
|
||||
setIsAlias(e.target.checked);
|
||||
}}/>
|
||||
</div>
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const dialog = document.getElementById(
|
||||
"edit_tag_dialog",
|
||||
) as HTMLDialogElement;
|
||||
dialog.showModal();
|
||||
}}
|
||||
>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
<dialog id="edit_tag_dialog" className="modal">
|
||||
<div
|
||||
className="modal-box"
|
||||
style={{
|
||||
overflowY: "initial",
|
||||
}}
|
||||
>
|
||||
<h3 className="font-bold text-lg">{t("Edit Tag")}</h3>
|
||||
<div className={"flex py-3"}>
|
||||
<p className={"flex-1"}>The tag is an alias of another tag</p>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary"
|
||||
checked={isAlias}
|
||||
onChange={(e) => {
|
||||
setIsAlias(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{
|
||||
isAlias ? <>
|
||||
{
|
||||
aliasOf && <div className={"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"}>
|
||||
{isAlias ? (
|
||||
<>
|
||||
{aliasOf && (
|
||||
<div
|
||||
className={
|
||||
"py-2 border border-base-300 rounded-3xl mt-2 px-4 flex mb-4"
|
||||
}
|
||||
>
|
||||
<p className={"flex-1"}>Alias Of: </p>
|
||||
<Badge>{aliasOf.name}</Badge>
|
||||
</div>
|
||||
}
|
||||
<TagInput mainTag={true} onAdd={(tag: Tag) => {
|
||||
setAliasOf(tag);
|
||||
}}/>
|
||||
</> : <>
|
||||
<Input value={type} onChange={(e) => setType(e.target.value)} label={"Type"}/>
|
||||
<TextArea label={"Description"} value={description} onChange={(e) => setDescription(e.target.value)}/>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
<TagInput
|
||||
mainTag={true}
|
||||
onAdd={(tag: Tag) => {
|
||||
setAliasOf(tag);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
label={"Type"}
|
||||
/>
|
||||
<TextArea
|
||||
label={"Description"}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Close")}</Button>
|
||||
</form>
|
||||
<Button isLoading={isLoading} className={"btn-primary"} onClick={submit}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{error && <ErrorAlert className={"mt-2"} message={error} />}
|
||||
<div className="modal-action">
|
||||
<form method="dialog">
|
||||
<Button className="btn">{t("Close")}</Button>
|
||||
</form>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
className={"btn-primary"}
|
||||
onClick={submit}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import {TagWithCount} from "../network/models.ts";
|
||||
import {useEffect, useState} from "react";
|
||||
import {network} from "../network/network.ts";
|
||||
import { TagWithCount } from "../network/models.ts";
|
||||
import { useEffect, useState } from "react";
|
||||
import { network } from "../network/network.ts";
|
||||
import showToast from "../components/toast.ts";
|
||||
import Loading from "../components/loading.tsx";
|
||||
import Badge from "../components/badge.tsx";
|
||||
import {useNavigate} from "react-router";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
export default function TagsPage() {
|
||||
const [tags, setTags] = useState<TagWithCount[] | null>(null);
|
||||
@@ -16,22 +16,22 @@ export default function TagsPage() {
|
||||
} else {
|
||||
showToast({
|
||||
type: "error",
|
||||
message: res.message || "Failed to load tags"
|
||||
})
|
||||
message: res.message || "Failed to load tags",
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
}, []);
|
||||
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!tags) {
|
||||
return <Loading/>
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const tagsMap = new Map<string, TagWithCount[]>();
|
||||
|
||||
for (const tag of tags || []) {
|
||||
const type = tag.type
|
||||
const type = tag.type;
|
||||
if (!tagsMap.has(type)) {
|
||||
tagsMap.set(type, []);
|
||||
}
|
||||
@@ -42,21 +42,30 @@ export default function TagsPage() {
|
||||
tags.sort((a, b) => b.resources_count - a.resources_count);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-4 p-4">
|
||||
<h1 className={"text-2xl font-bold py-2"}>Tags</h1>
|
||||
{Array.from(tagsMap.entries()).map(([type, tags]) => (
|
||||
<div key={type} className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-bold pl-1">{type == "" ? "Other" : type}</h2>
|
||||
<p>
|
||||
{tags.map(tag => (
|
||||
<Badge onClick={() => {
|
||||
navigate(`/tag/${tag.name}`);
|
||||
}} key={tag.name} className={"m-1 cursor-pointer badge-soft badge-primary"}>
|
||||
{tag.name + (tag.resources_count > 0 ? ` (${tag.resources_count})` : "")}
|
||||
</Badge>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<h1 className={"text-2xl font-bold py-2"}>Tags</h1>
|
||||
{Array.from(tagsMap.entries()).map(([type, tags]) => (
|
||||
<div key={type} className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-bold pl-1">
|
||||
{type == "" ? "Other" : type}
|
||||
</h2>
|
||||
<p>
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
onClick={() => {
|
||||
navigate(`/tag/${tag.name}`);
|
||||
}}
|
||||
key={tag.name}
|
||||
className={"m-1 cursor-pointer badge-soft badge-primary"}
|
||||
>
|
||||
{tag.name +
|
||||
(tag.resources_count > 0 ? ` (${tag.resources_count})` : "")}
|
||||
</Badge>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -23,7 +23,7 @@ export default function UserPage() {
|
||||
showToast({
|
||||
message: res.message,
|
||||
type: "error",
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [username]);
|
||||
@@ -33,54 +33,83 @@ export default function UserPage() {
|
||||
}, [username]);
|
||||
|
||||
if (!user) {
|
||||
return <div className="w-full">
|
||||
<Loading />
|
||||
</div>;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>
|
||||
<UserCard user={user!} />
|
||||
<div role="tablist" className="border-b border-base-300 mx-2 flex">
|
||||
<div role="tab" className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 0 ? "border-primary text-primary" : "text-base-content/80"} transition-all`} onClick={() => setPage(0)}>Resources</div>
|
||||
<div role="tab" className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 1 ? "border-primary text-primary" : "text-base-content/80"}`} onClick={() => setPage(1)}>Comments</div>
|
||||
return (
|
||||
<div>
|
||||
<UserCard user={user!} />
|
||||
<div role="tablist" className="border-b border-base-300 mx-2 flex">
|
||||
<div
|
||||
role="tab"
|
||||
className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 0 ? "border-primary text-primary" : "text-base-content/80"} transition-all`}
|
||||
onClick={() => setPage(0)}
|
||||
>
|
||||
Resources
|
||||
</div>
|
||||
<div
|
||||
role="tab"
|
||||
className={`text-sm py-2 px-4 cursor-pointer border-b-2 border-base-100 ${page === 1 ? "border-primary text-primary" : "text-base-content/80"}`}
|
||||
onClick={() => setPage(1)}
|
||||
>
|
||||
Comments
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{page === 0 && <UserResources user={user} />}
|
||||
{page === 1 && <UserComments user={user} />}
|
||||
</div>
|
||||
<div className="h-16"></div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
{page === 0 && <UserResources user={user} />}
|
||||
{page === 1 && <UserComments user={user} />}
|
||||
</div>
|
||||
<div className="h-16"></div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
return <div className={"flex m-4 items-center"}>
|
||||
<div className={"avatar py-2"}>
|
||||
<div className="w-24 rounded-full ring-2 ring-offset-2 ring-primary ring-offset-base-100">
|
||||
<img src={network.getUserAvatar(user)} />
|
||||
return (
|
||||
<div className={"flex m-4 items-center"}>
|
||||
<div className={"avatar py-2"}>
|
||||
<div className="w-24 rounded-full ring-2 ring-offset-2 ring-primary ring-offset-base-100">
|
||||
<img src={network.getUserAvatar(user)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-6"></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{user.username}</h1>
|
||||
<div className="h-4"></div>
|
||||
{user.bio.trim() !== "" ? (
|
||||
<p className="text-sm text-base-content/80">{user.bio.trim()}</p>
|
||||
) : (
|
||||
<p>
|
||||
<span className="text-sm font-bold mr-1">
|
||||
{" "}
|
||||
{user.uploads_count}
|
||||
</span>
|
||||
<span className="text-sm">Resources</span>
|
||||
<span className="mx-2"></span>
|
||||
<span className="text-sm font-bold mr-1">
|
||||
{" "}
|
||||
{user.comments_count}
|
||||
</span>
|
||||
<span className="text-base-content text-sm">Comments</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-6"></div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{user.username}</h1>
|
||||
<div className="h-4"></div>
|
||||
{user.bio.trim() !== ""
|
||||
? <p className="text-sm text-base-content/80">{user.bio.trim()}</p>
|
||||
: <p>
|
||||
<span className="text-sm font-bold mr-1"> {user.uploads_count}</span>
|
||||
<span className="text-sm">Resources</span>
|
||||
<span className="mx-2"></span>
|
||||
<span className="text-sm font-bold mr-1"> {user.comments_count}</span>
|
||||
<span className="text-base-content text-sm">Comments</span>
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserResources({ user }: { user: User }) {
|
||||
return <ResourcesView loader={(page) => {
|
||||
return network.getResourcesByUser(user.username, page);
|
||||
}}></ResourcesView>
|
||||
return (
|
||||
<ResourcesView
|
||||
loader={(page) => {
|
||||
return network.getResourcesByUser(user.username, page);
|
||||
}}
|
||||
></ResourcesView>
|
||||
);
|
||||
}
|
||||
|
||||
function UserComments({ user }: { user: User }) {
|
||||
@@ -88,18 +117,30 @@ function UserComments({ user }: { user: User }) {
|
||||
|
||||
const [maxPage, setMaxPage] = useState(0);
|
||||
|
||||
return <div className="px-2">
|
||||
<CommentsList username={user.username} page={page} maxPageCallback={setMaxPage} />
|
||||
{maxPage ? <div className={"w-full flex justify-center"}>
|
||||
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
||||
</div> : null}
|
||||
</div>
|
||||
return (
|
||||
<div className="px-2">
|
||||
<CommentsList
|
||||
username={user.username}
|
||||
page={page}
|
||||
maxPageCallback={setMaxPage}
|
||||
/>
|
||||
{maxPage ? (
|
||||
<div className={"w-full flex justify-center"}>
|
||||
<Pagination page={page} setPage={setPage} totalPages={maxPage} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsList({ username, page, maxPageCallback }: {
|
||||
username: string,
|
||||
page: number,
|
||||
maxPageCallback: (maxPage: number) => void
|
||||
function CommentsList({
|
||||
username,
|
||||
page,
|
||||
maxPageCallback,
|
||||
}: {
|
||||
username: string;
|
||||
page: number;
|
||||
maxPageCallback: (maxPage: number) => void;
|
||||
}) {
|
||||
const [comments, setComments] = useState<CommentWithResource[] | null>(null);
|
||||
|
||||
@@ -118,43 +159,50 @@ function CommentsList({ username, page, maxPageCallback }: {
|
||||
}, [maxPageCallback, page, username]);
|
||||
|
||||
if (comments == null) {
|
||||
return <div className={"w-full"}>
|
||||
<Loading />
|
||||
</div>
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>
|
||||
{
|
||||
comments.map((comment) => {
|
||||
return <CommentTile comment={comment} key={comment.id} />
|
||||
})
|
||||
}
|
||||
</>
|
||||
return (
|
||||
<>
|
||||
{comments.map((comment) => {
|
||||
return <CommentTile comment={comment} key={comment.id} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentTile({ comment }: { comment: CommentWithResource }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return <div className={"card card-border border-base-300 p-2 my-3"}>
|
||||
<div className={"flex flex-row items-center my-1 mx-1"}>
|
||||
<div className="avatar">
|
||||
<div className="w-8 rounded-full">
|
||||
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
|
||||
return (
|
||||
<div className={"card card-border border-base-300 p-2 my-3"}>
|
||||
<div className={"flex flex-row items-center my-1 mx-1"}>
|
||||
<div className="avatar">
|
||||
<div className="w-8 rounded-full">
|
||||
<img src={network.getUserAvatar(comment.user)} alt={"avatar"} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={"w-2"}></div>
|
||||
<div className={"text-sm font-bold"}>{comment.user.username}</div>
|
||||
<div className={"grow"}></div>
|
||||
<div className={"text-sm text-gray-500"}>
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={"w-2"}></div>
|
||||
<div className={"text-sm font-bold"}>{comment.user.username}</div>
|
||||
<div className={"grow"}></div>
|
||||
<div className={"text-sm text-gray-500"}>{new Date(comment.created_at).toLocaleString()}</div>
|
||||
<div className={"p-2"}>{comment.content}</div>
|
||||
<a
|
||||
className="text-sm text-base-content/80 p-1 hover:text-primary cursor-pointer transition-all"
|
||||
onClick={() => {
|
||||
navigate("/resources/" + comment.resource.id);
|
||||
}}
|
||||
>
|
||||
<MdOutlineArrowRight className="inline-block mr-1 mb-0.5" size={18} />
|
||||
{comment.resource.title}
|
||||
</a>
|
||||
</div>
|
||||
<div className={"p-2"}>
|
||||
{comment.content}
|
||||
</div>
|
||||
<a className="text-sm text-base-content/80 p-1 hover:text-primary cursor-pointer transition-all" onClick={() => {
|
||||
navigate("/resources/" + comment.resource.id);
|
||||
}}>
|
||||
<MdOutlineArrowRight className="inline-block mr-1 mb-0.5" size={18} />
|
||||
{comment.resource.title}
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
);
|
||||
}
|
||||
|
Reference in New Issue
Block a user